Merge pull request #115 from maziggy/0.1.6b10

v0.1.6b10

### New Features
- **Unified Print Modal** - Consolidated three separate modals into one unified component:
  - Single modal handles reprint, add-to-queue, and edit-queue-item operations
  - Consistent UI/UX across all print operations
  - Reduced code duplication (~1300 LOC removed)
- **Multi-Printer Selection** - Send prints or queue items to multiple printers at once:
  - Checkbox selection for multiple printers in reprint and add-to-queue modes
  - "Select all" / "Clear" buttons for quick selection
  - Progress indicator during multi-printer submission
  - Ideal for print farms with identical filament configurations
- **Per-Printer AMS Mapping** - Configure filament slot mapping individually for each printer:
  - Enable "Custom mapping" checkbox under each selected printer
  - Auto-configure uses RFID data to match filaments automatically
  - Manual override for specific slot assignments
  - Match status indicator shows exact/partial/missing matches
  - Re-read button to refresh printer's loaded filaments
  - New setting in Settings → Filament to expand custom mapping by default
- **Enhanced Add-to-Queue** - Now includes plate selection and print options:
  - Configure all print settings upfront instead of editing afterward
  - Filament mapping with manual override capability
- **Print from File Manager** - Full print configuration when printing from library files:
  - Plate selection for multi-plate 3MF files with thumbnails
  - Filament slot mapping with comparison to loaded filaments
  - All print options (bed levelling, flow calibration, etc.)
- **File Manager Print Button** - Print directly from multi-selection toolbar:
  - "Print" button appears when exactly one sliced file is selected
  - Opens full PrintModal with plate selection and print options
  - "Add to Queue" button now uses Clock icon for clarity
- **Multiple Embedded Camera Viewers** - Open camera streams for multiple printers simultaneously in embedded mode:
  - Each viewer has its own remembered position and size
  - New viewers are automatically offset to prevent stacking
  - Printer-specific persistence in localStorage
  - **Navigation persistence** - Open cameras stay open when navigating away and back to Printers page
- **Application Log Viewer** - View and filter application logs in real-time from System Information page:
  - Start/Stop live streaming with 2-second auto-refresh
  - Filter by log level (DEBUG, INFO, WARNING, ERROR)
  - Text search across messages and logger names
  - Clear logs with one click
  - Expandable multi-line log entries (stack traces, etc.)
  - Auto-scroll to follow new entries
- **Deferred archive creation** - Queue items from File Manager no longer create archives upfront:
  - Queue items store `library_file_id` directly
  - Archives are created automatically when prints start
  - Reduces clutter in Archives from unprinted queued files
  - Queue displays library file name, thumbnail, and print time
- **Expandable Color Picker** - Configure AMS Slot modal now has an expandable color palette:
  - 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray)
  - Click "+" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream)
  - Click "-" to collapse back to basic colors
- **File Manager Sorting** - Printer file manager now has sorting options:
  - Sort by name (A-Z or Z-A)
  - Sort by size (smallest or largest first)
  - Sort by date (oldest or newest first)
  - Directories always sorted first
- **Camera View Mode Setting** - Choose how camera streams open:
  - "New Window" (default): Opens camera in a separate browser window
  - "Embedded": Shows camera as a floating overlay on the main screen
  - Embedded viewer is draggable and resizable with persistent position/size
  - Configure in Settings → General → Camera section
- **File Manager Rename** - Rename files and folders directly in File Manager:
  - Right-click context menu "Rename" option for files and folders
  - Inline rename button in list view
  - Validates filenames (no path separators allowed)
- **File Manager Mobile Accessibility** - Improved touch device support:
  - Three-dot menu button always visible on mobile (hover-only on desktop)
  - Selection checkbox always visible on mobile devices
  - Better PWA experience for file management

### Changed
- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
- **Edit Queue Item button** - Changed from "Print to X Printers" to "Save"

### Fixed
- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
  - Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
  - Folder navigation now works correctly without resetting
- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
- **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:
  - Now correctly derives `tray_info_idx` from the preset's `base_id` when `filament_id` is null
  - User presets that inherit from Bambu presets (e.g., "# Overture Matte PLA @BBL H2D") now work correctly
- **Faster AMS slot updates** - Frontend now updates immediately after configuring AMS slots:
  - Added WebSocket broadcast to AMS change callback for instant UI updates
  - Removed unnecessary delayed refetch that was causing slow updates
This commit is contained in:
MartinNYHC
2026-01-21 09:44:35 +01:00
committed by GitHub
64 changed files with 16352 additions and 11510 deletions
+86
View File
@@ -2,6 +2,92 @@
All notable changes to Bambuddy will be documented in this file.
## [0.1.6b10] - 2026-01-21
### New Features
- **Unified Print Modal** - Consolidated three separate modals into one unified component:
- Single modal handles reprint, add-to-queue, and edit-queue-item operations
- Consistent UI/UX across all print operations
- Reduced code duplication (~1300 LOC removed)
- **Multi-Printer Selection** - Send prints or queue items to multiple printers at once:
- Checkbox selection for multiple printers in reprint and add-to-queue modes
- "Select all" / "Clear" buttons for quick selection
- Progress indicator during multi-printer submission
- Ideal for print farms with identical filament configurations
- **Per-Printer AMS Mapping** - Configure filament slot mapping individually for each printer:
- Enable "Custom mapping" checkbox under each selected printer
- Auto-configure uses RFID data to match filaments automatically
- Manual override for specific slot assignments
- Match status indicator shows exact/partial/missing matches
- Re-read button to refresh printer's loaded filaments
- New setting in Settings → Filament to expand custom mapping by default
- **Enhanced Add-to-Queue** - Now includes plate selection and print options:
- Configure all print settings upfront instead of editing afterward
- Filament mapping with manual override capability
- **Print from File Manager** - Full print configuration when printing from library files:
- Plate selection for multi-plate 3MF files with thumbnails
- Filament slot mapping with comparison to loaded filaments
- All print options (bed levelling, flow calibration, etc.)
- **File Manager Print Button** - Print directly from multi-selection toolbar:
- "Print" button appears when exactly one sliced file is selected
- Opens full PrintModal with plate selection and print options
- "Add to Queue" button now uses Clock icon for clarity
- **Multiple Embedded Camera Viewers** - Open camera streams for multiple printers simultaneously in embedded mode:
- Each viewer has its own remembered position and size
- New viewers are automatically offset to prevent stacking
- Printer-specific persistence in localStorage
- **Navigation persistence** - Open cameras stay open when navigating away and back to Printers page
- **Application Log Viewer** - View and filter application logs in real-time from System Information page:
- Start/Stop live streaming with 2-second auto-refresh
- Filter by log level (DEBUG, INFO, WARNING, ERROR)
- Text search across messages and logger names
- Clear logs with one click
- Expandable multi-line log entries (stack traces, etc.)
- Auto-scroll to follow new entries
- **Deferred archive creation** - Queue items from File Manager no longer create archives upfront:
- Queue items store `library_file_id` directly
- Archives are created automatically when prints start
- Reduces clutter in Archives from unprinted queued files
- Queue displays library file name, thumbnail, and print time
- **Expandable Color Picker** - Configure AMS Slot modal now has an expandable color palette:
- 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray)
- Click "+" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream)
- Click "-" to collapse back to basic colors
- **File Manager Sorting** - Printer file manager now has sorting options:
- Sort by name (A-Z or Z-A)
- Sort by size (smallest or largest first)
- Sort by date (oldest or newest first)
- Directories always sorted first
- **Camera View Mode Setting** - Choose how camera streams open:
- "New Window" (default): Opens camera in a separate browser window
- "Embedded": Shows camera as a floating overlay on the main screen
- Embedded viewer is draggable and resizable with persistent position/size
- Configure in Settings → General → Camera section
- **File Manager Rename** - Rename files and folders directly in File Manager:
- Right-click context menu "Rename" option for files and folders
- Inline rename button in list view
- Validates filenames (no path separators allowed)
- **File Manager Mobile Accessibility** - Improved touch device support:
- Three-dot menu button always visible on mobile (hover-only on desktop)
- Selection checkbox always visible on mobile devices
- Better PWA experience for file management
### Changed
- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
- **Edit Queue Item button** - Changed from "Print to X Printers" to "Save"
### Fixed
- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
- Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
- Folder navigation now works correctly without resetting
- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
- **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:
- Now correctly derives `tray_info_idx` from the preset's `base_id` when `filament_id` is null
- User presets that inherit from Bambu presets (e.g., "# Overture Matte PLA @BBL H2D") now work correctly
- **Faster AMS slot updates** - Frontend now updates immediately after configuring AMS slots:
- Added WebSocket broadcast to AMS change callback for instant UI updates
- Removed unnecessary delayed refetch that was causing slow updates
## [0.1.6b9] - 2026-01-19
### New Features
+5 -3
View File
@@ -43,13 +43,15 @@ RUN mkdir -p /app/data /app/logs
ENV PYTHONUNBUFFERED=1
ENV DATA_DIR=/app/data
ENV LOG_DIR=/app/logs
ENV PORT=8000
EXPOSE 8000
# Health check
# Health check (uses PORT env var via shell)
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD python -c "import urllib.request, os; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\", \"8000\")}/health')" || exit 1
# Run the application
# Use standard asyncio loop (uvloop has permission issues in some Docker environments)
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000", "--loop", "asyncio"]
# Port is configurable via PORT environment variable (default: 8000)
CMD ["sh", "-c", "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio"]
+18 -1
View File
@@ -56,12 +56,13 @@
### 📊 Monitoring & Control
- Real-time printer status via WebSocket
- Live camera streaming (MJPEG) & snapshots
- Live camera streaming (MJPEG) & snapshots with multi-viewer support
- Fan status monitoring (part cooling, auxiliary, chamber)
- Printer control (stop, pause, resume, chamber light)
- Resizable printer cards (S/M/L/XL)
- Skip objects during print
- AMS slot RFID re-read
- AMS slot configuration (custom presets, K profiles, color picker)
- HMS error monitoring with history
- Print success rates & trends
- Filament usage tracking
@@ -70,6 +71,8 @@
### ⏰ Scheduling & Automation
- Print queue with drag-and-drop
- Multi-printer selection (send to multiple printers at once)
- Per-printer AMS mapping (individual slot configuration for print farms)
- Scheduled prints (date/time)
- Queue Only mode (stage without auto-start)
- Smart plug integration (Tasmota, Home Assistant)
@@ -77,6 +80,16 @@
- Auto power-on before print
- Auto power-off after cooldown
### 📁 File Manager (Library)
- Upload and organize sliced files (3MF, gcode)
- Folder structure with drag-and-drop
- Rename files and folders via context menu
- Print directly to any printer with full options
- Add to queue without creating archive upfront
- Plate selection for multi-plate 3MF files
- Duplicate detection via file hash
- Mobile-friendly with always-visible action buttons
### 📁 Projects
- Group related prints (e.g., "Voron Build")
- Track plates (print jobs) and parts separately
@@ -118,6 +131,7 @@
- File manager for printer storage
- Firmware update helper (LAN-only printers)
- Debug logging toggle with live indicator
- Live application log viewer with filtering
- Support bundle generator (privacy-filtered)
</td>
@@ -288,6 +302,8 @@ Open **http://localhost:8000** in your browser.
> **macOS/Windows users:** Docker Desktop doesn't support `network_mode: host`. Edit docker-compose.yml: comment out `network_mode: host` and uncomment the `ports:` section. Printer discovery won't work - add printers manually by IP.
> **Linux users:** If you get "permission denied" errors, either prefix commands with `sudo` (e.g., `sudo docker compose up -d`) or [add your user to the docker group](https://docs.docker.com/engine/install/linux-postinstall/).
<details>
<summary><strong>Docker Configuration & Commands</strong></summary>
@@ -296,6 +312,7 @@ Open **http://localhost:8000** in your browser.
| Variable | Default | Description |
|----------|---------|-------------|
| `TZ` | `UTC` | Your timezone (e.g., `America/New_York`, `Europe/Berlin`) |
| `PORT` | `8000` | Port BamBuddy runs on (with host networking mode) |
| `DEBUG` | `false` | Enable debug logging |
| `LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
+43 -6
View File
@@ -2010,14 +2010,39 @@ async def get_archive_plates(
plate_indices.sort()
# Parse model_settings.config for plate names
# Plate names are stored with plater_id and plater_name keys
plate_names = {} # plater_id -> name
if "Metadata/model_settings.config" in namelist:
try:
model_content = zf.read("Metadata/model_settings.config").decode()
model_root = ET.fromstring(model_content)
for plate_elem in model_root.findall(".//plate"):
plater_id = None
plater_name = None
for meta in plate_elem.findall("metadata"):
key = meta.get("key")
value = meta.get("value")
if key == "plater_id" and value:
try:
plater_id = int(value)
except ValueError:
pass
elif key == "plater_name" and value:
plater_name = value.strip()
if plater_id is not None and plater_name:
plate_names[plater_id] = plater_name
except Exception:
pass # model_settings.config parsing is optional
# Parse slice_info.config for plate metadata
plate_metadata = {} # plate_index -> {filaments, prediction, weight, name}
plate_metadata = {} # plate_index -> {filaments, prediction, weight, name, objects}
if "Metadata/slice_info.config" in namelist:
content = zf.read("Metadata/slice_info.config").decode()
root = ET.fromstring(content)
for plate_elem in root.findall(".//plate"):
plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None}
plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
# Get plate index from metadata
plate_index = None
@@ -2067,12 +2092,23 @@ async def get_archive_plates(
# Sort filaments by slot ID
plate_info["filaments"].sort(key=lambda x: x["slot_id"])
# Get first object name as plate name hint
first_obj = plate_elem.find("object")
if first_obj is not None:
plate_info["name"] = first_obj.get("name")
# Collect all object names on this plate
for obj_elem in plate_elem.findall("object"):
obj_name = obj_elem.get("name")
if obj_name and obj_name not in plate_info["objects"]:
plate_info["objects"].append(obj_name)
# Set plate name: prefer custom name from model_settings.config,
# fall back to first object name if no custom name was set
if plate_index is not None:
custom_name = plate_names.get(plate_index)
if custom_name:
plate_info["name"] = custom_name
else:
# Fall back to first object name as hint
if plate_info["objects"]:
plate_info["name"] = plate_info["objects"][0]
plate_metadata[plate_index] = plate_info
# Build plate list
@@ -2084,6 +2120,7 @@ async def get_archive_plates(
{
"index": idx,
"name": meta.get("name"),
"objects": meta.get("objects", []),
"has_thumbnail": has_thumbnail,
"thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
if has_thumbnail
+526 -27
View File
@@ -9,7 +9,7 @@ import shutil
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from fastapi.responses import FileResponse as FastAPIFileResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,6 +30,7 @@ from backend.app.schemas.library import (
FileDuplicate,
FileListResponse,
FileMoveRequest,
FilePrintRequest,
FileResponse as FileResponseSchema,
FileUpdate,
FileUploadResponse,
@@ -197,8 +198,11 @@ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", "
@router.get("/folders", response_model=list[FolderTreeItem])
@router.get("/folders/", response_model=list[FolderTreeItem])
async def list_folders(db: AsyncSession = Depends(get_db)):
async def list_folders(response: Response, db: AsyncSession = Depends(get_db)):
"""Get all folders as a tree structure."""
# Prevent browser caching of folder list
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
# Get all folders with project and archive joins
result = await db.execute(
select(LibraryFolder, Project.name, PrintArchive.print_name)
@@ -540,6 +544,7 @@ async def delete_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
@router.get("/files", response_model=list[FileListResponse])
@router.get("/files/", response_model=list[FileListResponse])
async def list_files(
response: Response,
folder_id: int | None = None,
include_root: bool = True,
db: AsyncSession = Depends(get_db),
@@ -574,7 +579,10 @@ async def list_files(
)
hash_counts = {h: c - 1 for h, c in dup_result.all()} # -1 to exclude self
response = []
# Prevent browser caching of file list
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
file_list = []
for f in files:
# Extract key metadata for display
print_name = None
@@ -585,7 +593,7 @@ async def list_files(
print_time = f.file_metadata.get("print_time_seconds")
filament_grams = f.file_metadata.get("filament_used_grams")
response.append(
file_list.append(
FileListResponse(
id=f.id,
folder_id=f.folder_id,
@@ -602,7 +610,7 @@ async def list_files(
)
)
return response
return file_list
@router.post("/files", response_model=FileUploadResponse)
@@ -755,10 +763,7 @@ async def add_files_to_queue(
"""Add library files to the print queue.
Only sliced files (.gcode or .gcode.3mf) can be added to the queue.
For each file:
1. Validates it's a sliced file
2. Creates an archive from the library file
3. Creates a queue item pointing to that archive
The archive will be created automatically when the print starts.
"""
added: list[AddToQueueResult] = []
errors: list[AddToQueueError] = []
@@ -771,8 +776,6 @@ async def add_files_to_queue(
pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
max_position = pos_result.scalar() or 0
archive_service = ArchiveService(db)
for file_id in request.file_ids:
lib_file = files.get(file_id)
@@ -792,7 +795,7 @@ async def add_files_to_queue(
continue
try:
# Get the full file path
# Verify file exists on disk
file_path = Path(app_settings.base_dir) / lib_file.file_path
if not file_path.exists():
@@ -801,23 +804,11 @@ async def add_files_to_queue(
)
continue
# Create archive from the library file
archive = await archive_service.archive_print(
printer_id=None, # Unassigned
source_file=file_path,
)
if not archive:
errors.append(
AddToQueueError(file_id=file_id, filename=lib_file.filename, error="Failed to create archive")
)
continue
# Create queue item
# Create queue item referencing library file (archive created at print start)
max_position += 1
queue_item = PrintQueueItem(
printer_id=None, # Unassigned
archive_id=archive.id,
library_file_id=file_id,
position=max_position,
status="pending",
)
@@ -830,7 +821,6 @@ async def add_files_to_queue(
file_id=file_id,
filename=lib_file.filename,
queue_item_id=queue_item.id,
archive_id=archive.id,
)
)
@@ -843,6 +833,509 @@ async def add_files_to_queue(
return AddToQueueResponse(added=added, errors=errors)
@router.get("/files/{file_id}/plates")
async def get_library_file_plates(
file_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get available plates from a multi-plate 3MF library file.
Returns a list of plates with their index, name, thumbnail availability,
and filament requirements. For single-plate exports, returns a single plate.
"""
import xml.etree.ElementTree as ET
import zipfile
# Get the library file
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
lib_file = result.scalar_one_or_none()
if not lib_file:
raise HTTPException(status_code=404, detail="File not found")
file_path = Path(app_settings.base_dir) / lib_file.file_path
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
# Only 3MF files have plates
if not lib_file.filename.lower().endswith(".3mf"):
return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
plates = []
try:
with zipfile.ZipFile(file_path, "r") as zf:
namelist = zf.namelist()
# Find all plate gcode files to determine available plates
gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
if not gcode_files:
# No sliced plates found
return {"file_id": file_id, "filename": lib_file.filename, "plates": [], "is_multi_plate": False}
# Extract plate indices from gcode filenames
plate_indices = []
for gf in gcode_files:
try:
plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
plate_indices.append(int(plate_str))
except ValueError:
pass
plate_indices.sort()
# Parse model_settings.config for plate names
plate_names = {}
if "Metadata/model_settings.config" in namelist:
try:
model_content = zf.read("Metadata/model_settings.config").decode()
model_root = ET.fromstring(model_content)
for plate_elem in model_root.findall(".//plate"):
plater_id = None
plater_name = None
for meta in plate_elem.findall("metadata"):
key = meta.get("key")
value = meta.get("value")
if key == "plater_id" and value:
try:
plater_id = int(value)
except ValueError:
pass
elif key == "plater_name" and value:
plater_name = value.strip()
if plater_id is not None and plater_name:
plate_names[plater_id] = plater_name
except Exception:
pass
# Parse slice_info.config for plate metadata
plate_metadata = {}
if "Metadata/slice_info.config" in namelist:
content = zf.read("Metadata/slice_info.config").decode()
root = ET.fromstring(content)
for plate_elem in root.findall(".//plate"):
plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
plate_index = None
for meta in plate_elem.findall("metadata"):
key = meta.get("key")
value = meta.get("value")
if key == "index" and value:
try:
plate_index = int(value)
except ValueError:
pass
elif key == "prediction" and value:
try:
plate_info["prediction"] = int(value)
except ValueError:
pass
elif key == "weight" and value:
try:
plate_info["weight"] = float(value)
except ValueError:
pass
# Get filaments used in this plate
for filament_elem in plate_elem.findall("filament"):
filament_id = filament_elem.get("id")
filament_type = filament_elem.get("type", "")
filament_color = filament_elem.get("color", "")
used_g = filament_elem.get("used_g", "0")
used_m = filament_elem.get("used_m", "0")
try:
used_grams = float(used_g)
except (ValueError, TypeError):
used_grams = 0
if used_grams > 0 and filament_id:
plate_info["filaments"].append(
{
"slot_id": int(filament_id),
"type": filament_type,
"color": filament_color,
"used_grams": round(used_grams, 1),
"used_meters": float(used_m) if used_m else 0,
}
)
plate_info["filaments"].sort(key=lambda x: x["slot_id"])
# Collect object names
for obj_elem in plate_elem.findall("object"):
obj_name = obj_elem.get("name")
if obj_name and obj_name not in plate_info["objects"]:
plate_info["objects"].append(obj_name)
# Set plate name
if plate_index is not None:
custom_name = plate_names.get(plate_index)
if custom_name:
plate_info["name"] = custom_name
elif plate_info["objects"]:
plate_info["name"] = plate_info["objects"][0]
plate_metadata[plate_index] = plate_info
# Build plate list
for idx in plate_indices:
meta = plate_metadata.get(idx, {})
has_thumbnail = f"Metadata/plate_{idx}.png" in namelist
plates.append(
{
"index": idx,
"name": meta.get("name"),
"objects": meta.get("objects", []),
"has_thumbnail": has_thumbnail,
"thumbnail_url": f"/api/v1/library/files/{file_id}/plate-thumbnail/{idx}"
if has_thumbnail
else None,
"print_time_seconds": meta.get("prediction"),
"filament_used_grams": meta.get("weight"),
"filaments": meta.get("filaments", []),
}
)
except Exception as e:
logger.warning(f"Failed to parse plates from library file {file_id}: {e}")
return {
"file_id": file_id,
"filename": lib_file.filename,
"plates": plates,
"is_multi_plate": len(plates) > 1,
}
@router.get("/files/{file_id}/plate-thumbnail/{plate_index}")
async def get_library_file_plate_thumbnail(
file_id: int,
plate_index: int,
db: AsyncSession = Depends(get_db),
):
"""Get the thumbnail image for a specific plate from a library file."""
import zipfile
from starlette.responses import Response
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
lib_file = result.scalar_one_or_none()
if not lib_file:
raise HTTPException(status_code=404, detail="File not found")
file_path = Path(app_settings.base_dir) / lib_file.file_path
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
try:
with zipfile.ZipFile(file_path, "r") as zf:
thumb_path = f"Metadata/plate_{plate_index}.png"
if thumb_path in zf.namelist():
data = zf.read(thumb_path)
return Response(content=data, media_type="image/png")
except Exception:
pass
raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
@router.get("/files/{file_id}/filament-requirements")
async def get_library_file_filament_requirements(
file_id: int,
plate_id: int | None = None,
db: AsyncSession = Depends(get_db),
):
"""Get filament requirements from a library file.
Parses the 3MF file to extract filament slot IDs, types, colors, and usage.
This enables AMS slot assignment when printing from the file manager.
Args:
file_id: The library file ID
plate_id: Optional plate index to get filaments for a specific plate
"""
import xml.etree.ElementTree as ET
import zipfile
# Get the library file
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
lib_file = result.scalar_one_or_none()
if not lib_file:
raise HTTPException(status_code=404, detail="File not found")
# Get the full file path
file_path = Path(app_settings.base_dir) / lib_file.file_path
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
# Only 3MF files have parseable filament info
if not lib_file.filename.lower().endswith(".3mf"):
return {"file_id": file_id, "filename": lib_file.filename, "plate_id": plate_id, "filaments": []}
filaments = []
try:
with zipfile.ZipFile(file_path, "r") as zf:
# Parse slice_info.config for filament requirements
if "Metadata/slice_info.config" in zf.namelist():
content = zf.read("Metadata/slice_info.config").decode()
root = ET.fromstring(content)
if plate_id is not None:
# Find filaments for specific plate
for plate_elem in root.findall(".//plate"):
# Check if this is the requested plate
plate_index = None
for meta in plate_elem.findall("metadata"):
if meta.get("key") == "index":
try:
plate_index = int(meta.get("value", ""))
except ValueError:
pass
break
if plate_index == plate_id:
# Extract filaments from this plate
for filament_elem in plate_elem.findall("filament"):
filament_id = filament_elem.get("id")
filament_type = filament_elem.get("type", "")
filament_color = filament_elem.get("color", "")
used_g = filament_elem.get("used_g", "0")
used_m = filament_elem.get("used_m", "0")
try:
used_grams = float(used_g)
except (ValueError, TypeError):
used_grams = 0
if used_grams > 0 and filament_id:
filaments.append(
{
"slot_id": int(filament_id),
"type": filament_type,
"color": filament_color,
"used_grams": round(used_grams, 1),
"used_meters": float(used_m) if used_m else 0,
}
)
break
else:
# Extract all filaments with used_g > 0 (for single-plate or overview)
for filament_elem in root.findall(".//filament"):
filament_id = filament_elem.get("id")
filament_type = filament_elem.get("type", "")
filament_color = filament_elem.get("color", "")
used_g = filament_elem.get("used_g", "0")
used_m = filament_elem.get("used_m", "0")
try:
used_grams = float(used_g)
except (ValueError, TypeError):
used_grams = 0
if used_grams > 0 and filament_id:
filaments.append(
{
"slot_id": int(filament_id),
"type": filament_type,
"color": filament_color,
"used_grams": round(used_grams, 1),
"used_meters": float(used_m) if used_m else 0,
}
)
# Sort by slot ID
filaments.sort(key=lambda x: x["slot_id"])
except Exception as e:
logger.warning(f"Failed to parse filament requirements from library file {file_id}: {e}")
return {
"file_id": file_id,
"filename": lib_file.filename,
"plate_id": plate_id,
"filaments": filaments,
}
@router.post("/files/{file_id}/print")
async def print_library_file(
file_id: int,
printer_id: int,
body: FilePrintRequest | None = None,
db: AsyncSession = Depends(get_db),
):
"""Print a library file directly.
This endpoint:
1. Creates an archive from the library file
2. Uploads the file to the printer
3. Starts the print
Only sliced files (.gcode or .gcode.3mf) can be printed.
"""
import zipfile
from backend.app.main import register_expected_print
from backend.app.models.printer import Printer
from backend.app.services.bambu_ftp import (
delete_file_async,
get_ftp_retry_settings,
upload_file_async,
with_ftp_retry,
)
from backend.app.services.printer_manager import printer_manager
# Use defaults if no body provided
if body is None:
body = FilePrintRequest()
# Get the library file
result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
lib_file = result.scalar_one_or_none()
if not lib_file:
raise HTTPException(status_code=404, detail="File not found")
# Validate file is sliced
if not is_sliced_file(lib_file.filename):
raise HTTPException(
status_code=400,
detail="Not a sliced file. Only .gcode or .gcode.3mf files can be printed.",
)
# Get the full file path
file_path = Path(app_settings.base_dir) / lib_file.file_path
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found on disk")
# Get printer
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
# Check printer is connected
if not printer_manager.is_connected(printer_id):
raise HTTPException(status_code=400, detail="Printer is not connected")
# Create archive from the library file
archive_service = ArchiveService(db)
archive = await archive_service.archive_print(
printer_id=printer_id,
source_file=file_path,
)
if not archive:
raise HTTPException(status_code=500, detail="Failed to create archive")
await db.flush()
# Prepare remote filename
base_name = lib_file.filename
if base_name.endswith(".gcode.3mf"):
base_name = base_name[:-10]
elif base_name.endswith(".3mf"):
base_name = base_name[:-4]
remote_filename = f"{base_name}.3mf"
remote_path = f"/{remote_filename}"
# Get FTP retry settings
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
# Delete existing file if present (avoids 553 error)
await delete_file_async(
printer.ip_address,
printer.access_code,
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
)
# Upload file to printer
if ftp_retry_enabled:
uploaded = await with_ftp_retry(
upload_file_async,
printer.ip_address,
printer.access_code,
file_path,
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
max_retries=ftp_retry_count,
retry_delay=ftp_retry_delay,
operation_name=f"Upload for print to {printer.name}",
)
else:
uploaded = await upload_file_async(
printer.ip_address,
printer.access_code,
file_path,
remote_path,
socket_timeout=ftp_timeout,
printer_model=printer.model,
)
if not uploaded:
raise HTTPException(status_code=500, detail="Failed to upload file to printer")
# Register this as an expected print so we don't create a duplicate archive
register_expected_print(printer_id, remote_filename, archive.id)
# Determine plate ID
if body.plate_id is not None:
plate_id = body.plate_id
else:
plate_id = 1
try:
with zipfile.ZipFile(file_path, "r") as zf:
for name in zf.namelist():
if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
plate_str = name[15:-6]
plate_id = int(plate_str)
break
except Exception:
pass
logger.info(
f"Print library file {file_id}: archive_id={archive.id}, plate_id={plate_id}, "
f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}"
)
# Start the print
started = printer_manager.start_print(
printer_id,
remote_filename,
plate_id,
ams_mapping=body.ams_mapping,
timelapse=body.timelapse,
bed_levelling=body.bed_levelling,
flow_cali=body.flow_cali,
vibration_cali=body.vibration_cali,
layer_inspect=body.layer_inspect,
use_ams=body.use_ams,
)
if not started:
raise HTTPException(status_code=500, detail="Failed to start print")
await db.commit()
return {
"status": "printing",
"printer_id": printer_id,
"archive_id": archive.id,
"filename": lib_file.filename,
}
# ============ File Detail Endpoints ============
@@ -920,6 +1413,12 @@ async def update_file(file_id: int, data: FileUpdate, db: AsyncSession = Depends
if not file:
raise HTTPException(status_code=404, detail="File not found")
if data.filename is not None:
# Validate filename doesn't contain path separators
if "/" in data.filename or "\\" in data.filename:
raise HTTPException(status_code=400, detail="Filename cannot contain path separators")
file.filename = data.filename
if data.folder_id is not None:
if data.folder_id == 0:
file.folder_id = None
+44 -11
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import selectinload
from backend.app.core.database import get_db
from backend.app.models.archive import PrintArchive
from backend.app.models.library import LibraryFile
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.schemas.print_queue import (
@@ -26,7 +27,7 @@ router = APIRouter(prefix="/queue", tags=["queue"])
def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
"""Add nested archive/printer info to response."""
"""Add nested archive/printer/library_file info to response."""
# Parse ams_mapping from JSON string BEFORE model_validate
ams_mapping_parsed = None
if item.ams_mapping:
@@ -40,6 +41,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
"id": item.id,
"printer_id": item.printer_id,
"archive_id": item.archive_id,
"library_file_id": item.library_file_id,
"position": item.position,
"scheduled_time": item.scheduled_time,
"require_previous_success": item.require_previous_success,
@@ -64,6 +66,16 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
response.archive_name = item.archive.print_name or item.archive.filename
response.archive_thumbnail = item.archive.thumbnail_path
response.print_time_seconds = item.archive.print_time_seconds
if item.library_file:
response.library_file_name = (
item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
)
if not response.library_file_name:
response.library_file_name = item.library_file.filename
response.library_file_thumbnail = item.library_file.thumbnail_path
# Get print time from library file metadata if no archive
if not item.archive and item.library_file.file_metadata:
response.print_time_seconds = item.library_file.file_metadata.get("print_time_seconds")
if item.printer:
response.printer_name = item.printer.name
return response
@@ -78,7 +90,11 @@ async def list_queue(
"""List all queue items, optionally filtered by printer or status."""
query = (
select(PrintQueueItem)
.options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
.options(
selectinload(PrintQueueItem.archive),
selectinload(PrintQueueItem.printer),
selectinload(PrintQueueItem.library_file),
)
.order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
)
@@ -102,16 +118,27 @@ async def add_to_queue(
db: AsyncSession = Depends(get_db),
):
"""Add an item to the print queue."""
# Validate that either archive_id or library_file_id is provided
if not data.archive_id and not data.library_file_id:
raise HTTPException(400, "Either archive_id or library_file_id must be provided")
# Validate printer exists (if assigned)
if data.printer_id is not None:
result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
if not result.scalar_one_or_none():
raise HTTPException(400, "Printer not found")
# Validate archive exists
result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
if not result.scalar_one_or_none():
raise HTTPException(400, "Archive not found")
# Validate archive exists (if provided)
if data.archive_id:
result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
if not result.scalar_one_or_none():
raise HTTPException(400, "Archive not found")
# Validate library file exists (if provided)
if data.library_file_id:
result = await db.execute(select(LibraryFile).where(LibraryFile.id == data.library_file_id))
if not result.scalar_one_or_none():
raise HTTPException(400, "Library file not found")
# Get next position for this printer (or for unassigned items)
if data.printer_id is not None:
@@ -132,6 +159,7 @@ async def add_to_queue(
item = PrintQueueItem(
printer_id=data.printer_id,
archive_id=data.archive_id,
library_file_id=data.library_file_id,
scheduled_time=data.scheduled_time,
require_previous_success=data.require_previous_success,
auto_off_after=data.auto_off_after,
@@ -152,9 +180,10 @@ async def add_to_queue(
await db.refresh(item)
# Load relationships for response
await db.refresh(item, ["archive", "printer"])
await db.refresh(item, ["archive", "printer", "library_file"])
logger.info(f"Added archive {data.archive_id} to queue for printer {data.printer_id or 'unassigned'}")
source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
logger.info(f"Added {source_name} to queue for printer {data.printer_id or 'unassigned'}")
# MQTT relay - publish queue job added
try:
@@ -177,7 +206,11 @@ async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
"""Get a specific queue item."""
result = await db.execute(
select(PrintQueueItem)
.options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
.options(
selectinload(PrintQueueItem.archive),
selectinload(PrintQueueItem.printer),
selectinload(PrintQueueItem.library_file),
)
.where(PrintQueueItem.id == item_id)
)
item = result.scalar_one_or_none()
@@ -217,7 +250,7 @@ async def update_queue_item(
setattr(item, field, value)
await db.commit()
await db.refresh(item, ["archive", "printer"])
await db.refresh(item, ["archive", "printer", "library_file"])
logger.info(f"Updated queue item {item_id}")
return _enrich_response(item)
@@ -372,7 +405,7 @@ async def start_queue_item(
# Clear manual_start flag so scheduler picks it up
item.manual_start = False
await db.commit()
await db.refresh(item, ["archive", "printer"])
await db.refresh(item, ["archive", "printer", "library_file"])
logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
return _enrich_response(item)
+163
View File
@@ -1067,6 +1067,169 @@ async def delete_slot_preset(
return {"success": True}
@router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
async def configure_ams_slot(
printer_id: int,
ams_id: int,
tray_id: int,
tray_info_idx: str = Query(...),
tray_type: str = Query(...),
tray_sub_brands: str = Query(...),
tray_color: str = Query(...),
nozzle_temp_min: int = Query(...),
nozzle_temp_max: int = Query(...),
cali_idx: int = Query(-1),
nozzle_diameter: str = Query("0.4"),
setting_id: str = Query(""),
kprofile_filament_id: str = Query(""),
kprofile_setting_id: str = Query(""),
k_value: float = Query(0.0),
):
"""Configure an AMS slot with a specific filament setting and K profile.
This sends two commands to the printer:
1. ams_filament_setting - sets filament type, color, temperature
2. extrusion_cali_sel - sets the K profile (pressure advance value)
Args:
printer_id: Database ID of the printer
ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
tray_id: Tray ID within the AMS (0-3)
tray_info_idx: Filament ID short format (e.g., "GFL05") or user preset ID
tray_type: Filament type (e.g., "PLA", "PETG")
tray_sub_brands: Sub-brand/profile name (e.g., "PLA Basic", "PETG HF")
tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
nozzle_temp_min: Minimum nozzle temperature
nozzle_temp_max: Maximum nozzle temperature
cali_idx: K profile calibration index (-1 for default 0.020)
nozzle_diameter: Nozzle diameter string (e.g., "0.4")
setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
kprofile_filament_id: K profile's filament_id for proper K profile linking
k_value: Direct K value to set (0.0 to skip direct K value setting)
"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"[configure_ams_slot] printer_id={printer_id}, ams_id={ams_id}, tray_id={tray_id}")
logger.info(
f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}"
)
logger.info(
f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}"
)
# Get MQTT client for this printer
client = printer_manager.get_client(printer_id)
if not client:
raise HTTPException(status_code=400, detail="Printer not connected")
# Send the filament setting command (type, color, temp)
success = client.ams_set_filament_setting(
ams_id=ams_id,
tray_id=tray_id,
tray_info_idx=tray_info_idx,
tray_type=tray_type,
tray_sub_brands=tray_sub_brands,
tray_color=tray_color,
nozzle_temp_min=nozzle_temp_min,
nozzle_temp_max=nozzle_temp_max,
setting_id=setting_id,
)
if not success:
raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
# Send the calibration/K-profile commands
# Use the K profile's filament_id if provided, otherwise use tray_info_idx
filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx
# Method 1: Select existing calibration profile by cali_idx
# IMPORTANT: Only pass setting_id if the K profile itself has one (from kprofile_setting_id)
# Do NOT use the preset's setting_id as fallback - it breaks the K profile linking in the slicer
client.extrusion_cali_sel(
ams_id=ams_id,
tray_id=tray_id,
cali_idx=cali_idx,
filament_id=filament_id_for_kprofile,
nozzle_diameter=nozzle_diameter,
setting_id=kprofile_setting_id if kprofile_setting_id else None,
)
# Method 2: Also directly set the K value if provided (for better compatibility)
if k_value > 0:
# Calculate global tray ID for extrusion_cali_set
if ams_id <= 3:
global_tray_id = ams_id * 4 + tray_id
elif ams_id >= 128 and ams_id <= 135:
global_tray_id = (ams_id - 128) * 4 + tray_id
else:
global_tray_id = tray_id
client.extrusion_cali_set(
tray_id=global_tray_id,
k_value=k_value,
n_coef=0.0,
nozzle_diameter=nozzle_diameter,
bed_temp=60,
nozzle_temp=nozzle_temp_max,
max_volumetric_speed=20.0,
)
# Request fresh status push from printer so frontend gets updated data via WebSocket
logger.info("[configure_ams_slot] Requesting status update from printer")
update_result = client.request_status_update()
logger.info(f"[configure_ams_slot] Status update request result: {update_result}")
return {
"success": True,
"message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}",
}
@router.post("/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset")
async def reset_ams_slot(
printer_id: int,
ams_id: int,
tray_id: int,
db: AsyncSession = Depends(get_db),
):
"""Reset an AMS slot to empty/unconfigured state.
This clears the filament configuration from the slot.
"""
# Get MQTT client for this printer
client = printer_manager.get_client(printer_id)
if not client:
raise HTTPException(status_code=400, detail="Printer not connected")
# Reset the slot
success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to send reset command")
# Also delete any saved slot preset mapping
result = await db.execute(
select(SlotPresetMapping).where(
SlotPresetMapping.printer_id == printer_id,
SlotPresetMapping.ams_id == ams_id,
SlotPresetMapping.tray_id == tray_id,
)
)
mapping = result.scalar_one_or_none()
if mapping:
await db.delete(mapping)
await db.commit()
# Request fresh status push from printer so frontend gets updated data via WebSocket
client.request_status_update()
return {
"success": True,
"message": f"Reset AMS {ams_id} tray {tray_id}",
}
@router.post("/{printer_id}/debug/simulate-print-complete")
async def debug_simulate_print_complete(
printer_id: int,
+5
View File
@@ -78,6 +78,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
"mqtt_enabled",
"mqtt_use_tls",
"ha_enabled",
"per_printer_mapping_expanded",
]:
settings_dict[setting.key] = setting.value.lower() == "true"
elif setting.key in [
@@ -137,6 +138,8 @@ async def update_settings(
await set_setting(db, key, str_value)
await db.commit()
# Expire all objects to ensure fresh reads after commit
db.expire_all()
# Reconfigure MQTT relay if any MQTT settings changed
if mqtt_updated:
@@ -214,6 +217,7 @@ async def update_spoolman_settings(
await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
await db.commit()
db.expire_all()
# Return updated settings
return await get_spoolman_settings(db)
@@ -1990,6 +1994,7 @@ async def update_virtual_printer_settings(
if model is not None:
await set_setting(db, "virtual_printer_model", model)
await db.commit()
db.expire_all()
# Reconfigure virtual printer
try:
+155 -2
View File
@@ -5,10 +5,11 @@ import json
import logging
import os
import platform
import re
import zipfile
from datetime import datetime
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import func, select
@@ -149,9 +150,161 @@ async def toggle_debug_logging(toggle: DebugLoggingToggle):
)
class LogEntry(BaseModel):
"""A single log entry."""
timestamp: str
level: str
logger_name: str
message: str
class LogsResponse(BaseModel):
"""Response containing log entries."""
entries: list[LogEntry]
total_in_file: int
filtered_count: int
# Log line regex pattern: "2024-01-15 10:30:45,123 INFO [module.name] Message here"
LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
def _parse_log_line(line: str) -> LogEntry | None:
"""Parse a single log line into a LogEntry."""
match = LOG_LINE_PATTERN.match(line.strip())
if match:
return LogEntry(
timestamp=match.group(1),
level=match.group(2),
logger_name=match.group(3),
message=match.group(4),
)
return None
def _read_log_entries(
limit: int = 200,
level_filter: str | None = None,
search: str | None = None,
) -> tuple[list[LogEntry], int]:
"""Read and parse log entries from file with optional filtering."""
log_file = settings.log_dir / "bambuddy.log"
if not log_file.exists():
return [], 0
entries: list[LogEntry] = []
total_lines = 0
try:
with open(log_file, encoding="utf-8", errors="replace") as f:
# Read all lines and process
lines = f.readlines()
total_lines = len(lines)
# Parse lines in reverse order (newest first)
current_entry: LogEntry | None = None
multi_line_buffer: list[str] = []
for line in reversed(lines):
parsed = _parse_log_line(line)
if parsed:
# Found a new log entry start
if current_entry:
# Apply filters and add previous entry (without multi_line_buffer - it belongs to new entry)
should_include = True
# Level filter
if level_filter and current_entry.level.upper() != level_filter.upper():
should_include = False
# Search filter (case-insensitive)
if search and should_include:
search_lower = search.lower()
if not (
search_lower in current_entry.message.lower()
or search_lower in current_entry.logger_name.lower()
):
should_include = False
if should_include:
entries.append(current_entry)
if len(entries) >= limit:
break
# Set new entry and attach any accumulated multi-line content to it
# (in reverse order, continuation lines come before their parent entry)
current_entry = parsed
if multi_line_buffer:
current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
multi_line_buffer = []
elif line.strip():
# Continuation of multi-line log entry (will be attached to next parsed entry)
multi_line_buffer.append(line.rstrip())
# Don't forget the last (oldest) entry
# Note: any remaining multi_line_buffer would be orphaned lines before the first entry
if current_entry and len(entries) < limit:
should_include = True
if level_filter and current_entry.level.upper() != level_filter.upper():
should_include = False
if search and should_include:
search_lower = search.lower()
if not (
search_lower in current_entry.message.lower()
or search_lower in current_entry.logger_name.lower()
):
should_include = False
if should_include:
entries.append(current_entry)
except Exception as e:
logger.error(f"Error reading log file: {e}")
return [], 0
# Entries are already in newest-first order
return entries, total_lines
@router.get("/logs", response_model=LogsResponse)
async def get_logs(
limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
search: str | None = Query(None, description="Search in message or logger name"),
):
"""Get recent application log entries with optional filtering."""
entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
return LogsResponse(
entries=entries,
total_in_file=total_lines,
filtered_count=len(entries),
)
@router.delete("/logs")
async def clear_logs():
"""Clear the application log file."""
log_file = settings.log_dir / "bambuddy.log"
if log_file.exists():
try:
# Truncate the file instead of deleting (keeps file handles valid)
with open(log_file, "w", encoding="utf-8") as f:
f.write("")
logger.info("Log file cleared by user")
return {"message": "Logs cleared successfully"}
except Exception as e:
logger.error(f"Error clearing log file: {e}")
raise HTTPException(status_code=500, detail=f"Failed to clear logs: {e}")
return {"message": "Log file does not exist"}
def _sanitize_path(path: str) -> str:
"""Remove username from paths for privacy."""
import re
# Replace /home/username/ or /Users/username/ with /home/[user]/
path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)
+1 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from pydantic_settings import BaseSettings
# Application version - single source of truth
APP_VERSION = "0.1.6b9"
APP_VERSION = "0.1.6b10"
GITHUB_REPO = "maziggy/bambuddy"
# App directory - where the application is installed (for static files)
+62
View File
@@ -579,6 +579,68 @@ async def run_migrations(conn):
except Exception:
pass
# Migration: Add library_file_id column to print_queue and make archive_id nullable
# This allows queue items to reference library files directly (archive created at print start)
try:
await conn.execute(
text(
"ALTER TABLE print_queue ADD COLUMN library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE"
)
)
except Exception:
pass
# Check if archive_id needs to be made nullable (requires table recreation in SQLite)
try:
result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
row = result.fetchone()
if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
# Need to migrate - archive_id is currently NOT NULL
await conn.execute(
text("""
CREATE TABLE print_queue_new2 (
id INTEGER PRIMARY KEY,
printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,
library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
position INTEGER DEFAULT 0,
scheduled_time DATETIME,
manual_start BOOLEAN DEFAULT 0,
require_previous_success BOOLEAN DEFAULT 0,
auto_off_after BOOLEAN DEFAULT 0,
ams_mapping TEXT,
plate_id INTEGER,
bed_levelling BOOLEAN DEFAULT 1,
flow_cali BOOLEAN DEFAULT 0,
vibration_cali BOOLEAN DEFAULT 1,
layer_inspect BOOLEAN DEFAULT 0,
timelapse BOOLEAN DEFAULT 0,
use_ams BOOLEAN DEFAULT 1,
status VARCHAR(20) DEFAULT 'pending',
started_at DATETIME,
completed_at DATETIME,
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
)
await conn.execute(
text("""
INSERT INTO print_queue_new2
SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
status, started_at, completed_at, error_message, created_at
FROM print_queue
""")
)
await conn.execute(text("DROP TABLE print_queue"))
await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
except Exception:
pass
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
+14 -1
View File
@@ -244,7 +244,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
f"{state.chamber_light}"
f"{state.chamber_light}:{state.active_extruder}"
)
# MQTT relay - publish status (before dedup check - always publish to MQTT)
@@ -280,6 +280,19 @@ async def on_ams_change(printer_id: int, ams_data: list):
except Exception:
pass # Don't fail AMS callback if MQTT fails
# Broadcast AMS change via WebSocket (bypasses status_key deduplication)
# This ensures frontend gets immediate updates when AMS slots are configured
try:
state = printer_manager.get_status(printer_id)
if state:
logger.info(f"[Printer {printer_id}] Broadcasting AMS change via WebSocket")
await ws_manager.send_printer_status(
printer_id,
printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
)
except Exception as e:
logger.warning(f"Failed to broadcast AMS change for printer {printer_id}: {e}")
try:
async with async_session() as db:
from backend.app.api.routes.settings import get_setting
+8 -2
View File
@@ -15,7 +15,11 @@ class PrintQueueItem(Base):
# Links
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"))
# Either archive_id OR library_file_id must be set (archive created at print start from library file)
archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=True)
library_file_id: Mapped[int | None] = mapped_column(
ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
)
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
# Scheduling
@@ -57,10 +61,12 @@ class PrintQueueItem(Base):
# Relationships
printer: Mapped["Printer"] = relationship()
archive: Mapped["PrintArchive"] = relationship()
archive: Mapped["PrintArchive | None"] = relationship()
library_file: Mapped["LibraryFile | None"] = relationship()
project: Mapped["Project | None"] = relationship(back_populates="queue_items")
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.library import LibraryFile # noqa: E402
from backend.app.models.printer import Printer # noqa: E402
from backend.app.models.project import Project # noqa: E402
+4 -3
View File
@@ -80,6 +80,7 @@ class FileCreate(BaseModel):
class FileUpdate(BaseModel):
"""Schema for updating a file."""
filename: str | None = Field(None, min_length=1, max_length=255)
folder_id: int | None = None
project_id: int | None = None
notes: str | None = None
@@ -159,9 +160,10 @@ class FileMoveRequest(BaseModel):
class FilePrintRequest(BaseModel):
"""Schema for printing a file from the library."""
"""Schema for printing a file from the library.
printer_id: str # Printer serial number
Note: printer_id is passed as a query parameter, not in the body.
"""
# Print options (same as archive reprint)
plate_id: int | None = None
@@ -218,7 +220,6 @@ class AddToQueueResult(BaseModel):
file_id: int
filename: str
queue_item_id: int
archive_id: int
class AddToQueueError(BaseModel):
+8 -3
View File
@@ -17,7 +17,9 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
class PrintQueueItemCreate(BaseModel):
printer_id: int | None = None # None = unassigned, user assigns later
archive_id: int
# Either archive_id OR library_file_id must be provided
archive_id: int | None = None
library_file_id: int | None = None
scheduled_time: datetime | None = None # None = ASAP (next when idle)
require_previous_success: bool = False
auto_off_after: bool = False # Power off printer after print completes
@@ -57,7 +59,8 @@ class PrintQueueItemUpdate(BaseModel):
class PrintQueueItemResponse(BaseModel):
id: int
printer_id: int | None # None = unassigned
archive_id: int
archive_id: int | None # None if library_file_id is set (archive created at print start)
library_file_id: int | None # For queue items from library files
position: int
scheduled_time: UTCDatetime
require_previous_success: bool
@@ -81,8 +84,10 @@ class PrintQueueItemResponse(BaseModel):
# Nested info for UI (populated in route)
archive_name: str | None = None
archive_thumbnail: str | None = None
library_file_name: str | None = None # Name of library file (if library_file_id is set)
library_file_thumbnail: str | None = None # Thumbnail of library file
printer_name: str | None = None
print_time_seconds: int | None = None # Estimated print time from archive
print_time_seconds: int | None = None # Estimated print time from archive or library file
class Config:
from_attributes = True
+13
View File
@@ -41,6 +41,11 @@ class AppSettings(BaseModel):
)
ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
# Print modal settings
per_printer_mapping_expanded: bool = Field(
default=False, description="Expand custom filament mapping by default in print modal"
)
# Date/time display format
date_format: str = Field(default="system", description="Date format: system, us, eu, iso")
time_format: str = Field(default="system", description="Time format: system, 12h, 24h")
@@ -100,6 +105,12 @@ class AppSettings(BaseModel):
description="Show warning when free disk space falls below this threshold (GB)",
)
# Camera view settings
camera_view_mode: str = Field(
default="window",
description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
)
class AppSettingsUpdate(BaseModel):
"""Schema for updating settings (all fields optional)."""
@@ -121,6 +132,7 @@ class AppSettingsUpdate(BaseModel):
ams_temp_good: float | None = None
ams_temp_fair: float | None = None
ams_history_retention_days: int | None = None
per_printer_mapping_expanded: bool | None = None
date_format: str | None = None
time_format: str | None = None
default_printer_id: int | None = None
@@ -149,3 +161,4 @@ class AppSettingsUpdate(BaseModel):
ha_token: str | None = None
library_archive_mode: str | None = None
library_disk_warning_gb: float | None = None
camera_view_mode: str | None = None
+182 -8
View File
@@ -358,6 +358,7 @@ class BambuMQTTClient:
# Track last message time - receiving a message proves we're connected
self._last_message_time = time.time()
self.state.connected = True
# TEMP: Dump full payload once to find extruder state field
if not hasattr(self, "_payload_dumped"):
self._payload_dumped = True
@@ -1858,7 +1859,9 @@ class BambuMQTTClient:
True if the request was sent, False if not connected.
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] request_status_update: not connected")
return False
logger.info(f"[{self.serial_number}] Requesting status update (pushall)")
self._request_push_all()
# Note: get_accessories returns stale nozzle data on H2D.
# The correct nozzle data comes from push_status response.
@@ -3155,20 +3158,22 @@ class BambuMQTTClient:
tray_color: str,
nozzle_temp_min: int,
nozzle_temp_max: int,
k: float,
setting_id: str = "",
) -> bool:
"""Set AMS tray filament settings including K (pressure advance) value.
"""Set AMS tray filament settings (type, color, temperature).
Note: K value is set separately via extrusion_cali_sel command.
Args:
ams_id: AMS unit ID (0-3)
ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
tray_id: Tray ID within the AMS (0-3)
tray_info_idx: Filament preset ID (e.g., "GFA00")
tray_info_idx: Filament ID short format (e.g., "GFL05")
tray_type: Filament type (e.g., "PLA", "PETG")
tray_sub_brands: Sub-brand name (e.g., "PLA Basic", "PETG HF")
tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
nozzle_temp_min: Minimum nozzle temperature
nozzle_temp_max: Maximum nozzle temperature
k: Pressure advance (K) value (e.g., 0.020)
setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
Returns:
True if command was sent, False otherwise
@@ -3177,25 +3182,194 @@ class BambuMQTTClient:
logger.warning(f"[{self.serial_number}] Cannot set AMS filament setting: not connected")
return False
# Calculate slot_id based on AMS type
if ams_id <= 3:
slot_id = tray_id
else:
# AMS-HT or external: slot_id = 0
slot_id = 0
command = {
"print": {
"command": "ams_filament_setting",
"ams_id": ams_id,
"tray_id": tray_id,
"slot_id": slot_id,
"tray_info_idx": tray_info_idx,
"tray_type": tray_type,
"tray_sub_brands": tray_sub_brands,
"tray_color": tray_color,
"nozzle_temp_min": nozzle_temp_min,
"nozzle_temp_max": nozzle_temp_max,
"k": k,
"sequence_id": "0",
}
}
# Include setting_id if provided (helps slicer show correct profile)
if setting_id:
command["print"]["setting_id"] = setting_id
command_json = json.dumps(command)
logger.info(
f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, tray_info_idx={tray_info_idx}, setting_id={setting_id}"
)
logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
self._client.publish(self.topic_publish, command_json, qos=1)
return True
def reset_ams_slot(self, ams_id: int, tray_id: int) -> bool:
"""Reset an AMS slot to empty/unconfigured state.
Args:
ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
tray_id: Tray ID within the AMS (0-3)
Returns:
True if command was sent, False otherwise
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot reset AMS slot: not connected")
return False
# Calculate slot_id based on AMS type
if ams_id <= 3:
slot_id = tray_id
else:
slot_id = 0
command = {
"print": {
"command": "ams_filament_setting",
"ams_id": ams_id,
"tray_id": tray_id,
"slot_id": slot_id,
"tray_info_idx": "",
"tray_type": "",
"tray_sub_brands": "",
"tray_color": "00000000",
"nozzle_temp_min": 0,
"nozzle_temp_max": 0,
"sequence_id": "0",
}
}
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, k={k}")
logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
logger.info(f"[{self.serial_number}] Resetting AMS slot: AMS {ams_id}, tray {tray_id}")
logger.debug(f"[{self.serial_number}] reset_ams_slot command: {command_json}")
self._client.publish(self.topic_publish, command_json, qos=1)
return True
def extrusion_cali_sel(
self,
ams_id: int,
tray_id: int,
cali_idx: int,
filament_id: str,
nozzle_diameter: str = "0.4",
setting_id: str | None = None,
) -> bool:
"""Set calibration profile (K value) for an AMS slot.
This command selects a K profile from the printer's calibration list.
Use cali_idx=-1 to use the default K value (0.020).
Args:
ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
tray_id: Tray ID within the AMS (0-3)
cali_idx: Calibration profile index (-1 for default)
filament_id: Filament preset ID (same as tray_info_idx)
nozzle_diameter: Nozzle diameter string (e.g., "0.4")
setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
Returns:
True if command was sent, False otherwise
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot set calibration: not connected")
return False
# Calculate slot_id based on AMS type
# tray_id in the command should be the local tray index (0-3)
if ams_id <= 3:
slot_id = tray_id
elif ams_id >= 128 and ams_id <= 135:
slot_id = 0
else:
slot_id = 0
command = {
"print": {
"command": "extrusion_cali_sel",
"cali_idx": cali_idx,
"filament_id": filament_id,
"nozzle_diameter": nozzle_diameter,
"ams_id": ams_id,
"tray_id": tray_id, # Local tray index (0-3), not global
"slot_id": slot_id,
"sequence_id": "0",
}
}
# Include setting_id if provided (helps slicer show correct K profile)
if setting_id:
command["print"]["setting_id"] = setting_id
command_json = json.dumps(command)
logger.info(
f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}, setting_id={setting_id}"
)
logger.debug(f"[{self.serial_number}] extrusion_cali_sel command: {command_json}")
self._client.publish(self.topic_publish, command_json, qos=1)
return True
def extrusion_cali_set(
self,
tray_id: int,
k_value: float,
n_coef: float = 0.0,
nozzle_diameter: str = "0.4",
bed_temp: int = 60,
nozzle_temp: int = 220,
max_volumetric_speed: float = 20.0,
) -> bool:
"""Directly set K value (pressure advance) for a tray.
This command sets the K value directly without selecting from stored profiles.
Use this when you want to apply a specific K value to a tray.
Args:
tray_id: Global tray ID (ams_id * 4 + slot)
k_value: Pressure advance K value (e.g., 0.020)
n_coef: N coefficient (usually 0.0 for manual, 1.4 for auto-calibration)
nozzle_diameter: Nozzle diameter string (e.g., "0.4")
bed_temp: Bed temperature for calibration reference
nozzle_temp: Nozzle temperature for calibration reference
max_volumetric_speed: Max volumetric speed for calibration reference
Returns:
True if command was sent, False otherwise
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot set K value: not connected")
return False
command = {
"print": {
"command": "extrusion_cali_set",
"tray_id": tray_id,
"k_value": k_value,
"n_coef": n_coef,
"nozzle_diameter": nozzle_diameter,
"bed_temp": bed_temp,
"nozzle_temp": nozzle_temp,
"max_volumetric_speed": max_volumetric_speed,
"sequence_id": "0",
}
}
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Publishing extrusion_cali_set: tray {tray_id}, k_value={k_value}")
logger.debug(f"[{self.serial_number}] extrusion_cali_set command: {command_json}")
self._client.publish(self.topic_publish, command_json, qos=1)
return True
+2
View File
@@ -571,6 +571,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
"heatbreak_fan_speed": state.heatbreak_fan_speed,
# Chamber light state
"chamber_light": state.chamber_light,
# Active extruder for dual-nozzle printers (0=right, 1=left)
"active_extruder": state.active_extruder,
}
# Add cover URL if there's an active print and printer_id is provided
# Include PAUSE/PAUSED states so skip objects modal can show cover
@@ -204,6 +204,37 @@ class TestLibraryFilesAPI:
result = response.json()
assert result.get("message") or result.get("success", True)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_file(self, async_client: AsyncClient, file_factory, db_session):
"""Verify file can be renamed."""
lib_file = await file_factory(filename="old_name.3mf")
data = {"filename": "new_name.3mf"}
response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
assert response.status_code == 200
result = response.json()
assert result["filename"] == "new_name.3mf"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_file_invalid_path_separator(self, async_client: AsyncClient, file_factory, db_session):
"""Verify file rename fails with path separators."""
lib_file = await file_factory(filename="test.3mf")
data = {"filename": "path/to/file.3mf"}
response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
assert response.status_code == 400
assert "path separator" in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rename_file_invalid_backslash(self, async_client: AsyncClient, file_factory, db_session):
"""Verify file rename fails with backslash."""
lib_file = await file_factory(filename="test.3mf")
data = {"filename": "path\\to\\file.3mf"}
response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
assert response.status_code == 400
assert "path separator" in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_library_stats(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
@@ -540,3 +540,197 @@ class TestQueueCancelEndpoint:
response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
assert response.status_code == 400
class TestQueueLibraryFileSupport:
"""Tests for queue items with library_file_id (instead of archive_id)."""
@pytest.fixture
async def printer_factory(self, db_session):
"""Factory to create test printers."""
_counter = [0]
async def _create_printer(**kwargs):
from backend.app.models.printer import Printer
_counter[0] += 1
counter = _counter[0]
defaults = {
"name": f"Library Test Printer {counter}",
"ip_address": f"192.168.1.{150 + counter}",
"serial_number": f"TESTLIB{counter:04d}",
"access_code": "12345678",
"model": "X1C",
}
defaults.update(kwargs)
printer = Printer(**defaults)
db_session.add(printer)
await db_session.commit()
await db_session.refresh(printer)
return printer
return _create_printer
@pytest.fixture
async def library_file_factory(self, db_session):
"""Factory to create test library files."""
_counter = [0]
async def _create_library_file(**kwargs):
from backend.app.models.library import LibraryFile
_counter[0] += 1
counter = _counter[0]
defaults = {
"filename": f"library_test_{counter}.3mf",
"file_path": f"/test/library/library_test_{counter}.3mf",
"file_size": 2048,
"file_type": "3mf",
"file_metadata": {"print_name": f"Library Print {counter}", "print_time_seconds": 3600},
}
defaults.update(kwargs)
lib_file = LibraryFile(**defaults)
db_session.add(lib_file)
await db_session.commit()
await db_session.refresh(lib_file)
return lib_file
return _create_library_file
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_with_library_file(
self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
):
"""Verify item can be added to queue using library_file_id instead of archive_id."""
printer = await printer_factory()
lib_file = await library_file_factory()
data = {
"printer_id": printer.id,
"library_file_id": lib_file.id,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["printer_id"] == printer.id
assert result["library_file_id"] == lib_file.id
assert result["archive_id"] is None
assert result["status"] == "pending"
assert result["library_file_name"] == "Library Print 1"
assert result["print_time_seconds"] == 3600
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_library_file_with_options(
self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
):
"""Verify library file queue item can have all options set."""
printer = await printer_factory()
lib_file = await library_file_factory()
data = {
"printer_id": printer.id,
"library_file_id": lib_file.id,
"ams_mapping": [1, 2, -1, -1],
"plate_id": 2,
"bed_levelling": False,
"timelapse": True,
"manual_start": True,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["library_file_id"] == lib_file.id
assert result["ams_mapping"] == [1, 2, -1, -1]
assert result["plate_id"] == 2
assert result["bed_levelling"] is False
assert result["timelapse"] is True
assert result["manual_start"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_requires_archive_or_library_file(
self, async_client: AsyncClient, printer_factory, db_session
):
"""Verify 400 error when neither archive_id nor library_file_id provided."""
printer = await printer_factory()
data = {
"printer_id": printer.id,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 400
assert (
"archive_id" in response.json()["detail"].lower() or "library_file_id" in response.json()["detail"].lower()
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_queue_item_with_library_file(
self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
):
"""Verify queue item with library_file_id can be updated."""
from backend.app.models.print_queue import PrintQueueItem
printer = await printer_factory()
lib_file = await library_file_factory()
# Create queue item directly
item = PrintQueueItem(
printer_id=printer.id,
library_file_id=lib_file.id,
status="pending",
position=1,
)
db_session.add(item)
await db_session.commit()
await db_session.refresh(item)
# Update the item
response = await async_client.patch(
f"/api/v1/queue/{item.id}",
json={"auto_off_after": True, "plate_id": 3},
)
assert response.status_code == 200
result = response.json()
assert result["auto_off_after"] is True
assert result["plate_id"] == 3
assert result["library_file_id"] == lib_file.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_queue_includes_library_file_info(
self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
):
"""Verify queue list includes library file metadata."""
from backend.app.models.print_queue import PrintQueueItem
printer = await printer_factory()
lib_file = await library_file_factory(
file_metadata={"print_name": "Custom Print Name", "print_time_seconds": 7200}
)
item = PrintQueueItem(
printer_id=printer.id,
library_file_id=lib_file.id,
status="pending",
position=1,
)
db_session.add(item)
await db_session.commit()
response = await async_client.get("/api/v1/queue/")
assert response.status_code == 200
items = response.json()
assert len(items) >= 1
# Find our item
our_item = next((i for i in items if i["library_file_id"] == lib_file.id), None)
assert our_item is not None
assert our_item["library_file_name"] == "Custom Print Name"
assert our_item["print_time_seconds"] == 7200
@@ -285,3 +285,88 @@ class TestSettingsAPI:
assert result["mqtt_port"] == 1883
assert result["mqtt_topic_prefix"] == "bambuddy"
assert result["mqtt_use_tls"] is False
# ========================================================================
# Camera settings tests
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_camera_view_mode(self, async_client: AsyncClient):
"""Verify camera view mode can be updated."""
response = await async_client.put("/api/v1/settings/", json={"camera_view_mode": "embedded"})
assert response.status_code == 200
assert response.json()["camera_view_mode"] == "embedded"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_view_mode_persists(self, async_client: AsyncClient):
"""CRITICAL: Verify camera view mode persists after update."""
# Update to embedded
await async_client.put("/api/v1/settings/", json={"camera_view_mode": "embedded"})
# Verify persistence in new request
response = await async_client.get("/api/v1/settings/")
assert response.json()["camera_view_mode"] == "embedded"
# Update back to window
await async_client.put("/api/v1/settings/", json={"camera_view_mode": "window"})
# Verify persistence
response = await async_client.get("/api/v1/settings/")
assert response.json()["camera_view_mode"] == "window"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_view_mode_default(self, async_client: AsyncClient):
"""Verify camera view mode has correct default value."""
# Reset by requesting settings (default should be 'window')
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert "camera_view_mode" in result
# Default is 'window' as defined in schema
assert result["camera_view_mode"] in ["window", "embedded"]
# ========================================================================
# Per-printer mapping settings tests
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_per_printer_mapping_expanded(self, async_client: AsyncClient):
"""Verify per_printer_mapping_expanded can be updated."""
response = await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
assert response.status_code == 200
assert response.json()["per_printer_mapping_expanded"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_per_printer_mapping_expanded_persists(self, async_client: AsyncClient):
"""CRITICAL: Verify per_printer_mapping_expanded persists after update."""
# Update to True
await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
# Verify persistence in new request
response = await async_client.get("/api/v1/settings/")
assert response.json()["per_printer_mapping_expanded"] is True
# Update back to False
await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": False})
# Verify persistence
response = await async_client.get("/api/v1/settings/")
assert response.json()["per_printer_mapping_expanded"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_per_printer_mapping_expanded_default(self, async_client: AsyncClient):
"""Verify per_printer_mapping_expanded has correct default value."""
response = await async_client.get("/api/v1/settings/")
result = response.json()
assert "per_printer_mapping_expanded" in result
# Default is False as defined in schema
assert isinstance(result["per_printer_mapping_expanded"], bool)
@@ -0,0 +1,256 @@
"""Integration tests for Support API endpoints.
Tests the full request/response cycle for /api/v1/support/ endpoints.
"""
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from httpx import AsyncClient
class TestSupportLogsAPI:
"""Integration tests for /api/v1/support/logs endpoints."""
# ========================================================================
# GET /api/v1/support/logs
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_logs_empty_file(self, async_client: AsyncClient):
"""Verify get logs returns empty list when log file doesn't exist."""
with patch("backend.app.api.routes.support.settings") as mock_settings:
mock_settings.log_dir = Path("/nonexistent/path")
response = await async_client.get("/api/v1/support/logs")
assert response.status_code == 200
result = response.json()
assert result["entries"] == []
assert result["total_in_file"] == 0
assert result["filtered_count"] == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_logs_with_entries(self, async_client: AsyncClient):
"""Verify get logs returns parsed log entries."""
log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Connecting to printer
2024-01-15 10:30:47,789 WARNING [backend.app.services.mqtt] Connection timeout
2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file
"""
with tempfile.TemporaryDirectory() as tmpdir:
log_file = Path(tmpdir) / "bambuddy.log"
log_file.write_text(log_content)
with patch("backend.app.api.routes.support.settings") as mock_settings:
mock_settings.log_dir = Path(tmpdir)
response = await async_client.get("/api/v1/support/logs")
assert response.status_code == 200
result = response.json()
assert len(result["entries"]) == 4
assert result["total_in_file"] == 4
assert result["filtered_count"] == 4
# Entries are in newest-first order
assert result["entries"][0]["level"] == "ERROR"
assert result["entries"][1]["level"] == "WARNING"
assert result["entries"][2]["level"] == "DEBUG"
assert result["entries"][3]["level"] == "INFO"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_logs_with_level_filter(self, async_client: AsyncClient):
"""Verify get logs filters by log level."""
log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Connecting to printer
2024-01-15 10:30:47,789 ERROR [backend.app.services.mqtt] Connection timeout
2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file
"""
with tempfile.TemporaryDirectory() as tmpdir:
log_file = Path(tmpdir) / "bambuddy.log"
log_file.write_text(log_content)
with patch("backend.app.api.routes.support.settings") as mock_settings:
mock_settings.log_dir = Path(tmpdir)
response = await async_client.get("/api/v1/support/logs?level=ERROR")
assert response.status_code == 200
result = response.json()
assert len(result["entries"]) == 2
assert result["filtered_count"] == 2
assert all(e["level"] == "ERROR" for e in result["entries"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_logs_with_search_filter(self, async_client: AsyncClient):
"""Verify get logs filters by search query."""
log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
2024-01-15 10:30:46,456 INFO [backend.app.services.printer] Connecting to printer X1C
2024-01-15 10:30:47,789 ERROR [backend.app.services.mqtt] Connection to printer failed
2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file
"""
with tempfile.TemporaryDirectory() as tmpdir:
log_file = Path(tmpdir) / "bambuddy.log"
log_file.write_text(log_content)
with patch("backend.app.api.routes.support.settings") as mock_settings:
mock_settings.log_dir = Path(tmpdir)
response = await async_client.get("/api/v1/support/logs?search=printer")
assert response.status_code == 200
result = response.json()
assert len(result["entries"]) == 2
assert result["filtered_count"] == 2
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_logs_with_limit(self, async_client: AsyncClient):
"""Verify get logs respects limit parameter."""
log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Line 1
2024-01-15 10:30:46,456 INFO [backend.app.main] Line 2
2024-01-15 10:30:47,789 INFO [backend.app.main] Line 3
2024-01-15 10:30:48,012 INFO [backend.app.main] Line 4
2024-01-15 10:30:49,345 INFO [backend.app.main] Line 5
"""
with tempfile.TemporaryDirectory() as tmpdir:
log_file = Path(tmpdir) / "bambuddy.log"
log_file.write_text(log_content)
with patch("backend.app.api.routes.support.settings") as mock_settings:
mock_settings.log_dir = Path(tmpdir)
response = await async_client.get("/api/v1/support/logs?limit=2")
assert response.status_code == 200
result = response.json()
assert len(result["entries"]) == 2
assert result["filtered_count"] == 2
# Should get the newest entries (Line 5 and Line 4)
assert "Line 5" in result["entries"][0]["message"]
assert "Line 4" in result["entries"][1]["message"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_logs_multiline_entry(self, async_client: AsyncClient):
"""Verify get logs handles multi-line log entries."""
log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
2024-01-15 10:30:46,456 ERROR [backend.app.services.mqtt] Exception occurred
Traceback (most recent call last):
File "test.py", line 10, in test
raise ValueError("test error")
ValueError: test error
2024-01-15 10:30:47,789 INFO [backend.app.main] Recovery complete
"""
with tempfile.TemporaryDirectory() as tmpdir:
log_file = Path(tmpdir) / "bambuddy.log"
log_file.write_text(log_content)
with patch("backend.app.api.routes.support.settings") as mock_settings:
mock_settings.log_dir = Path(tmpdir)
response = await async_client.get("/api/v1/support/logs")
assert response.status_code == 200
result = response.json()
assert len(result["entries"]) == 3
# Find the error entry
error_entry = next(e for e in result["entries"] if e["level"] == "ERROR")
assert "Exception occurred" in error_entry["message"]
assert "Traceback" in error_entry["message"]
assert "ValueError" in error_entry["message"]
# ========================================================================
# DELETE /api/v1/support/logs
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_clear_logs_success(self, async_client: AsyncClient):
"""Verify clear logs truncates the log file."""
log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Some debug info
"""
with tempfile.TemporaryDirectory() as tmpdir:
log_file = Path(tmpdir) / "bambuddy.log"
log_file.write_text(log_content)
with patch("backend.app.api.routes.support.settings") as mock_settings:
mock_settings.log_dir = Path(tmpdir)
response = await async_client.delete("/api/v1/support/logs")
# Verify file was cleared
assert log_file.read_text() == ""
assert response.status_code == 200
result = response.json()
assert "cleared" in result["message"].lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_clear_logs_no_file(self, async_client: AsyncClient):
"""Verify clear logs handles missing log file gracefully."""
with patch("backend.app.api.routes.support.settings") as mock_settings:
mock_settings.log_dir = Path("/nonexistent/path")
response = await async_client.delete("/api/v1/support/logs")
assert response.status_code == 200
result = response.json()
assert "does not exist" in result["message"].lower()
class TestLogParsingHelpers:
"""Tests for log parsing helper functions."""
def test_parse_log_line_valid(self):
"""Verify _parse_log_line handles valid log lines."""
from backend.app.api.routes.support import _parse_log_line
line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Server started"
entry = _parse_log_line(line)
assert entry is not None
assert entry.timestamp == "2024-01-15 10:30:45,123"
assert entry.level == "INFO"
assert entry.logger_name == "backend.app.main"
assert entry.message == "Server started"
def test_parse_log_line_invalid(self):
"""Verify _parse_log_line returns None for invalid lines."""
from backend.app.api.routes.support import _parse_log_line
line = "This is not a valid log line"
entry = _parse_log_line(line)
assert entry is None
def test_parse_log_line_with_brackets_in_message(self):
"""Verify _parse_log_line handles messages with brackets."""
from backend.app.api.routes.support import _parse_log_line
line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Processing [item 1] and [item 2]"
entry = _parse_log_line(line)
assert entry is not None
assert entry.message == "Processing [item 1] and [item 2]"
def test_parse_log_line_all_levels(self):
"""Verify _parse_log_line handles all log levels."""
from backend.app.api.routes.support import _parse_log_line
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
for level in levels:
line = f"2024-01-15 10:30:45,123 {level} [test.module] Test message"
entry = _parse_log_line(line)
assert entry is not None
assert entry.level == level
@@ -916,3 +916,34 @@ class TestInitPrinterConnections:
await init_printer_connections(mock_db)
mock_manager.connect_printer.assert_not_called()
class TestAmsChangeCallback:
"""Tests for AMS change callback functionality."""
@pytest.fixture
def manager(self):
"""Create a fresh PrinterManager instance."""
return PrinterManager()
def test_ams_change_callback_is_triggered(self, manager):
"""Verify AMS change callback is called when AMS data changes."""
callback = MagicMock()
manager.set_ams_change_callback(callback)
# Verify callback was set
assert manager._on_ams_change == callback
def test_ams_change_callback_receives_correct_data(self, manager):
"""Verify AMS change callback receives the correct AMS data format."""
received_data = []
def capture_callback(printer_id, ams_data):
received_data.append((printer_id, ams_data))
manager.set_ams_change_callback(capture_callback)
# The callback should accept printer_id and ams_data
# This tests the callback signature
assert manager._on_ams_change is not None
assert callable(manager._on_ams_change)
+4 -1
View File
@@ -14,7 +14,7 @@ services:
# Comment out "network_mode: host" above and uncomment "ports:" below.
# Note: Printer discovery won't work - add printers manually by IP.
#ports:
# - "8000:8000"
# - "${PORT:-8000}:8000"
volumes:
- bambuddy_data:/app/data
- bambuddy_logs:/app/logs
@@ -24,6 +24,9 @@ services:
- ./virtual_printer:/app/data/virtual_printer
environment:
- TZ=Europe/Berlin
# Port BamBuddy runs on (default: 8000)
# Usage: PORT=8080 docker compose up -d
- PORT=${PORT:-8000}
restart: unless-stopped
volumes:
@@ -1,196 +0,0 @@
/**
* Tests for the AddToQueueModal component.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { AddToQueueModal } from '../../components/AddToQueueModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockPrinters = [
{
id: 1,
name: 'X1 Carbon',
ip_address: '192.168.1.100',
model: 'X1C',
enabled: true,
},
{
id: 2,
name: 'P1S',
ip_address: '192.168.1.101',
model: 'P1S',
enabled: true,
},
];
const mockPlates = [
{ id: 1, plate_number: 1, name: 'Plate 1' },
{ id: 2, plate_number: 2, name: 'Plate 2' },
];
describe('AddToQueueModal', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json(mockPrinters);
}),
http.get('/api/v1/archives/:id/plates', () => {
return HttpResponse.json(mockPlates);
}),
http.get('/api/v1/archives/:id/filament-requirements', () => {
return HttpResponse.json([]);
}),
http.post('/api/v1/queue/', () => {
return HttpResponse.json({ id: 1, status: 'pending' });
})
);
});
describe('rendering', () => {
it('renders the modal title', () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Schedule Print')).toBeInTheDocument();
});
it('shows archive name', () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Test Print')).toBeInTheDocument();
});
it('shows printer selector', async () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByText('Printer')).toBeInTheDocument();
});
});
it('shows add button', () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument();
});
it('shows cancel button', () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
});
describe('queue options', () => {
it('shows Queue Only option', () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Queue Only')).toBeInTheDocument();
});
it('shows power off option', () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText(/power off/i)).toBeInTheDocument();
});
});
describe('print options', () => {
it('has print configuration options', async () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
// Modal should render and have configuration options
await waitFor(() => {
expect(screen.getByText('Schedule Print')).toBeInTheDocument();
});
});
});
describe('actions', () => {
it('calls onClose when cancel is clicked', async () => {
const user = userEvent.setup();
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalled();
});
});
describe('plate selection', () => {
it('shows plate selector when plates exist', async () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
// Modal should render - plate selector may be conditional
await waitFor(() => {
expect(screen.getByText('Schedule Print')).toBeInTheDocument();
});
});
});
});
@@ -0,0 +1,207 @@
/**
* Tests for the ConfigureAmsSlotModal component.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
import { api } from '../../api/client';
// Mock the API client
vi.mock('../../api/client', () => ({
api: {
getCloudSettings: vi.fn(),
getKProfiles: vi.fn(),
configureAmsSlot: vi.fn(),
getCloudSettingDetail: vi.fn(),
saveSlotPreset: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
},
}));
const mockCloudSettings = {
filament: [
{
setting_id: 'GFSL05_09',
name: 'Bambu PLA Basic @BBL X1C',
filament_id: 'GFL05',
},
{
setting_id: 'PFUScd84f663d2c2ef',
name: '# Overture Matte PLA @BBL H2D',
filament_id: null,
},
],
};
const mockKProfiles = {
profiles: [
{
id: 1,
name: 'PLA Basic',
k_value: '0.020',
filament_id: 'GFL05',
setting_id: '',
extruder_id: 1,
cali_idx: 1,
},
],
};
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
printerId: 1,
slotInfo: {
amsId: 0,
trayId: 0,
trayCount: 4,
trayType: 'PLA',
trayColor: 'FFFFFF',
traySubBrands: 'PLA Basic',
},
nozzleDiameter: '0.4',
onSuccess: vi.fn(),
};
describe('ConfigureAmsSlotModal', () => {
beforeEach(() => {
vi.clearAllMocks();
(api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);
(api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue(mockKProfiles);
(api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
(api.saveSlotPreset as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
});
it('renders nothing visible when closed', () => {
render(<ConfigureAmsSlotModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('Configure AMS Slot')).not.toBeInTheDocument();
});
it('renders modal when open', async () => {
render(<ConfigureAmsSlotModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();
});
});
it('displays basic color buttons', async () => {
render(<ConfigureAmsSlotModal {...defaultProps} />);
await waitFor(() => {
// Check for basic color buttons by their title attribute
expect(screen.getByTitle('White')).toBeInTheDocument();
expect(screen.getByTitle('Black')).toBeInTheDocument();
expect(screen.getByTitle('Red')).toBeInTheDocument();
expect(screen.getByTitle('Blue')).toBeInTheDocument();
expect(screen.getByTitle('Green')).toBeInTheDocument();
expect(screen.getByTitle('Yellow')).toBeInTheDocument();
expect(screen.getByTitle('Orange')).toBeInTheDocument();
expect(screen.getByTitle('Gray')).toBeInTheDocument();
});
});
it('does not show extended colors by default', async () => {
render(<ConfigureAmsSlotModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTitle('White')).toBeInTheDocument();
});
// Extended colors should not be visible initially
expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument();
expect(screen.queryByTitle('Purple')).not.toBeInTheDocument();
expect(screen.queryByTitle('Coral')).not.toBeInTheDocument();
});
it('shows extended colors when expand button is clicked', async () => {
render(<ConfigureAmsSlotModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTitle('White')).toBeInTheDocument();
});
// Click the expand button (+ button)
const expandButton = screen.getByTitle('Show more colors');
fireEvent.click(expandButton);
// Extended colors should now be visible
await waitFor(() => {
expect(screen.getByTitle('Cyan')).toBeInTheDocument();
expect(screen.getByTitle('Purple')).toBeInTheDocument();
expect(screen.getByTitle('Pink')).toBeInTheDocument();
expect(screen.getByTitle('Brown')).toBeInTheDocument();
expect(screen.getByTitle('Coral')).toBeInTheDocument();
});
});
it('hides extended colors when collapse button is clicked', async () => {
render(<ConfigureAmsSlotModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTitle('White')).toBeInTheDocument();
});
// Click the expand button
const expandButton = screen.getByTitle('Show more colors');
fireEvent.click(expandButton);
// Wait for extended colors to appear
await waitFor(() => {
expect(screen.getByTitle('Cyan')).toBeInTheDocument();
});
// Click the collapse button
const collapseButton = screen.getByTitle('Show less colors');
fireEvent.click(collapseButton);
// Extended colors should be hidden again
await waitFor(() => {
expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument();
});
});
it('selects a color when color button is clicked', async () => {
render(<ConfigureAmsSlotModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTitle('Red')).toBeInTheDocument();
});
// Click the red color button
const redButton = screen.getByTitle('Red');
fireEvent.click(redButton);
// The color input should now show "Red"
const colorInput = screen.getByPlaceholderText(/Color name or hex/);
expect(colorInput).toHaveValue('Red');
});
it('derives tray_info_idx from base_id when filament_id is null', async () => {
// Mock the detail API to return base_id but no filament_id
(api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
filament_id: null,
base_id: 'GFSL05_09',
name: '# Overture Matte PLA @BBL H2D',
});
render(<ConfigureAmsSlotModal {...defaultProps} />);
// Wait for presets to load
await waitFor(() => {
expect(api.getCloudSettings).toHaveBeenCalled();
});
// Select a user preset (one without filament_id)
// Find and click the preset - this would require the preset to be in the list
// The actual tray_info_idx derivation happens during the configure mutation
});
it('renders configure slot button', async () => {
render(<ConfigureAmsSlotModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();
});
// Find the Configure Slot button
const configureButton = screen.getByRole('button', { name: /Configure Slot/i });
expect(configureButton).toBeInTheDocument();
});
});
@@ -1,257 +0,0 @@
/**
* Tests for the EditQueueItemModal component.
*
* These tests focus on:
* - Basic rendering and modal controls
* - Print options (bed levelling, flow calibration, etc.)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { EditQueueItemModal } from '../../components/EditQueueItemModal';
import type { PrintQueueItem, Printer } from '../../api/client';
// Mock the API client to prevent actual API calls
vi.mock('../../api/client', async () => {
const actual = await vi.importActual('../../api/client');
return {
...actual,
fetchArchivePlates: vi.fn().mockResolvedValue([]),
fetchFilamentRequirements: vi.fn().mockResolvedValue([]),
};
});
// Mock data
const createMockPrinter = (overrides: Partial<Printer> = {}): Printer => ({
id: 1,
name: 'Test Printer',
ip_address: '192.168.1.100',
serial_number: 'TESTSERIAL0001',
access_code: '12345678',
model: 'X1C',
enabled: true,
created_at: '2024-01-01T00:00:00Z',
...overrides,
});
const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueueItem => ({
id: 1,
printer_id: 1,
archive_id: 1,
position: 1,
scheduled_time: null,
require_previous_success: false,
auto_off_after: false,
manual_start: false,
ams_mapping: null,
plate_id: null,
bed_levelling: true,
flow_cali: false,
vibration_cali: true,
layer_inspect: false,
timelapse: false,
use_ams: true,
status: 'pending',
started_at: null,
completed_at: null,
error_message: null,
created_at: '2024-01-01T00:00:00Z',
archive_name: 'Test Print',
archive_thumbnail: null,
printer_name: 'Test Printer',
print_time_seconds: 3600,
...overrides,
});
describe('EditQueueItemModal', () => {
const mockOnClose = vi.fn();
const mockOnSave = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders the modal with title', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
});
it('shows printer selector label', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter({ name: 'My Printer' })];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
// The printer label should be present
expect(screen.getByText('Printer')).toBeInTheDocument();
});
it('shows print options toggle', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
expect(screen.getByText('Print Options')).toBeInTheDocument();
});
});
describe('print options', () => {
it('has print options toggle button', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
// Print Options toggle should be present
expect(screen.getByText('Print Options')).toBeInTheDocument();
});
it('print options toggle is clickable', async () => {
const user = userEvent.setup();
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
// Click should not throw an error
const printOptionsButton = screen.getByText('Print Options');
await user.click(printOptionsButton);
// The button should still be in the document after clicking
expect(screen.getByText('Print Options')).toBeInTheDocument();
});
});
describe('modal controls', () => {
it('has save button', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
const saveButton = screen.getByRole('button', { name: /save/i });
expect(saveButton).toBeInTheDocument();
});
it('has cancel button', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
expect(cancelButton).toBeInTheDocument();
});
it('calls onClose when cancel button is clicked', async () => {
const user = userEvent.setup();
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(mockOnClose).toHaveBeenCalled();
});
});
describe('queue options', () => {
it('shows queue only option', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
expect(screen.getByText('Queue Only')).toBeInTheDocument();
});
it('shows power off option', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
expect(screen.getByText(/power off/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,537 @@
/**
* Tests for the unified PrintModal component.
*
* The PrintModal supports three modes:
* - 'reprint': Immediate print from archive (multi-printer support)
* - 'add-to-queue': Schedule print to queue (multi-printer support)
* - 'edit-queue-item': Edit existing queue item (single printer)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { PrintModal } from '../../components/PrintModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
import type { PrintQueueItem } from '../../api/client';
const mockPrinters = [
{ id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
{ id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true },
];
const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueueItem => ({
id: 1,
printer_id: 1,
archive_id: 1,
position: 1,
scheduled_time: null,
require_previous_success: false,
auto_off_after: false,
manual_start: false,
ams_mapping: null,
plate_id: null,
bed_levelling: true,
flow_cali: false,
vibration_cali: true,
layer_inspect: false,
timelapse: false,
use_ams: true,
status: 'pending',
started_at: null,
completed_at: null,
error_message: null,
created_at: '2024-01-01T00:00:00Z',
archive_name: 'Test Print',
archive_thumbnail: null,
printer_name: 'Test Printer',
print_time_seconds: 3600,
...overrides,
});
describe('PrintModal', () => {
const mockOnClose = vi.fn();
const mockOnSuccess = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json(mockPrinters);
}),
http.get('/api/v1/archives/:id/plates', () => {
return HttpResponse.json({ is_multi_plate: false, plates: [] });
}),
http.get('/api/v1/archives/:id/filament-requirements', () => {
return HttpResponse.json({ filaments: [] });
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null });
}),
http.post('/api/v1/archives/:id/reprint', () => {
return HttpResponse.json({ success: true });
}),
http.post('/api/v1/queue/', () => {
return HttpResponse.json({ id: 1, status: 'pending' });
}),
http.patch('/api/v1/queue/:id', () => {
return HttpResponse.json({ id: 1, status: 'pending' });
})
);
});
describe('reprint mode', () => {
it('renders the modal title', () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByText('Re-print')).toBeInTheDocument();
});
it('shows archive name', () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
it('shows printer selection with checkboxes for multi-select', async () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
expect(screen.getByText('P1S')).toBeInTheDocument();
});
});
it('has print button', () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// Get the submit button specifically (not printer selection buttons)
const submitButton = screen.getByRole('button', { name: /^print$/i });
expect(submitButton).toBeInTheDocument();
});
it('has cancel button', () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('calls onClose when cancel is clicked', async () => {
const user = userEvent.setup();
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalled();
});
it('print button is disabled until printer is selected', () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// Get the submit button specifically (not printer selection buttons)
const printButton = screen.getByRole('button', { name: /^print$/i });
expect(printButton).toBeDisabled();
});
it('shows no printers message when none active', async () => {
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json([]);
})
);
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByText('No active printers available')).toBeInTheDocument();
});
});
it('shows print options toggle', () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByText('Print Options')).toBeInTheDocument();
});
});
describe('add-to-queue mode', () => {
it('renders the modal title', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Schedule Print')).toBeInTheDocument();
});
it('shows archive name', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Test Print')).toBeInTheDocument();
});
it('shows add button', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument();
});
it('shows cancel button', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('shows Queue Only option', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Queue Only')).toBeInTheDocument();
});
it('shows power off option', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText(/power off/i)).toBeInTheDocument();
});
it('shows schedule options', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('ASAP')).toBeInTheDocument();
expect(screen.getByText('Scheduled')).toBeInTheDocument();
});
it('calls onClose when cancel is clicked', async () => {
const user = userEvent.setup();
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalled();
});
});
describe('edit-queue-item mode', () => {
it('renders the modal title', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
});
it('shows save button', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('shows cancel button', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('shows print options toggle', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByText('Print Options')).toBeInTheDocument();
});
it('shows Queue Only option', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByText('Queue Only')).toBeInTheDocument();
});
it('shows power off option', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByText(/power off/i)).toBeInTheDocument();
});
it('calls onClose when cancel button is clicked', async () => {
const user = userEvent.setup();
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(mockOnClose).toHaveBeenCalled();
});
it('shows printer selector for single selection', async () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
// PrinterSelector shows printer names directly
await waitFor(() => {
expect(screen.getByText('P1S')).toBeInTheDocument();
});
});
});
describe('multi-printer selection', () => {
it('shows select all button when multiple printers available', async () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByText('Select all')).toBeInTheDocument();
});
});
it('shows selected count when multiple printers selected', async () => {
const user = userEvent.setup();
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByText('Select all')).toBeInTheDocument();
});
await user.click(screen.getByText('Select all'));
await waitFor(() => {
expect(screen.getByText(/2 printers selected/)).toBeInTheDocument();
});
});
it('updates button text when multiple printers selected', async () => {
const user = userEvent.setup();
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByText('Select all')).toBeInTheDocument();
});
await user.click(screen.getByText('Select all'));
await waitFor(() => {
expect(screen.getByRole('button', { name: /print to 2 printers/i })).toBeInTheDocument();
});
});
});
});
@@ -1,184 +0,0 @@
/**
* Tests for the ReprintModal component.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { ReprintModal } from '../../components/ReprintModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockPrinters = [
{ id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true },
{ id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true },
];
describe('ReprintModal', () => {
const mockOnClose = vi.fn();
const mockOnSuccess = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json(mockPrinters);
}),
http.get('/api/v1/archives/:id/plates', () => {
return HttpResponse.json({ is_multi_plate: false, plates: [] });
}),
http.get('/api/v1/archives/:id/filament-requirements', () => {
return HttpResponse.json({ filaments: [] });
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null });
}),
http.post('/api/v1/archives/:id/reprint', () => {
return HttpResponse.json({ success: true });
})
);
});
describe('rendering', () => {
it('renders the modal title', () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByText('Re-print')).toBeInTheDocument();
});
it('shows archive name', () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
it('shows printer selection buttons', async () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
expect(screen.getByText('P1S')).toBeInTheDocument();
});
});
});
describe('printer selection', () => {
it('shows active printers as buttons', async () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
// Printer buttons should be present
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
});
it('shows no printers message when none active', async () => {
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json([]);
})
);
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByText('No active printers available')).toBeInTheDocument();
});
});
});
describe('actions', () => {
it('has print button', () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByRole('button', { name: /print/i })).toBeInTheDocument();
});
it('has cancel button', () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('calls onClose when cancel is clicked', async () => {
const user = userEvent.setup();
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalled();
});
it('print button is disabled until printer is selected', async () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// Print button should be disabled initially (no printer selected)
const printButton = screen.getByRole('button', { name: /print/i });
expect(printButton).toBeDisabled();
});
});
});
+148 -3
View File
@@ -6,6 +6,7 @@ async function request<T>(
): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
cache: 'no-store', // Prevent browser caching of API responses
headers: {
'Content-Type': 'application/json',
...options.headers,
@@ -558,6 +559,8 @@ export interface AppSettings {
ams_temp_good: number; // <= this is green/blue
ams_temp_fair: number; // <= this is orange, > is red
ams_history_retention_days: number; // days to keep AMS sensor history
// Print modal settings
per_printer_mapping_expanded: boolean; // Whether custom mapping is expanded by default in print modal
// Date/time format settings
date_format: 'system' | 'us' | 'eu' | 'iso';
time_format: 'system' | '12h' | '24h';
@@ -593,6 +596,8 @@ export interface AppSettings {
// File Manager / Library settings
library_archive_mode: 'always' | 'never' | 'ask';
library_disk_warning_gb: number;
// Camera view settings
camera_view_mode: 'window' | 'embedded';
}
export type AppSettingsUpdate = Partial<AppSettings>;
@@ -843,7 +848,9 @@ export interface DiscoveredTasmotaDevice {
export interface PrintQueueItem {
id: number;
printer_id: number | null; // null = unassigned
archive_id: number;
// Either archive_id OR library_file_id must be set (archive created at print start)
archive_id: number | null;
library_file_id: number | null;
position: number;
scheduled_time: string | null;
require_previous_success: boolean;
@@ -865,13 +872,17 @@ export interface PrintQueueItem {
created_at: string;
archive_name?: string | null;
archive_thumbnail?: string | null;
library_file_name?: string | null;
library_file_thumbnail?: string | null;
printer_name?: string | null;
print_time_seconds?: number | null; // Estimated print time from archive
print_time_seconds?: number | null; // Estimated print time from archive or library file
}
export interface PrintQueueItemCreate {
printer_id?: number | null; // null = unassigned
archive_id: number;
// Either archive_id OR library_file_id must be provided
archive_id?: number | null;
library_file_id?: number | null;
scheduled_time?: string | null;
require_previous_success?: boolean;
auto_off_after?: boolean;
@@ -1470,6 +1481,7 @@ export const api = {
is_directory: boolean;
size: number;
path: string;
mtime?: string;
}>;
}>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
getPrinterFileDownloadUrl: (printerId: number, path: string) =>
@@ -1814,6 +1826,7 @@ export const api = {
plates: Array<{
index: number;
name: string | null;
objects: string[];
has_thumbnail: boolean;
thumbnail_url: string | null;
print_time_seconds: number | null;
@@ -2139,6 +2152,57 @@ export const api = {
request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
method: 'DELETE',
}),
configureAmsSlot: (
printerId: number,
amsId: number,
trayId: number,
config: {
tray_info_idx: string;
tray_type: string;
tray_sub_brands: string;
tray_color: string;
nozzle_temp_min: number;
nozzle_temp_max: number;
cali_idx: number;
nozzle_diameter: string;
setting_id?: string;
kprofile_filament_id?: string;
kprofile_setting_id?: string;
k_value?: number;
}
) => {
const params = new URLSearchParams({
tray_info_idx: config.tray_info_idx,
tray_type: config.tray_type,
tray_sub_brands: config.tray_sub_brands,
tray_color: config.tray_color,
nozzle_temp_min: config.nozzle_temp_min.toString(),
nozzle_temp_max: config.nozzle_temp_max.toString(),
cali_idx: config.cali_idx.toString(),
nozzle_diameter: config.nozzle_diameter,
});
if (config.setting_id) {
params.set('setting_id', config.setting_id);
}
if (config.kprofile_filament_id) {
params.set('kprofile_filament_id', config.kprofile_filament_id);
}
if (config.kprofile_setting_id) {
params.set('kprofile_setting_id', config.kprofile_setting_id);
}
if (config.k_value !== undefined && config.k_value > 0) {
params.set('k_value', config.k_value.toString());
}
return request<{ success: boolean; message: string }>(
`/printers/${printerId}/slots/${amsId}/${trayId}/configure?${params}`,
{ method: 'POST' }
);
},
resetAmsSlot: (printerId: number, amsId: number, trayId: number) =>
request<{ success: boolean; message: string }>(
`/printers/${printerId}/ams/${amsId}/tray/${trayId}/reset`,
{ method: 'POST' }
),
// Filaments
listFilaments: () => request<Filament[]>('/filaments/'),
@@ -2539,6 +2603,61 @@ export const api = {
method: 'POST',
body: JSON.stringify({ file_ids: fileIds }),
}),
printLibraryFile: (
fileId: number,
printerId: number,
options?: {
plate_id?: number;
ams_mapping?: number[];
bed_levelling?: boolean;
flow_cali?: boolean;
vibration_cali?: boolean;
layer_inspect?: boolean;
timelapse?: boolean;
use_ams?: boolean;
}
) =>
request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
`/library/files/${fileId}/print?printer_id=${printerId}`,
{
method: 'POST',
body: options ? JSON.stringify(options) : undefined,
}
),
getLibraryFilePlates: (fileId: number) =>
request<{
file_id: number;
filename: string;
plates: Array<{
index: number;
name: string | null;
objects: string[];
has_thumbnail: boolean;
thumbnail_url: string | null;
print_time_seconds: number | null;
filament_used_grams: number | null;
filaments: Array<{
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}>;
}>;
is_multi_plate: boolean;
}>(`/library/files/${fileId}/plates`),
getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
request<{
file_id: number;
filename: string;
filaments: Array<{
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}>;
}>(`/library/files/${fileId}/filament-requirements${plateId !== undefined ? `?plate_id=${plateId}` : ''}`),
};
// AMS History types
@@ -2718,6 +2837,7 @@ export interface LibraryFileListItem {
}
export interface LibraryFileUpdate {
filename?: string;
folder_id?: number | null;
project_id?: number | null;
notes?: string | null;
@@ -2962,6 +3082,19 @@ export interface DebugLoggingState {
duration_seconds: number | null;
}
export interface LogEntry {
timestamp: string;
level: string;
logger_name: string;
message: string;
}
export interface LogsResponse {
entries: LogEntry[];
total_in_file: number;
filtered_count: number;
}
// Support API
export const supportApi = {
getDebugLoggingState: () =>
@@ -2995,4 +3128,16 @@ export const supportApi = {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
},
getLogs: (params?: { limit?: number; level?: string; search?: string }) => {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.level) searchParams.set('level', params.level);
if (params?.search) searchParams.set('search', params.search);
const query = searchParams.toString();
return request<LogsResponse>(`/support/logs${query ? `?${query}` : ''}`);
},
clearLogs: () =>
request<{ message: string }>('/support/logs', { method: 'DELETE' }),
};
-596
View File
@@ -1,596 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Calendar, Clock, X, AlertCircle, Power, Hand, Check, AlertTriangle, Circle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
import { api } from '../api/client';
import type { PrintQueueItemCreate } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import { getColorName } from '../utils/colors';
interface AddToQueueModalProps {
archiveId: number;
archiveName: string;
onClose: () => void;
}
export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [printerId, setPrinterId] = useState<number | null>(null);
const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>('asap');
const [scheduledTime, setScheduledTime] = useState('');
const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(false);
const [autoOffAfter, setAutoOffAfter] = useState(false);
const [showFilamentMapping, setShowFilamentMapping] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
// Manual slot overrides: slot_id (1-indexed) -> globalTrayId
const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: () => api.getPrinters(),
});
// Fetch filament requirements from the archived 3MF
const { data: filamentReqs } = useQuery({
queryKey: ['archive-filaments', archiveId],
queryFn: () => api.getArchiveFilamentRequirements(archiveId),
});
// Fetch printer status when a printer is selected
const { data: printerStatus } = useQuery({
queryKey: ['printer-status', printerId],
queryFn: () => api.getPrinterStatus(printerId!),
enabled: !!printerId,
});
// Set default printer if only one available
useEffect(() => {
if (printers?.length === 1 && !printerId) {
setPrinterId(printers[0].id);
}
}, [printers, printerId]);
// Clear manual mappings when printer changes
useEffect(() => {
setManualMappings({});
}, [printerId]);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
const normalizeColor = (color: string | null | undefined): string => {
if (!color) return '#808080';
const hex = color.replace('#', '').substring(0, 6);
return `#${hex}`;
};
// Helper to format slot label for display
const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
if (isExternal) return 'External';
const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
if (isHt) return `HT-${letter}`;
return `AMS-${letter} Slot ${trayId + 1}`;
};
// Calculate global tray ID for MQTT command
const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
if (isExternal) return 254;
return amsId * 4 + trayId;
};
// Build a list of all loaded filaments from printer's AMS/HT/External
const loadedFilaments = useMemo(() => {
const filaments: Array<{
type: string;
color: string;
colorName: string;
amsId: number;
trayId: number;
isHt: boolean;
isExternal: boolean;
label: string;
globalTrayId: number;
}> = [];
printerStatus?.ams?.forEach((amsUnit) => {
const isHt = amsUnit.tray.length === 1;
amsUnit.tray.forEach((tray) => {
if (tray.tray_type) {
const color = normalizeColor(tray.tray_color);
filaments.push({
type: tray.tray_type,
color,
colorName: getColorName(color),
amsId: amsUnit.id,
trayId: tray.id,
isHt,
isExternal: false,
label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
});
}
});
});
if (printerStatus?.vt_tray?.tray_type) {
const color = normalizeColor(printerStatus.vt_tray.tray_color);
filaments.push({
type: printerStatus.vt_tray.tray_type,
color,
colorName: getColorName(color),
amsId: -1,
trayId: 0,
isHt: false,
isExternal: true,
label: 'External',
globalTrayId: 254,
});
}
return filaments;
}, [printerStatus]);
// Compare required filaments with loaded filaments
const filamentComparison = useMemo(() => {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
const normalizeColorForCompare = (color: string | undefined): string => {
if (!color) return '';
return color.replace('#', '').toLowerCase().substring(0, 6);
};
const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
const hex1 = normalizeColorForCompare(color1);
const hex2 = normalizeColorForCompare(color2);
if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
const r1 = parseInt(hex1.substring(0, 2), 16);
const g1 = parseInt(hex1.substring(2, 4), 16);
const b1 = parseInt(hex1.substring(4, 6), 16);
const r2 = parseInt(hex2.substring(0, 2), 16);
const g2 = parseInt(hex2.substring(2, 4), 16);
const b2 = parseInt(hex2.substring(4, 6), 16);
return Math.abs(r1 - r2) <= threshold &&
Math.abs(g1 - g2) <= threshold &&
Math.abs(b1 - b2) <= threshold;
};
const usedTrayIds = new Set<number>(Object.values(manualMappings));
return filamentReqs.filaments.map((req) => {
const slotId = req.slot_id || 0;
// Check if there's a manual override for this slot
if (slotId > 0 && manualMappings[slotId] !== undefined) {
const manualTrayId = manualMappings[slotId];
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
if (manualLoaded) {
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
colorsAreSimilar(manualLoaded.color, req.color);
let status: 'match' | 'type_only' | 'mismatch' | 'empty';
if (typeMatch && colorMatch) {
status = 'match';
} else if (typeMatch) {
status = 'type_only';
} else {
status = 'mismatch';
}
return {
...req,
loaded: manualLoaded,
hasFilament: true,
typeMatch,
colorMatch,
status,
isManual: true,
};
}
}
// Auto-match
const exactMatch = loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = !exactMatch && loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
const hasFilament = !!loaded;
const typeMatch = hasFilament;
const colorMatch = !!exactMatch || !!similarMatch;
let status: 'match' | 'type_only' | 'mismatch' | 'empty';
if (exactMatch || similarMatch) {
status = 'match';
} else if (typeOnlyMatch) {
status = 'type_only';
} else {
status = 'mismatch';
}
return {
...req,
loaded,
hasFilament,
typeMatch,
colorMatch,
status,
isManual: false,
};
});
}, [filamentReqs, loadedFilaments, manualMappings]);
// Build AMS mapping array
const amsMapping = useMemo(() => {
if (filamentComparison.length === 0) return undefined;
const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
if (maxSlotId <= 0) return undefined;
const mapping = new Array(maxSlotId).fill(-1);
filamentComparison.forEach((f) => {
if (f.slot_id && f.slot_id > 0) {
mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
}
});
return mapping;
}, [filamentComparison]);
const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
const addMutation = useMutation({
mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Added to print queue');
onClose();
},
onError: (error: Error) => {
showToast(error.message || 'Failed to add to queue', 'error');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!printerId) {
showToast('Please select a printer', 'error');
return;
}
const data: PrintQueueItemCreate = {
printer_id: printerId,
archive_id: archiveId,
require_previous_success: requirePreviousSuccess,
auto_off_after: autoOffAfter,
manual_start: scheduleType === 'manual',
ams_mapping: amsMapping,
};
if (scheduleType === 'scheduled' && scheduledTime) {
data.scheduled_time = new Date(scheduledTime).toISOString();
}
addMutation.mutate(data);
};
// Get minimum datetime (now + 1 minute)
const getMinDateTime = () => {
const now = new Date();
now.setMinutes(now.getMinutes() + 1);
return now.toISOString().slice(0, 16);
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<CardContent className="p-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-bambu-green" />
<h2 className="text-xl font-semibold text-white">Schedule Print</h2>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{/* Archive name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Print Job</label>
<p className="text-white font-medium truncate">{archiveName}</p>
</div>
{/* Printer selection */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
{printers?.length === 0 ? (
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
No printers configured
</div>
) : (
<select
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"
value={printerId || ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
required
>
<option value="">Select printer...</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
)}
</div>
{/* Filament Mapping Section */}
{printerId && hasFilamentReqs && (
<div>
<button
type="button"
onClick={() => setShowFilamentMapping(!showFilamentMapping)}
className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
>
<Circle className="w-4 h-4" fill={filamentComparison.some(f => f.status === 'mismatch') ? '#f97316' : filamentComparison.some(f => f.status === 'type_only') ? '#facc15' : '#00ae42'} stroke="none" />
<span>Filament Mapping</span>
{filamentComparison.some(f => f.status === 'mismatch') ? (
<span className="text-xs text-orange-400">(Type not found)</span>
) : filamentComparison.some(f => f.status === 'type_only') ? (
<span className="text-xs text-yellow-400">(Color mismatch)</span>
) : (
<span className="text-xs text-bambu-green">(Ready)</span>
)}
{showFilamentMapping ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
</button>
{showFilamentMapping && (
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-bambu-gray">Click to change slot assignment</span>
<button
type="button"
onClick={async () => {
if (!printerId) return;
setIsRefreshing(true);
try {
await api.refreshPrinterStatus(printerId);
await new Promise((r) => setTimeout(r, 500));
await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
} finally {
setIsRefreshing(false);
}
}}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
disabled={isRefreshing}
>
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>Re-read</span>
</button>
</div>
{filamentComparison.map((item, idx) => (
<div
key={idx}
className="grid items-center gap-2 text-xs"
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
>
<span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
<Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
</span>
<span className="text-white truncate">
{item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
</span>
<span className="text-bambu-gray">→</span>
<select
value={item.loaded?.globalTrayId ?? ''}
onChange={(e) => {
const slotId = item.slot_id || 0;
if (slotId > 0) {
const value = e.target.value;
if (value === '') {
setManualMappings((prev) => {
const next = { ...prev };
delete next[slotId];
return next;
});
} else {
setManualMappings((prev) => ({
...prev,
[slotId]: parseInt(value, 10),
}));
}
}
}}
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
item.status === 'match'
? 'border-bambu-green/50 text-bambu-green'
: item.status === 'type_only'
? 'border-yellow-400/50 text-yellow-400'
: 'border-orange-400/50 text-orange-400'
} ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
title={item.isManual ? 'Manually selected' : 'Auto-matched'}
>
<option value="" className="bg-bambu-dark text-bambu-gray">
-- Select slot --
</option>
{loadedFilaments.map((f) => (
<option
key={f.globalTrayId}
value={f.globalTrayId}
className="bg-bambu-dark text-white"
>
{f.label}: {f.type} ({f.colorName})
</option>
))}
</select>
{item.status === 'match' ? (
<Check className="w-3 h-3 text-bambu-green" />
) : item.status === 'type_only' ? (
<span title="Same type, different color">
<AlertTriangle className="w-3 h-3 text-yellow-400" />
</span>
) : (
<span title="Filament type not loaded">
<AlertTriangle className="w-3 h-3 text-orange-400" />
</span>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Schedule type */}
<div>
<label className="block text-sm text-bambu-gray mb-2">When to print</label>
<div className="flex gap-2">
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'asap'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('asap')}
>
<Clock className="w-4 h-4" />
ASAP
</button>
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'scheduled'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('scheduled')}
>
<Calendar className="w-4 h-4" />
Scheduled
</button>
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'manual'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('manual')}
>
<Hand className="w-4 h-4" />
Queue Only
</button>
</div>
</div>
{/* Scheduled time input */}
{scheduleType === 'scheduled' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
<input
type="datetime-local"
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"
value={scheduledTime}
onChange={(e) => setScheduledTime(e.target.value)}
min={getMinDateTime()}
required
/>
</div>
)}
{/* Require previous success */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="requirePrevious"
checked={requirePreviousSuccess}
onChange={(e) => setRequirePreviousSuccess(e.target.checked)}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
Only start if previous print succeeded
</label>
</div>
{/* Auto power off */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoOffAfter"
checked={autoOffAfter}
onChange={(e) => setAutoOffAfter(e.target.checked)}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
<Power className="w-3.5 h-3.5" />
Power off printer when done
</label>
</div>
{/* Help text */}
<p className="text-xs text-bambu-gray">
{scheduleType === 'asap'
? 'Print will start as soon as the printer is idle.'
: scheduleType === 'scheduled'
? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
: 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
</p>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={addMutation.isPending || !printerId || printers?.length === 0}
>
{addMutation.isPending ? 'Adding...' : 'Add to Queue'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,853 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
import { api } from '../api/client';
import type { KProfile } from '../api/client';
import { Button } from './Button';
interface SlotInfo {
amsId: number;
trayId: number;
trayCount: number;
trayType?: string;
trayColor?: string;
traySubBrands?: string;
trayInfoIdx?: string;
}
// Get proper AMS label (handles HT AMS with ID 128+)
function getAmsLabel(amsId: number, trayCount: number): string {
// External spool
if (amsId === 255) return 'External';
let normalizedId: number;
let isHt = false;
if (amsId >= 128 && amsId <= 135) {
// HT AMS range: 128-135 → A-H
normalizedId = amsId - 128;
isHt = true;
} else if (amsId >= 0 && amsId <= 3) {
// Regular AMS range: 0-3 → A-D
normalizedId = amsId;
// Check tray count as secondary indicator
isHt = trayCount === 1;
} else {
// Unknown range - fallback to A
normalizedId = 0;
}
// Cap to valid letter range (A-H)
normalizedId = Math.max(0, Math.min(normalizedId, 7));
const letter = String.fromCharCode(65 + normalizedId);
return isHt ? `HT-${letter}` : `AMS-${letter}`;
}
// Convert setting_id to tray_info_idx (filament_id format)
// Bambu format: setting_id "GFSL05" → tray_info_idx "GFL05"
function convertToTrayInfoIdx(settingId: string): string {
// Strip version suffix if present (e.g., GFSL05_07 -> GFSL05)
const baseId = settingId.includes('_') ? settingId.split('_')[0] : settingId;
// Bambu presets start with "GFS" - remove the 'S' to get filament_id
if (baseId.startsWith('GFS')) {
return 'GF' + baseId.slice(3);
}
// User presets (PFUS*, PFSP*) - use the base setting_id (without version suffix)
// This follows the pattern that filament_id and setting_id share the same base ID
if (baseId.startsWith('PFUS') || baseId.startsWith('PFSP')) {
return baseId; // Use base ID without version suffix
}
// For other formats, use as-is
return baseId;
}
interface ConfigureAmsSlotModalProps {
isOpen: boolean;
onClose: () => void;
printerId: number;
slotInfo: SlotInfo;
nozzleDiameter?: string;
onSuccess?: () => void;
}
// Known filament material types
const MATERIAL_TYPES = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'NYLON', 'PVA', 'HIPS', 'PP', 'PET'];
// Extract filament type from preset name by finding known material type
function parsePresetName(name: string): { material: string; brand: string; variant: string } {
// Remove printer/nozzle suffix first
const withoutSuffix = name.replace(/@.+$/, '').trim();
// Try to find a known material type in the name
const upperName = withoutSuffix.toUpperCase();
for (const mat of MATERIAL_TYPES) {
// Use word boundary to match whole words only
const regex = new RegExp(`\\b${mat}\\b`, 'i');
if (regex.test(upperName)) {
// Found material, extract brand (everything before material) and variant (after)
const parts = withoutSuffix.split(regex);
const brand = parts[0]?.trim() || '';
const variant = parts[1]?.trim() || '';
return { material: mat, brand, variant };
}
}
// Fallback: assume first word is brand, second is material
const parts = withoutSuffix.split(/\s+/);
if (parts.length >= 2) {
return { material: parts[1], brand: parts[0], variant: parts.slice(2).join(' ') };
}
return { material: withoutSuffix, brand: '', variant: '' };
}
// Check if a preset is a user preset (not built-in)
function isUserPreset(settingId: string): boolean {
// Built-in presets have specific patterns, user presets are UUIDs
return !settingId.startsWith('GF') && !settingId.startsWith('P1');
}
// Common color name to hex mapping
const COLOR_NAME_MAP: Record<string, string> = {
// Basic colors
'white': 'FFFFFF',
'black': '000000',
'red': 'FF0000',
'green': '00FF00',
'blue': '0000FF',
'yellow': 'FFFF00',
'cyan': '00FFFF',
'magenta': 'FF00FF',
'orange': 'FFA500',
'purple': '800080',
'pink': 'FFC0CB',
'brown': '8B4513',
'gray': '808080',
'grey': '808080',
// Filament-specific colors
'jade white': 'FFFEF2',
'ivory': 'FFFFF0',
'beige': 'F5F5DC',
'cream': 'FFFDD0',
'silver': 'C0C0C0',
'gold': 'FFD700',
'bronze': 'CD7F32',
'copper': 'B87333',
'navy': '000080',
'teal': '008080',
'olive': '808000',
'maroon': '800000',
'coral': 'FF7F50',
'salmon': 'FA8072',
'lime': '32CD32',
'mint': '98FF98',
'forest green': '228B22',
'sky blue': '87CEEB',
'royal blue': '4169E1',
'turquoise': '40E0D0',
'lavender': 'E6E6FA',
'violet': 'EE82EE',
'plum': 'DDA0DD',
'tan': 'D2B48C',
'chocolate': 'D2691E',
'charcoal': '36454F',
'slate': '708090',
'transparent': '000000', // Will need special handling
'natural': 'F5F5DC',
'wood': 'DEB887',
};
// Quick-select color presets (common filament colors)
// Basic colors shown by default
const QUICK_COLORS_BASIC = [
{ name: 'White', hex: 'FFFFFF' },
{ name: 'Black', hex: '000000' },
{ name: 'Red', hex: 'FF0000' },
{ name: 'Blue', hex: '0000FF' },
{ name: 'Green', hex: '00AA00' },
{ name: 'Yellow', hex: 'FFFF00' },
{ name: 'Orange', hex: 'FFA500' },
{ name: 'Gray', hex: '808080' },
];
// Extended colors shown when expanded
const QUICK_COLORS_EXTENDED = [
{ name: 'Cyan', hex: '00FFFF' },
{ name: 'Magenta', hex: 'FF00FF' },
{ name: 'Purple', hex: '800080' },
{ name: 'Pink', hex: 'FFC0CB' },
{ name: 'Brown', hex: '8B4513' },
{ name: 'Beige', hex: 'F5F5DC' },
{ name: 'Navy', hex: '000080' },
{ name: 'Teal', hex: '008080' },
{ name: 'Lime', hex: '32CD32' },
{ name: 'Gold', hex: 'FFD700' },
{ name: 'Silver', hex: 'C0C0C0' },
{ name: 'Maroon', hex: '800000' },
{ name: 'Olive', hex: '808000' },
{ name: 'Coral', hex: 'FF7F50' },
{ name: 'Salmon', hex: 'FA8072' },
{ name: 'Turquoise', hex: '40E0D0' },
{ name: 'Violet', hex: 'EE82EE' },
{ name: 'Indigo', hex: '4B0082' },
{ name: 'Chocolate', hex: 'D2691E' },
{ name: 'Tan', hex: 'D2B48C' },
{ name: 'Slate', hex: '708090' },
{ name: 'Charcoal', hex: '36454F' },
{ name: 'Ivory', hex: 'FFFFF0' },
{ name: 'Cream', hex: 'FFFDD0' },
];
// Try to convert color name to hex
function colorNameToHex(name: string): string | null {
const normalized = name.toLowerCase().trim();
return COLOR_NAME_MAP[normalized] || null;
}
export function ConfigureAmsSlotModal({
isOpen,
onClose,
printerId,
slotInfo,
nozzleDiameter = '0.4',
onSuccess,
}: ConfigureAmsSlotModalProps) {
const [selectedPresetId, setSelectedPresetId] = useState<string>('');
const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
const [colorHex, setColorHex] = useState<string>(''); // Just the 6-char hex, no alpha
const [colorInput, setColorInput] = useState<string>(''); // User's text input (name or hex)
const [searchQuery, setSearchQuery] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
const [showExtendedColors, setShowExtendedColors] = useState(false);
// Fetch cloud settings
const { data: cloudSettings, isLoading: settingsLoading } = useQuery({
queryKey: ['cloudSettings'],
queryFn: () => api.getCloudSettings(),
enabled: isOpen,
});
// Fetch K profiles
const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
queryKey: ['kprofiles', printerId, nozzleDiameter],
queryFn: () => api.getKProfiles(printerId, nozzleDiameter),
enabled: isOpen && !!printerId,
});
// Configure slot mutation
const configureMutation = useMutation({
mutationFn: async () => {
if (!selectedPresetId) throw new Error('No filament preset selected');
// Get the selected preset details
const selectedPreset = cloudSettings?.filament.find(p => p.setting_id === selectedPresetId);
if (!selectedPreset) throw new Error('Selected preset not found');
// Parse the preset name for filament info
const parsed = parsePresetName(selectedPreset.name);
// Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
const caliIdx = selectedKProfile?.slot_id ?? -1;
// Use custom color if set, otherwise use current slot color or default
const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
// Create the tray_sub_brands from preset name (without printer/nozzle suffix)
const traySubBrands = selectedPreset.name.replace(/@.+$/, '').trim();
// Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
let trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
// For user presets (not starting with GF), fetch the detail to get the real filament_id
if (!selectedPresetId.startsWith('GFS')) {
try {
const detail = await api.getCloudSettingDetail(selectedPresetId);
if (detail.filament_id) {
trayInfoIdx = detail.filament_id;
} else if (detail.base_id) {
// If no filament_id but has base_id (e.g., "GFSL05_09"), derive tray_info_idx from it
// This is common for user presets that inherit from Bambu presets
trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
}
} catch (e) {
console.warn('Failed to fetch preset detail for filament_id:', e);
// Fall back to derived tray_info_idx
}
}
// Default temp range based on material type
let tempMin = 190;
let tempMax = 230;
const material = parsed.material.toUpperCase();
if (material.includes('PLA')) {
tempMin = 190;
tempMax = 230;
} else if (material.includes('PETG')) {
tempMin = 220;
tempMax = 260;
} else if (material.includes('ABS')) {
tempMin = 240;
tempMax = 280;
} else if (material.includes('ASA')) {
tempMin = 240;
tempMax = 280;
} else if (material.includes('TPU')) {
tempMin = 200;
tempMax = 240;
} else if (material.includes('PC')) {
tempMin = 260;
tempMax = 300;
} else if (material.includes('PA') || material.includes('NYLON')) {
tempMin = 250;
tempMax = 290;
}
// Parse K value from selected profile
const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0;
// Configure the slot via MQTT
const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
tray_info_idx: trayInfoIdx,
tray_type: parsed.material || 'PLA',
tray_sub_brands: traySubBrands,
tray_color: color + 'FF', // Add alpha
nozzle_temp_min: tempMin,
nozzle_temp_max: tempMax,
cali_idx: caliIdx,
nozzle_diameter: nozzleDiameter,
setting_id: selectedPresetId, // Full setting ID for slicer compatibility
// Pass K profile's filament_id and setting_id for proper linking
kprofile_filament_id: selectedKProfile?.filament_id,
kprofile_setting_id: selectedKProfile?.setting_id || undefined,
// Also pass the K value directly for extrusion_cali_set command
k_value: kValue,
});
// Save the preset mapping so we can display the correct name in the UI
// This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
// which can't be resolved to a name via the filamentInfo API
try {
await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, selectedPresetId, traySubBrands);
} catch (e) {
console.warn('Failed to save slot preset mapping:', e);
// Don't fail the whole operation - slot was configured successfully
}
return result;
},
onSuccess: () => {
setShowSuccess(true);
onSuccess?.();
// Close after showing success briefly
setTimeout(() => {
setShowSuccess(false);
onClose();
}, 1500);
},
});
// Reset slot mutation
const resetMutation = useMutation({
mutationFn: async () => {
return api.resetAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId);
},
onSuccess: () => {
setShowSuccess(true);
onSuccess?.();
setTimeout(() => {
setShowSuccess(false);
onClose();
}, 1500);
},
});
// Filter filament presets based on search
const filteredPresets = useMemo(() => {
if (!cloudSettings?.filament) return [];
const query = searchQuery.toLowerCase();
return cloudSettings.filament
.filter(p => {
if (!query) return true;
return p.name.toLowerCase().includes(query);
})
.sort((a, b) => {
// Sort user presets first, then alphabetically
const aIsUser = isUserPreset(a.setting_id);
const bIsUser = isUserPreset(b.setting_id);
if (aIsUser && !bIsUser) return -1;
if (!aIsUser && bIsUser) return 1;
return a.name.localeCompare(b.name);
});
}, [cloudSettings?.filament, searchQuery]);
// Get full preset name for K profile filtering (brand + material, without printer suffix)
const selectedPresetInfo = useMemo(() => {
if (!selectedPresetId || !cloudSettings?.filament) return null;
const selectedPreset = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
if (!selectedPreset) return null;
// Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
let nameWithoutSuffix = selectedPreset.name.replace(/@.+$/, '').trim();
// Strip leading "# " from custom preset names (user convention)
if (nameWithoutSuffix.startsWith('# ')) {
nameWithoutSuffix = nameWithoutSuffix.slice(2).trim();
}
const parsed = parsePresetName(nameWithoutSuffix);
return {
fullName: nameWithoutSuffix,
material: parsed.material,
brand: parsed.brand,
};
}, [selectedPresetId, cloudSettings?.filament]);
// For backwards compatibility with the label
const selectedMaterial = selectedPresetInfo?.fullName || '';
const matchingKProfiles = useMemo(() => {
if (!kprofilesData?.profiles || !selectedPresetInfo) return [];
const { fullName, material, brand } = selectedPresetInfo;
const upperFullName = fullName.toUpperCase();
const upperMaterial = material.toUpperCase();
const upperBrand = brand.toUpperCase();
// Material must be at least 2 chars to avoid false positives
if (!upperMaterial || upperMaterial.length < 2) return [];
// Filter profiles - require brand match if brand is present in selected preset
const filtered = kprofilesData.profiles.filter(p => {
const profileName = p.name.toUpperCase();
// If the selected preset has a brand (e.g., "Azurefilm PLA Wood"),
// only show profiles that match the brand
if (upperBrand) {
// Must contain the brand name
if (!profileName.includes(upperBrand)) {
return false;
}
// And must contain the material type
if (!profileName.includes(upperMaterial)) {
return false;
}
return true;
}
// No brand in selected preset - match on full name or material
// Priority 1: Exact match with full name
if (profileName.includes(upperFullName)) {
return true;
}
// Priority 2: Material type match (only when no brand specified)
if (profileName.includes(upperMaterial)) {
return true;
}
// Check for common material aliases
const aliases: Record<string, string[]> = {
'NYLON': ['PA', 'PA-CF', 'PA6'],
'PA': ['NYLON'],
};
const materialAliases = aliases[upperMaterial] || [];
for (const alias of materialAliases) {
if (profileName.includes(alias)) {
return true;
}
}
return false;
});
// Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates)
// Prefer extruder_id=1 (High Flow) profiles as they're more commonly used on H2D
const seen = new Map<string, KProfile>();
for (const profile of filtered) {
const key = `${profile.name}|${profile.k_value}`;
const existing = seen.get(key);
if (!existing) {
seen.set(key, profile);
} else if (profile.extruder_id === 1 && existing.extruder_id === 0) {
// Replace extruder_id=0 profile with extruder_id=1 (High Flow) profile
seen.set(key, profile);
}
}
return Array.from(seen.values());
}, [kprofilesData?.profiles, selectedPresetInfo]);
// Pre-select current profile when modal opens, reset when closes
useEffect(() => {
if (isOpen && cloudSettings?.filament) {
// Try to pre-select current profile based on trayInfoIdx
if (slotInfo.trayInfoIdx) {
const currentPreset = cloudSettings.filament.find(
p => p.setting_id === slotInfo.trayInfoIdx
);
if (currentPreset) {
setSelectedPresetId(currentPreset.setting_id);
}
}
} else if (!isOpen) {
// Reset when modal closes
setSelectedPresetId('');
setSelectedKProfile(null);
setColorHex('');
setColorInput('');
setSearchQuery('');
setShowSuccess(false);
}
}, [isOpen, cloudSettings?.filament, slotInfo.trayInfoIdx]);
// Auto-select best matching K profile when preset changes
useEffect(() => {
if (matchingKProfiles.length > 0) {
// Auto-select first matching profile
setSelectedKProfile(matchingKProfiles[0]);
} else {
setSelectedKProfile(null);
}
}, [selectedPresetId, matchingKProfiles]);
// Escape key handler
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
const isLoading = settingsLoading || kprofilesLoading;
const canSave = selectedPresetId && !configureMutation.isPending;
// Get display color (custom or slot default)
const displayColor = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Settings2 className="w-5 h-5 text-bambu-blue" />
<h2 className="text-lg font-semibold text-white">Configure AMS Slot</h2>
</div>
<button
onClick={onClose}
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4 max-h-[60vh] overflow-y-auto">
{/* Success overlay */}
{showSuccess && (
<div className="absolute inset-0 bg-bambu-dark-secondary/95 z-10 flex items-center justify-center rounded-xl">
<div className="text-center space-y-3">
<CheckCircle2 className="w-16 h-16 text-bambu-green mx-auto" />
<p className="text-lg font-semibold text-white">Slot Configured!</p>
<p className="text-sm text-bambu-gray">Settings sent to printer</p>
</div>
</div>
)}
{/* Slot info */}
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">Configuring slot:</p>
<div className="flex items-center gap-2">
{slotInfo.trayColor && (
<span
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
/>
)}
<span className="text-white font-medium">
{getAmsLabel(slotInfo.amsId, slotInfo.trayCount)} Slot {slotInfo.trayId + 1}
</span>
{slotInfo.traySubBrands && (
<span className="text-bambu-gray">({slotInfo.traySubBrands})</span>
)}
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : (
<>
{/* Filament Profile Select */}
<div>
<label className="block text-sm text-bambu-gray mb-2">
Filament Profile <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
type="text"
placeholder="Search presets..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2"
/>
<div className="max-h-48 overflow-y-auto space-y-1">
{filteredPresets.length === 0 ? (
<p className="text-center py-4 text-bambu-gray">
{cloudSettings?.filament?.length === 0
? 'No cloud presets. Login to Bambu Cloud to sync.'
: 'No matching presets found.'}
</p>
) : (
filteredPresets.map((preset) => (
<button
key={preset.setting_id}
onClick={() => setSelectedPresetId(preset.setting_id)}
className={`w-full p-2 rounded-lg border text-left transition-colors ${
selectedPresetId === preset.setting_id
? 'bg-bambu-green/20 border-bambu-green'
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="flex items-center justify-between">
<span className="text-white text-sm truncate">{preset.name}</span>
{isUserPreset(preset.setting_id) && (
<span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
Custom
</span>
)}
</div>
</button>
))
)}
</div>
</div>
</div>
{/* K Profile Select */}
<div>
<label className="block text-sm text-bambu-gray mb-2">
K Profile (Pressure Advance)
{selectedMaterial && (
<span className="ml-2 text-xs text-bambu-blue">
Filtering for: {selectedMaterial}
</span>
)}
</label>
{matchingKProfiles.length > 0 ? (
<div className="relative">
<select
value={selectedKProfile?.name || ''}
onChange={(e) => {
const profile = matchingKProfiles.find(p => p.name === e.target.value);
setSelectedKProfile(profile || null);
}}
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 appearance-none pr-10"
>
<option value="">No K profile (use default 0.020)</option>
{matchingKProfiles.map((profile) => (
<option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
{profile.name} (K={profile.k_value})
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
) : selectedPresetId ? (
<p className="text-sm text-bambu-gray italic py-2">
No matching K profiles found. Default K=0.020 will be used.
</p>
) : (
<span className="inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30">
Select a filament profile first
</span>
)}
{selectedKProfile && (
<p className="text-xs text-bambu-green mt-1">
K={selectedKProfile.k_value} from printer calibration
</p>
)}
</div>
{/* Optional: Custom color */}
<div>
<label className="block text-sm text-bambu-gray mb-2">
Custom Color (optional)
</label>
{/* Quick color buttons */}
<div className="flex flex-wrap gap-1.5 mb-2">
{QUICK_COLORS_BASIC.map((color) => (
<button
key={color.hex}
onClick={() => {
setColorHex(color.hex);
setColorInput(color.name);
}}
className={`w-7 h-7 rounded-md border-2 transition-all ${
colorHex === color.hex
? 'border-bambu-green scale-110'
: 'border-white/20 hover:border-white/40'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
/>
))}
<button
onClick={() => setShowExtendedColors(!showExtendedColors)}
className="w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs"
title={showExtendedColors ? 'Show less colors' : 'Show more colors'}
>
{showExtendedColors ? '−' : '+'}
</button>
</div>
{/* Extended colors (collapsible) */}
{showExtendedColors && (
<div className="flex flex-wrap gap-1.5 mb-2">
{QUICK_COLORS_EXTENDED.map((color) => (
<button
key={color.hex}
onClick={() => {
setColorHex(color.hex);
setColorInput(color.name);
}}
className={`w-7 h-7 rounded-md border-2 transition-all ${
colorHex === color.hex
? 'border-bambu-green scale-110'
: 'border-white/20 hover:border-white/40'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
/>
))}
</div>
)}
{/* Color input: name or hex */}
<div className="flex gap-2 items-center">
<div
className="w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0"
style={{ backgroundColor: `#${displayColor}` }}
/>
<input
type="text"
placeholder="Color name or hex (e.g., brown, FF8800)"
value={colorInput}
onChange={(e) => {
const input = e.target.value;
setColorInput(input);
// Try to parse as color name first
const nameHex = colorNameToHex(input);
if (nameHex) {
setColorHex(nameHex);
} else {
// Try to parse as hex code
const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
if (cleaned.length === 6) {
setColorHex(cleaned);
} else if (cleaned.length === 3) {
// Expand shorthand hex (e.g., F00 -> FF0000)
setColorHex(cleaned.split('').map(c => c + c).join(''));
}
}
}}
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm"
/>
{colorHex && (
<button
onClick={() => {
setColorHex('');
setColorInput('');
}}
className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
title="Clear custom color"
>
Clear
</button>
)}
</div>
{colorHex && (
<p className="text-xs text-bambu-gray mt-1.5">
Hex: #{colorHex}
</p>
)}
</div>
</>
)}
</div>
{/* Footer */}
<div className="flex justify-between p-4 border-t border-bambu-dark-tertiary">
{/* Reset button on the left */}
<Button
variant="secondary"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending || configureMutation.isPending}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
{resetMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Resetting...
</>
) : (
<>
<RotateCcw className="w-4 h-4" />
Reset Slot
</>
)}
</Button>
{/* Cancel and Configure buttons on the right */}
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => configureMutation.mutate()}
disabled={!canSave}
>
{configureMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Configuring...
</>
) : (
<>
<Settings2 className="w-4 h-4" />
Configure Slot
</>
)}
</Button>
</div>
</div>
{/* Error */}
{(configureMutation.isError || resetMutation.isError) && (
<div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
{(configureMutation.error as Error)?.message || (resetMutation.error as Error)?.message}
</div>
)}
</div>
</div>
);
}
@@ -1,754 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Calendar, Clock, X, AlertCircle, Power, Pencil, Hand, Check, AlertTriangle, Circle, RefreshCw, ChevronDown, ChevronUp, Layers, Settings } from 'lucide-react';
import { api } from '../api/client';
import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import { getColorName } from '../utils/colors';
interface EditQueueItemModalProps {
item: PrintQueueItem;
onClose: () => void;
}
export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [printerId, setPrinterId] = useState<number | null>(item.printer_id);
const [selectedPlate, setSelectedPlate] = useState<number | null>(item.plate_id);
// Check if scheduled_time is a "placeholder" far-future date (more than 6 months out)
const isPlaceholderDate = item.scheduled_time &&
new Date(item.scheduled_time).getTime() > Date.now() + (180 * 24 * 60 * 60 * 1000);
const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>(() => {
if (item.manual_start) return 'manual';
if (item.scheduled_time && !isPlaceholderDate) return 'scheduled';
return 'asap';
});
const [scheduledTime, setScheduledTime] = useState(() => {
if (item.scheduled_time && !isPlaceholderDate) {
// Convert ISO to local datetime-local format
const date = new Date(item.scheduled_time);
return date.toISOString().slice(0, 16);
}
return '';
});
const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(item.require_previous_success);
const [autoOffAfter, setAutoOffAfter] = useState(item.auto_off_after);
const [showFilamentMapping, setShowFilamentMapping] = useState(false);
const [showPrintOptions, setShowPrintOptions] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
// Print options
const [printOptions, setPrintOptions] = useState({
bed_levelling: item.bed_levelling ?? true,
flow_cali: item.flow_cali ?? false,
vibration_cali: item.vibration_cali ?? true,
layer_inspect: item.layer_inspect ?? false,
timelapse: item.timelapse ?? false,
use_ams: item.use_ams ?? true,
});
// Manual slot overrides: slot_id (1-indexed) -> globalTrayId
// Initialize from existing ams_mapping if present
const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
if (item.ams_mapping && Array.isArray(item.ams_mapping)) {
const mappings: Record<number, number> = {};
item.ams_mapping.forEach((globalTrayId, idx) => {
if (globalTrayId !== -1) {
mappings[idx + 1] = globalTrayId;
}
});
return mappings;
}
return {};
});
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: () => api.getPrinters(),
});
// Fetch available plates from the archived 3MF
const { data: platesData } = useQuery({
queryKey: ['archive-plates', item.archive_id],
queryFn: () => api.getArchivePlates(item.archive_id),
});
// Auto-select the first plate for single-plate files, or use existing plate_id
useEffect(() => {
if (platesData?.plates?.length === 1 && !selectedPlate) {
setSelectedPlate(platesData.plates[0].index);
}
}, [platesData, selectedPlate]);
const isMultiPlate = platesData?.is_multi_plate ?? false;
const plates = platesData?.plates ?? [];
// Fetch filament requirements from the archived 3MF (filtered by plate if selected)
const { data: filamentReqs } = useQuery({
queryKey: ['archive-filaments', item.archive_id, selectedPlate],
queryFn: () => api.getArchiveFilamentRequirements(item.archive_id, selectedPlate ?? undefined),
enabled: selectedPlate !== null || !isMultiPlate,
});
// Fetch printer status when a printer is selected
const { data: printerStatus } = useQuery({
queryKey: ['printer-status', printerId],
queryFn: () => api.getPrinterStatus(printerId!),
enabled: printerId !== null,
});
// Clear manual mappings when printer or plate changes (but not on initial load)
const [initialPrinterId] = useState(item.printer_id);
const [initialPlateId] = useState(item.plate_id);
useEffect(() => {
if (printerId !== initialPrinterId || selectedPlate !== initialPlateId) {
setManualMappings({});
}
}, [printerId, initialPrinterId, selectedPlate, initialPlateId]);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
const normalizeColor = (color: string | null | undefined): string => {
if (!color) return '#808080';
const hex = color.replace('#', '').substring(0, 6);
return `#${hex}`;
};
// Helper to format slot label for display
const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
if (isExternal) return 'External';
const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
if (isHt) return `HT-${letter}`;
return `AMS-${letter} Slot ${trayId + 1}`;
};
// Calculate global tray ID for MQTT command
const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
if (isExternal) return 254;
return amsId * 4 + trayId;
};
// Build a list of all loaded filaments from printer's AMS/HT/External
const loadedFilaments = useMemo(() => {
const filaments: Array<{
type: string;
color: string;
colorName: string;
amsId: number;
trayId: number;
isHt: boolean;
isExternal: boolean;
label: string;
globalTrayId: number;
}> = [];
printerStatus?.ams?.forEach((amsUnit) => {
const isHt = amsUnit.tray.length === 1;
amsUnit.tray.forEach((tray) => {
if (tray.tray_type) {
const color = normalizeColor(tray.tray_color);
filaments.push({
type: tray.tray_type,
color,
colorName: getColorName(color),
amsId: amsUnit.id,
trayId: tray.id,
isHt,
isExternal: false,
label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
});
}
});
});
if (printerStatus?.vt_tray?.tray_type) {
const color = normalizeColor(printerStatus.vt_tray.tray_color);
filaments.push({
type: printerStatus.vt_tray.tray_type,
color,
colorName: getColorName(color),
amsId: -1,
trayId: 0,
isHt: false,
isExternal: true,
label: 'External',
globalTrayId: 254,
});
}
return filaments;
}, [printerStatus]);
// Compare required filaments with loaded filaments
const filamentComparison = useMemo(() => {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
const normalizeColorForCompare = (color: string | undefined): string => {
if (!color) return '';
return color.replace('#', '').toLowerCase().substring(0, 6);
};
const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
const hex1 = normalizeColorForCompare(color1);
const hex2 = normalizeColorForCompare(color2);
if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
const r1 = parseInt(hex1.substring(0, 2), 16);
const g1 = parseInt(hex1.substring(2, 4), 16);
const b1 = parseInt(hex1.substring(4, 6), 16);
const r2 = parseInt(hex2.substring(0, 2), 16);
const g2 = parseInt(hex2.substring(2, 4), 16);
const b2 = parseInt(hex2.substring(4, 6), 16);
return Math.abs(r1 - r2) <= threshold &&
Math.abs(g1 - g2) <= threshold &&
Math.abs(b1 - b2) <= threshold;
};
const usedTrayIds = new Set<number>(Object.values(manualMappings));
return filamentReqs.filaments.map((req) => {
const slotId = req.slot_id || 0;
// Check if there's a manual override for this slot
if (slotId > 0 && manualMappings[slotId] !== undefined) {
const manualTrayId = manualMappings[slotId];
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
if (manualLoaded) {
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
colorsAreSimilar(manualLoaded.color, req.color);
let status: 'match' | 'type_only' | 'mismatch' | 'empty';
if (typeMatch && colorMatch) {
status = 'match';
} else if (typeMatch) {
status = 'type_only';
} else {
status = 'mismatch';
}
return {
...req,
loaded: manualLoaded,
hasFilament: true,
typeMatch,
colorMatch,
status,
isManual: true,
};
}
}
// Auto-match
const exactMatch = loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = !exactMatch && loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
const hasFilament = !!loaded;
const typeMatch = hasFilament;
const colorMatch = !!exactMatch || !!similarMatch;
let status: 'match' | 'type_only' | 'mismatch' | 'empty';
if (exactMatch || similarMatch) {
status = 'match';
} else if (typeOnlyMatch) {
status = 'type_only';
} else {
status = 'mismatch';
}
return {
...req,
loaded,
hasFilament,
typeMatch,
colorMatch,
status,
isManual: false,
};
});
}, [filamentReqs, loadedFilaments, manualMappings]);
// Build AMS mapping array
const amsMapping = useMemo(() => {
if (filamentComparison.length === 0) return undefined;
const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
if (maxSlotId <= 0) return undefined;
const mapping = new Array(maxSlotId).fill(-1);
filamentComparison.forEach((f) => {
if (f.slot_id && f.slot_id > 0) {
mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
}
});
return mapping;
}, [filamentComparison]);
const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
const updateMutation = useMutation({
mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(item.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Queue item updated');
onClose();
},
onError: (error: Error) => {
showToast(error.message || 'Failed to update queue item', 'error');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: PrintQueueItemUpdate = {
printer_id: printerId,
require_previous_success: requirePreviousSuccess,
auto_off_after: autoOffAfter,
manual_start: scheduleType === 'manual',
ams_mapping: amsMapping,
plate_id: selectedPlate,
...printOptions,
};
if (scheduleType === 'scheduled' && scheduledTime) {
data.scheduled_time = new Date(scheduledTime).toISOString();
} else {
data.scheduled_time = null;
}
updateMutation.mutate(data);
};
// Get minimum datetime (now + 1 minute)
const getMinDateTime = () => {
const now = new Date();
now.setMinutes(now.getMinutes() + 1);
return now.toISOString().slice(0, 16);
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<CardContent className="p-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-bambu-green" />
<h2 className="text-xl font-semibold text-white">Edit Queue Item</h2>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{/* Archive name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Print Job</label>
<p className="text-white font-medium truncate">
{item.archive_name || `Archive #${item.archive_id}`}
</p>
</div>
{/* Printer selection */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
{printers?.length === 0 ? (
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
No printers configured
</div>
) : (
<>
<select
className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:border-bambu-green focus:outline-none ${
printerId === null ? 'border-orange-400' : 'border-bambu-dark-tertiary'
}`}
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- Select a printer --</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{printerId === null && (
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Assign a printer to enable printing
</p>
)}
</>
)}
</div>
{/* Plate selection - show when multi-plate file detected */}
{isMultiPlate && plates.length > 1 && (
<div>
<div className="flex items-center gap-2 mb-2">
<Layers className="w-4 h-4 text-bambu-gray" />
<label className="text-sm text-bambu-gray">Select Plate to Print</label>
{!selectedPlate && (
<span className="text-xs text-orange-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Selection required
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{plates.map((plate) => (
<button
key={plate.index}
type="button"
onClick={() => setSelectedPlate(plate.index)}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
selectedPlate === plate.index
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
}`}
>
{plate.has_thumbnail && plate.thumbnail_url ? (
<img
src={plate.thumbnail_url}
alt={`Plate ${plate.index}`}
className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
/>
) : (
<div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
<Layers className="w-5 h-5 text-bambu-gray" />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm text-white font-medium truncate">
Plate {plate.index}
</p>
<p className="text-xs text-bambu-gray truncate">
{plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
</p>
</div>
{selectedPlate === plate.index && (
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</button>
))}
</div>
</div>
)}
{/* Filament Mapping Section */}
{printerId !== null && (isMultiPlate ? selectedPlate !== null : true) && hasFilamentReqs && (
<div>
<button
type="button"
onClick={() => setShowFilamentMapping(!showFilamentMapping)}
className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
>
<Circle className="w-4 h-4" fill={filamentComparison.some(f => f.status === 'mismatch') ? '#f97316' : filamentComparison.some(f => f.status === 'type_only') ? '#facc15' : '#00ae42'} stroke="none" />
<span>Filament Mapping</span>
{filamentComparison.some(f => f.status === 'mismatch') ? (
<span className="text-xs text-orange-400">(Type not found)</span>
) : filamentComparison.some(f => f.status === 'type_only') ? (
<span className="text-xs text-yellow-400">(Color mismatch)</span>
) : (
<span className="text-xs text-bambu-green">(Ready)</span>
)}
{showFilamentMapping ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
</button>
{showFilamentMapping && (
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-bambu-gray">Click to change slot assignment</span>
<button
type="button"
onClick={async () => {
if (!printerId) return;
setIsRefreshing(true);
try {
await api.refreshPrinterStatus(printerId);
await new Promise((r) => setTimeout(r, 500));
await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
} finally {
setIsRefreshing(false);
}
}}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
disabled={isRefreshing}
>
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>Re-read</span>
</button>
</div>
{filamentComparison.map((item, idx) => (
<div
key={idx}
className="grid items-center gap-2 text-xs"
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
>
<span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
<Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
</span>
<span className="text-white truncate">
{item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
</span>
<span className="text-bambu-gray">→</span>
<select
value={item.loaded?.globalTrayId ?? ''}
onChange={(e) => {
const slotId = item.slot_id || 0;
if (slotId > 0) {
const value = e.target.value;
if (value === '') {
setManualMappings((prev) => {
const next = { ...prev };
delete next[slotId];
return next;
});
} else {
setManualMappings((prev) => ({
...prev,
[slotId]: parseInt(value, 10),
}));
}
}
}}
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
item.status === 'match'
? 'border-bambu-green/50 text-bambu-green'
: item.status === 'type_only'
? 'border-yellow-400/50 text-yellow-400'
: 'border-orange-400/50 text-orange-400'
} ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
title={item.isManual ? 'Manually selected' : 'Auto-matched'}
>
<option value="" className="bg-bambu-dark text-bambu-gray">
-- Select slot --
</option>
{loadedFilaments.map((f) => (
<option
key={f.globalTrayId}
value={f.globalTrayId}
className="bg-bambu-dark text-white"
>
{f.label}: {f.type} ({f.colorName})
</option>
))}
</select>
{item.status === 'match' ? (
<Check className="w-3 h-3 text-bambu-green" />
) : item.status === 'type_only' ? (
<span title="Same type, different color">
<AlertTriangle className="w-3 h-3 text-yellow-400" />
</span>
) : (
<span title="Filament type not loaded">
<AlertTriangle className="w-3 h-3 text-orange-400" />
</span>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Print Options */}
<div>
<button
type="button"
onClick={() => setShowPrintOptions(!showPrintOptions)}
className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
>
<Settings className="w-4 h-4" />
<span>Print Options</span>
{showPrintOptions ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
</button>
{showPrintOptions && (
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
{[
{ key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
{ key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
{ key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
{ key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
{ key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
].map(({ key, label, desc }) => (
<label key={key} className="flex items-center justify-between cursor-pointer group">
<div>
<span className="text-sm text-white">{label}</span>
<p className="text-xs text-bambu-gray">{desc}</p>
</div>
<div
className={`relative w-10 h-5 rounded-full transition-colors ${
printOptions[key as keyof typeof printOptions] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
onClick={() => setPrintOptions((prev) => ({ ...prev, [key]: !prev[key as keyof typeof printOptions] }))}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
printOptions[key as keyof typeof printOptions] ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</div>
</label>
))}
</div>
)}
</div>
{/* Schedule type */}
<div>
<label className="block text-sm text-bambu-gray mb-2">When to print</label>
<div className="flex gap-2">
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'asap'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('asap')}
>
<Clock className="w-4 h-4" />
ASAP
</button>
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'scheduled'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('scheduled')}
>
<Calendar className="w-4 h-4" />
Scheduled
</button>
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'manual'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('manual')}
>
<Hand className="w-4 h-4" />
Queue Only
</button>
</div>
</div>
{/* Scheduled time input */}
{scheduleType === 'scheduled' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
<input
type="datetime-local"
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"
value={scheduledTime}
onChange={(e) => setScheduledTime(e.target.value)}
min={getMinDateTime()}
required
/>
</div>
)}
{/* Require previous success */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="requirePrevious"
checked={requirePreviousSuccess}
onChange={(e) => setRequirePreviousSuccess(e.target.checked)}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
Only start if previous print succeeded
</label>
</div>
{/* Auto power off */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoOffAfter"
checked={autoOffAfter}
onChange={(e) => setAutoOffAfter(e.target.checked)}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
<Power className="w-3.5 h-3.5" />
Power off printer when done
</label>
</div>
{/* Help text */}
<p className="text-xs text-bambu-gray">
{scheduleType === 'asap'
? 'Print will start as soon as the printer is idle.'
: scheduleType === 'scheduled'
? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
: 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
</p>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={updateMutation.isPending || printers?.length === 0}
>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,399 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff } from 'lucide-react';
import { api } from '../api/client';
interface EmbeddedCameraViewerProps {
printerId: number;
printerName: string;
viewerIndex?: number; // Used to offset multiple viewers
onClose: () => void;
}
const STORAGE_KEY_PREFIX = 'embeddedCameraState_';
const MAX_RECONNECT_ATTEMPTS = 5;
const INITIAL_RECONNECT_DELAY = 2000;
const MAX_RECONNECT_DELAY = 30000;
const STALL_CHECK_INTERVAL = 5000;
interface CameraState {
x: number;
y: number;
width: number;
height: number;
}
const DEFAULT_STATE: CameraState = {
x: window.innerWidth - 420,
y: 20,
width: 400,
height: 300,
};
export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, onClose }: EmbeddedCameraViewerProps) {
// Printer-specific storage key
const storageKey = `${STORAGE_KEY_PREFIX}${printerId}`;
// Load saved state or use defaults (offset for new viewers without saved state)
const loadState = (): CameraState => {
try {
const saved = localStorage.getItem(storageKey);
if (saved) {
const state = JSON.parse(saved);
// Validate state is on screen
return {
x: Math.min(Math.max(0, state.x), window.innerWidth - 100),
y: Math.min(Math.max(0, state.y), window.innerHeight - 100),
width: Math.max(200, Math.min(state.width, window.innerWidth - 20)),
height: Math.max(150, Math.min(state.height, window.innerHeight - 20)),
};
}
} catch {
// Ignore parse errors
}
// Offset new viewers so they don't stack exactly on top of each other
const offset = viewerIndex * 30;
return {
...DEFAULT_STATE,
x: Math.max(0, DEFAULT_STATE.x - offset),
y: Math.max(0, DEFAULT_STATE.y + offset),
};
};
const [state, setState] = useState<CameraState>(loadState);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [isMinimized, setIsMinimized] = useState(false);
// Stream state
const [streamError, setStreamError] = useState(false);
const [streamLoading, setStreamLoading] = useState(true);
const [imageKey, setImageKey] = useState(Date.now());
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [isReconnecting, setIsReconnecting] = useState(false);
const [reconnectCountdown, setReconnectCountdown] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Fetch printer info
const { data: printer } = useQuery({
queryKey: ['printer', printerId],
queryFn: () => api.getPrinter(printerId),
enabled: printerId > 0,
});
// Save state to localStorage (printer-specific)
useEffect(() => {
const saveTimeout = setTimeout(() => {
localStorage.setItem(storageKey, JSON.stringify(state));
}, 500);
return () => clearTimeout(saveTimeout);
}, [state, storageKey]);
// Cleanup on unmount
const stopSentRef = useRef(false);
useEffect(() => {
stopSentRef.current = false;
const stopUrl = `/api/v1/printers/${printerId}/camera/stop`;
const sendStopOnce = () => {
if (printerId > 0 && !stopSentRef.current) {
stopSentRef.current = true;
navigator.sendBeacon(stopUrl);
}
};
const imgElement = imgRef.current;
return () => {
if (imgElement) {
imgElement.src = '';
}
sendStopOnce();
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
if (stallCheckIntervalRef.current) clearInterval(stallCheckIntervalRef.current);
};
}, [printerId]);
// Auto-hide loading after timeout
useEffect(() => {
if (streamLoading) {
const timer = setTimeout(() => setStreamLoading(false), 3000);
return () => clearTimeout(timer);
}
}, [streamLoading, imageKey]);
// Auto-reconnect logic
const attemptReconnect = useCallback(() => {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
setIsReconnecting(false);
setStreamError(true);
return;
}
const delay = Math.min(
INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempts),
MAX_RECONNECT_DELAY
);
setIsReconnecting(true);
setReconnectCountdown(Math.ceil(delay / 1000));
countdownIntervalRef.current = setInterval(() => {
setReconnectCountdown((prev) => {
if (prev <= 1) {
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
return 0;
}
return prev - 1;
});
}, 1000);
reconnectTimerRef.current = setTimeout(() => {
setReconnectAttempts((prev) => prev + 1);
setIsReconnecting(false);
setStreamLoading(true);
setStreamError(false);
if (imgRef.current) imgRef.current.src = '';
setImageKey(Date.now());
}, delay);
}, [reconnectAttempts]);
// Stall detection
useEffect(() => {
if (streamLoading || isReconnecting || isMinimized) {
if (stallCheckIntervalRef.current) {
clearInterval(stallCheckIntervalRef.current);
stallCheckIntervalRef.current = null;
}
return;
}
stallCheckIntervalRef.current = setInterval(async () => {
try {
const response = await fetch(`/api/v1/printers/${printerId}/camera/status`);
if (response.ok) {
const status = await response.json();
if (status.stalled || (!status.active && !streamError)) {
if (stallCheckIntervalRef.current) {
clearInterval(stallCheckIntervalRef.current);
stallCheckIntervalRef.current = null;
}
setStreamLoading(false);
attemptReconnect();
}
}
} catch {
// Ignore errors
}
}, STALL_CHECK_INTERVAL);
return () => {
if (stallCheckIntervalRef.current) {
clearInterval(stallCheckIntervalRef.current);
stallCheckIntervalRef.current = null;
}
};
}, [streamLoading, streamError, isReconnecting, isMinimized, printerId, attemptReconnect]);
const handleStreamError = () => {
setStreamLoading(false);
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
attemptReconnect();
} else {
setStreamError(true);
}
};
const handleStreamLoad = () => {
setStreamLoading(false);
setStreamError(false);
setReconnectAttempts(0);
setIsReconnecting(false);
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
};
const refresh = () => {
setStreamLoading(true);
setStreamError(false);
setReconnectAttempts(0);
setIsReconnecting(false);
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
fetch(`/api/v1/printers/${printerId}/camera/stop`).catch(() => {});
if (imgRef.current) imgRef.current.src = '';
setTimeout(() => setImageKey(Date.now()), 100);
};
// Drag handlers
const handleMouseDown = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('.no-drag')) return;
setIsDragging(true);
setDragOffset({
x: e.clientX - state.x,
y: e.clientY - state.y,
});
};
// Resize handlers
const handleResizeMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
setIsResizing(true);
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
setState((prev) => ({
...prev,
x: Math.max(0, Math.min(e.clientX - dragOffset.x, window.innerWidth - prev.width)),
y: Math.max(0, Math.min(e.clientY - dragOffset.y, window.innerHeight - prev.height)),
}));
} else if (isResizing && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setState((prev) => ({
...prev,
width: Math.max(200, Math.min(e.clientX - rect.left, window.innerWidth - prev.x - 10)),
height: Math.max(150, Math.min(e.clientY - rect.top, window.innerHeight - prev.y - 10)),
}));
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
};
if (isDragging || isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}
}, [isDragging, isResizing, dragOffset]);
const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=10&t=${imageKey}`;
return (
<div
ref={containerRef}
className="fixed z-50 bg-bambu-dark-secondary rounded-lg shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
style={{
left: state.x,
top: state.y,
width: isMinimized ? 200 : state.width,
height: isMinimized ? 40 : state.height,
cursor: isDragging ? 'grabbing' : 'default',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-3 py-2 bg-bambu-dark border-b border-bambu-dark-tertiary cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown}
>
<div className="flex items-center gap-2 text-sm text-white truncate">
<GripVertical className="w-4 h-4 text-bambu-gray flex-shrink-0" />
<span className="truncate">{printer?.name || printerName}</span>
</div>
<div className="flex items-center gap-1 no-drag">
<button
onClick={refresh}
disabled={streamLoading || isReconnecting}
className="p-1 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
title="Refresh stream"
>
<RefreshCw className={`w-3.5 h-3.5 text-bambu-gray ${streamLoading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setIsMinimized(!isMinimized)}
className="p-1 hover:bg-bambu-dark-tertiary rounded"
title={isMinimized ? 'Expand' : 'Minimize'}
>
{isMinimized ? (
<Maximize2 className="w-3.5 h-3.5 text-bambu-gray" />
) : (
<Minimize2 className="w-3.5 h-3.5 text-bambu-gray" />
)}
</button>
<button
onClick={onClose}
className="p-1 hover:bg-red-500/20 rounded"
title="Close"
>
<X className="w-3.5 h-3.5 text-bambu-gray hover:text-red-400" />
</button>
</div>
</div>
{/* Video area */}
{!isMinimized && (
<div className="relative w-full h-[calc(100%-40px)] bg-black flex items-center justify-center">
{streamLoading && !isReconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
<RefreshCw className="w-6 h-6 text-bambu-gray animate-spin" />
</div>
)}
{isReconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
<div className="text-center p-2">
<WifiOff className="w-6 h-6 text-orange-400 mx-auto mb-2" />
<p className="text-xs text-bambu-gray">
Reconnecting in {reconnectCountdown}s...
</p>
</div>
</div>
)}
{streamError && !isReconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-black z-10">
<div className="text-center p-2">
<AlertTriangle className="w-6 h-6 text-orange-400 mx-auto mb-2" />
<p className="text-xs text-bambu-gray mb-2">Camera unavailable</p>
<button
onClick={refresh}
className="px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80"
>
Retry
</button>
</div>
</div>
)}
<img
ref={imgRef}
key={imageKey}
src={streamUrl}
alt="Camera stream"
className="max-w-full max-h-full object-contain"
onError={handleStreamError}
onLoad={handleStreamLoad}
/>
{/* Resize handle */}
<div
className="absolute bottom-0 right-0 w-6 h-6 cursor-se-resize no-drag hover:bg-white/10 rounded-tl transition-colors"
onMouseDown={handleResizeMouseDown}
title="Drag to resize"
>
<svg
className="w-6 h-6 text-bambu-gray/70 hover:text-bambu-gray"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22ZM22 10H20V8H22V10ZM18 14H16V12H18V14ZM14 18H12V16H14V18ZM10 22H8V20H10V22Z" />
</svg>
</div>
</div>
)}
</div>
);
}
+54 -7
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { Droplets, Link2, Copy, Check } from 'lucide-react';
import { Droplets, Link2, Copy, Check, Settings2 } from 'lucide-react';
interface FilamentData {
vendor: 'Bambu Lab' | 'Generic';
@@ -17,19 +17,25 @@ interface SpoolmanConfig {
hasUnlinkedSpools?: boolean; // Whether there are spools available to link
}
interface ConfigureSlotConfig {
enabled: boolean;
onConfigure?: () => void;
}
interface FilamentHoverCardProps {
data: FilamentData;
children: ReactNode;
disabled?: boolean;
className?: string;
spoolman?: SpoolmanConfig;
configureSlot?: ConfigureSlotConfig;
}
/**
* 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 = '', spoolman }: FilamentHoverCardProps) {
export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState<'top' | 'bottom'>('top');
const [copied, setCopied] = useState(false);
@@ -287,6 +293,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
)}
</div>
)}
{/* Configure slot section - always show if enabled */}
{configureSlot?.enabled && (
<div className={`${spoolman?.enabled && data.trayUuid ? '' : 'pt-2 mt-2 border-t border-bambu-dark-tertiary'}`}>
<button
onClick={(e) => {
e.stopPropagation();
configureSlot.onConfigure?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
title="Configure slot with filament profile and K value"
>
<Settings2 className="w-3.5 h-3.5" />
Configure
</button>
</div>
)}
</div>
</div>
@@ -307,10 +330,16 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
);
}
interface EmptySlotHoverCardProps {
children: ReactNode;
className?: string;
configureSlot?: ConfigureSlotConfig;
}
/**
* Wrapper for empty slots - just shows "Empty" on hover
* Wrapper for empty slots - shows "Empty" on hover with optional configure button
*/
export function EmptySlotHoverCard({ children, className = '' }: { children: ReactNode; className?: string }) {
export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -344,10 +373,28 @@ export function EmptySlotHoverCard({ children, className = '' }: { children: Rea
animate-in fade-in-0 zoom-in-95 duration-150
">
<div className="
px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary
rounded-md shadow-lg text-xs text-bambu-gray whitespace-nowrap
bg-bambu-dark-secondary border border-bambu-dark-tertiary
rounded-md shadow-lg overflow-hidden
">
Empty slot
<div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
Empty slot
</div>
{/* Configure slot button */}
{configureSlot?.enabled && (
<div className="px-2 pb-2">
<button
onClick={(e) => {
e.stopPropagation();
configureSlot.onConfigure?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
title="Configure slot with filament profile and K value"
>
<Settings2 className="w-3.5 h-3.5" />
Configure
</button>
</div>
)}
</div>
<div className="
absolute left-1/2 -translate-x-1/2 top-full w-0 h-0
+53 -2
View File
@@ -15,6 +15,7 @@ import {
FileText,
Image,
Search,
ArrowUpDown,
} from 'lucide-react';
import { api } from '../api/client';
import { Button } from './Button';
@@ -66,6 +67,17 @@ function getFileIcon(filename: string, isDirectory: boolean) {
}
}
type SortOption = 'name-asc' | 'name-desc' | 'size-asc' | 'size-desc' | 'date-asc' | 'date-desc';
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'name-asc', label: 'Name (A-Z)' },
{ value: 'name-desc', label: 'Name (Z-A)' },
{ value: 'size-asc', label: 'Size (smallest)' },
{ value: 'size-desc', label: 'Size (largest)' },
{ value: 'date-asc', label: 'Date (oldest)' },
{ value: 'date-desc', label: 'Date (newest)' },
];
export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {
const { showToast } = useToast();
const queryClient = useQueryClient();
@@ -73,6 +85,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [fileToDelete, setFileToDelete] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<SortOption>('name-asc');
// Close on Escape key
useEffect(() => {
@@ -206,6 +219,20 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
className="w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="relative flex items-center gap-1">
<ArrowUpDown className="w-4 h-4 text-bambu-gray" />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="appearance-none bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm py-1.5 pl-2 pr-6 focus:border-bambu-green focus:outline-none cursor-pointer"
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<Button
variant="secondary"
size="sm"
@@ -240,15 +267,39 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
</div>
) : (
<div className="space-y-1">
{/* Filter and sort: directories first, then files */}
{/* Filter and sort: directories first, then files with selected sort */}
{[...data.files]
.filter((file) =>
!searchQuery || file.name.toLowerCase().includes(searchQuery.toLowerCase())
)
.sort((a, b) => {
// Directories always first
if (a.is_directory && !b.is_directory) return -1;
if (!a.is_directory && b.is_directory) return 1;
return a.name.localeCompare(b.name);
// Apply selected sort within same type
switch (sortBy) {
case 'name-asc':
return a.name.localeCompare(b.name);
case 'name-desc':
return b.name.localeCompare(a.name);
case 'size-asc':
return a.size - b.size;
case 'size-desc':
return b.size - a.size;
case 'date-asc': {
const aTime = a.mtime ? new Date(a.mtime).getTime() : 0;
const bTime = b.mtime ? new Date(b.mtime).getTime() : 0;
return aTime - bTime;
}
case 'date-desc': {
const aTime = a.mtime ? new Date(a.mtime).getTime() : 0;
const bTime = b.mtime ? new Date(b.mtime).getTime() : 0;
return bTime - aTime;
}
default:
return a.name.localeCompare(b.name);
}
})
.map((file) => {
const FileIcon = getFileIcon(file.name, file.is_directory);
+352
View File
@@ -0,0 +1,352 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Play,
Square,
Trash2,
RefreshCw,
Search,
X,
ChevronDown,
ChevronUp,
AlertCircle,
AlertTriangle,
Info,
Bug,
} from 'lucide-react';
import { supportApi, type LogEntry } from '../api/client';
const LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
type LogLevel = (typeof LOG_LEVELS)[number];
const levelColors: Record<LogLevel, string> = {
DEBUG: 'text-gray-400',
INFO: 'text-blue-400',
WARNING: 'text-yellow-400',
ERROR: 'text-red-400',
};
const levelIcons: Record<LogLevel, typeof Info> = {
DEBUG: Bug,
INFO: Info,
WARNING: AlertTriangle,
ERROR: AlertCircle,
};
export function LogViewer() {
const queryClient = useQueryClient();
const [autoScroll, setAutoScroll] = useState(true);
const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [levelFilter, setLevelFilter] = useState<LogLevel | 'ALL'>('ALL');
const [isExpanded, setIsExpanded] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null);
// Fetch logs with polling when streaming is enabled
const { data, isLoading, refetch } = useQuery({
queryKey: ['application-logs', levelFilter, searchQuery],
queryFn: () =>
supportApi.getLogs({
limit: 200,
level: levelFilter === 'ALL' ? undefined : levelFilter,
search: searchQuery || undefined,
}),
refetchInterval: isStreaming ? 2000 : false, // Poll every 2 seconds when streaming
enabled: isExpanded, // Only fetch when viewer is expanded
});
// Stop streaming when viewer is collapsed
useEffect(() => {
if (!isExpanded) {
setIsStreaming(false);
}
}, [isExpanded]);
const clearMutation = useMutation({
mutationFn: () => supportApi.clearLogs(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application-logs'] });
},
});
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
if (autoScroll && logContainerRef.current && data?.entries) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [data?.entries, autoScroll]);
const toggleExpand = (index: number) => {
setExpandedLogs((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
};
const formatTimestamp = (timestamp: string) => {
// Input format: "2024-01-15 10:30:45,123"
const parts = timestamp.split(' ');
if (parts.length >= 2) {
return parts[1]; // Return just the time part
}
return timestamp;
};
const entries = useMemo(() => data?.entries ?? [], [data?.entries]);
// Reverse to show newest at bottom (better for auto-scroll UX)
const displayEntries = useMemo(() => [...entries].reverse(), [entries]);
const LevelIcon = ({ level }: { level: string }) => {
const Icon = levelIcons[level as LogLevel] || Info;
return <Icon className={`w-3.5 h-3.5 ${levelColors[level as LogLevel] || 'text-gray-400'}`} />;
};
return (
<div className="bg-bambu-dark rounded-lg overflow-hidden">
{/* Header - always visible */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 hover:bg-bambu-dark-tertiary/50 transition-colors"
>
<div className="flex items-center gap-3">
<div
className={`p-2 rounded-lg ${
isStreaming
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-dark-tertiary text-bambu-gray'
}`}
>
<Bug className="w-5 h-5" />
</div>
<div className="text-left">
<p className="font-medium text-white">Application Logs</p>
<p className="text-sm text-bambu-gray">
{isStreaming
? `Live streaming - ${data?.filtered_count ?? 0} entries`
: 'View and filter application logs'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isStreaming && (
<span className="flex items-center gap-1.5 px-2 py-1 bg-bambu-green/20 rounded text-bambu-green text-xs">
<span className="w-1.5 h-1.5 bg-bambu-green rounded-full animate-pulse" />
Live
</span>
)}
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-bambu-gray" />
) : (
<ChevronDown className="w-5 h-5 text-bambu-gray" />
)}
</div>
</button>
{/* Expanded content */}
{isExpanded && (
<div className="border-t border-bambu-dark-tertiary">
{/* Controls */}
<div className="flex flex-col gap-2 p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2 flex-wrap">
{/* Start/Stop streaming button */}
{isStreaming ? (
<button
onClick={(e) => {
e.stopPropagation();
setIsStreaming(false);
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors"
>
<Square className="w-4 h-4" />
Stop
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation();
setIsStreaming(true);
refetch(); // Immediately fetch when starting
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 rounded transition-colors"
>
<Play className="w-4 h-4" />
Start
</button>
)}
{/* Clear button */}
<button
onClick={() => clearMutation.mutate()}
disabled={clearMutation.isPending || entries.length === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50"
>
<Trash2 className="w-4 h-4" />
Clear
</button>
{/* Refresh button */}
<button
onClick={() => refetch()}
disabled={isLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<div className="flex-1" />
{/* Auto-scroll toggle */}
<label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="rounded border-bambu-dark-tertiary bg-bambu-dark-tertiary"
/>
Auto-scroll
</label>
{/* Entry count */}
<span className="text-sm text-bambu-gray">
{data?.filtered_count ?? 0}/{data?.total_in_file ?? 0}
</span>
</div>
{/* Search and Filter Row */}
<div className="flex items-center gap-2">
{/* Search input */}
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
placeholder="Search message or logger name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-8 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Level filter */}
<div className="flex items-center gap-1 bg-bambu-dark-secondary rounded border border-bambu-dark-tertiary">
<button
onClick={() => setLevelFilter('ALL')}
className={`px-2 py-1.5 text-xs rounded-l transition-colors ${
levelFilter === 'ALL'
? 'bg-bambu-green text-white'
: 'text-bambu-gray hover:text-white'
}`}
>
All
</button>
{LOG_LEVELS.map((level, idx) => (
<button
key={level}
onClick={() => setLevelFilter(level)}
className={`px-2 py-1.5 text-xs transition-colors flex items-center gap-1 ${
idx === LOG_LEVELS.length - 1 ? 'rounded-r' : ''
} ${
levelFilter === level
? `${levelColors[level]} bg-bambu-dark-tertiary`
: 'text-bambu-gray hover:text-white'
}`}
>
{level}
</button>
))}
</div>
</div>
</div>
{/* Log Content */}
<div
ref={logContainerRef}
className="overflow-auto font-mono text-xs bg-black min-h-[300px] max-h-[500px]"
>
{entries.length === 0 ? (
<div className="flex flex-col items-center justify-center h-[300px] text-bambu-gray">
<p className="mb-2">No log entries found</p>
<p className="text-sm">Log file may be empty or cleared</p>
</div>
) : (
<div className="divide-y divide-bambu-dark-tertiary/30">
{displayEntries.map((log: LogEntry, index: number) => {
const isEntryExpanded = expandedLogs.has(index);
const hasMultiLine = log.message.includes('\n');
return (
<div
key={index}
className={`p-2 cursor-pointer hover:bg-bambu-dark-secondary/50 transition-colors ${
isEntryExpanded ? 'bg-bambu-dark-secondary/30' : ''
}`}
onClick={() => hasMultiLine && toggleExpand(index)}
>
<div className="flex items-start gap-2">
<span className="text-bambu-gray/70 shrink-0 w-20">
{formatTimestamp(log.timestamp)}
</span>
<span className="shrink-0">
<LevelIcon level={log.level} />
</span>
<span className="text-purple-400/80 shrink-0 max-w-[200px] truncate" title={log.logger_name}>
[{log.logger_name}]
</span>
<span
className={`flex-1 ${levelColors[log.level as LogLevel] || 'text-white/80'} ${
!isEntryExpanded && hasMultiLine ? 'truncate' : ''
}`}
>
{isEntryExpanded ? (
<pre className="whitespace-pre-wrap break-all">{log.message}</pre>
) : (
log.message.split('\n')[0]
)}
</span>
{hasMultiLine && (
<span className="text-bambu-gray/50 shrink-0">
{isEntryExpanded ? (
<ChevronUp className="w-3.5 h-3.5" />
) : (
<ChevronDown className="w-3.5 h-3.5" />
)}
</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-3 border-t border-bambu-dark-tertiary text-sm text-bambu-gray">
{isStreaming ? (
<span className="flex items-center gap-2">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Auto-refreshing every 2 seconds
</span>
) : (
<span>Click Start to enable live log streaming</span>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,178 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
import { api } from '../../api/client';
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
import { getColorName } from '../../utils/colors';
import type { FilamentMappingProps } from './types';
/**
* Filament mapping UI for comparing required filaments with loaded AMS slots.
* Shows auto-matched and manually overridden slot assignments.
*/
export function FilamentMapping({
printerId,
filamentReqs,
manualMappings,
onManualMappingChange,
defaultExpanded = false,
}: FilamentMappingProps & { defaultExpanded?: boolean }) {
const queryClient = useQueryClient();
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
// Fetch printer status
const { data: printerStatus } = useQuery({
queryKey: ['printer-status', printerId],
queryFn: () => api.getPrinterStatus(printerId),
enabled: !!printerId,
});
const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
useFilamentMapping(filamentReqs, printerStatus, manualMappings);
const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
// Don't render if no filament requirements
if (!hasFilamentReqs) {
return null;
}
// Don't render until we have printer status to do the comparison
if (!printerStatus) {
return null;
}
// Determine status indicator color
const statusColor = hasTypeMismatch
? '#f97316' // orange
: hasColorMismatch
? '#facc15' // yellow
: '#00ae42'; // green
const handleSlotChange = (slotId: number, value: string) => {
if (slotId > 0) {
if (value === '') {
// Clear manual override
const next = { ...manualMappings };
delete next[slotId];
onManualMappingChange(next);
} else {
onManualMappingChange({
...manualMappings,
[slotId]: parseInt(value, 10),
});
}
}
};
const handleRefresh = async () => {
setIsRefreshing(true);
try {
// Request fresh data from printer via MQTT pushall command
await api.refreshPrinterStatus(printerId);
// Wait a moment for printer to respond, then refetch
await new Promise((r) => setTimeout(r, 500));
await queryClient.refetchQueries({ queryKey: ['printer-status', printerId] });
} finally {
setIsRefreshing(false);
}
};
return (
<div className="mb-4">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
>
<Circle className="w-4 h-4" fill={statusColor} stroke="none" />
<span>Filament Mapping</span>
{hasTypeMismatch ? (
<span className="text-xs text-orange-400">(Type not found)</span>
) : hasColorMismatch ? (
<span className="text-xs text-yellow-400">(Color mismatch)</span>
) : (
<span className="text-xs text-bambu-green">(Ready)</span>
)}
{isExpanded ? (
<ChevronUp className="w-4 h-4 ml-auto" />
) : (
<ChevronDown className="w-4 h-4 ml-auto" />
)}
</button>
{isExpanded && (
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-bambu-gray">Click to change slot assignment</span>
<button
type="button"
onClick={handleRefresh}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
disabled={isRefreshing}
>
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>Re-read</span>
</button>
</div>
{filamentComparison.map((item, idx) => (
<div
key={idx}
className="grid items-center gap-2 text-xs"
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
>
{/* Required color */}
<span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
<Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
</span>
{/* Required type + grams */}
<span className="text-white truncate">
{item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
</span>
{/* Arrow */}
<span className="text-bambu-gray">→</span>
{/* Slot selector dropdown */}
<select
value={item.loaded?.globalTrayId ?? ''}
onChange={(e) => handleSlotChange(item.slot_id || 0, e.target.value)}
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
item.status === 'match'
? 'border-bambu-green/50 text-bambu-green'
: item.status === 'type_only'
? 'border-yellow-400/50 text-yellow-400'
: 'border-orange-400/50 text-orange-400'
} ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
title={item.isManual ? 'Manually selected' : 'Auto-matched'}
>
<option value="" className="bg-bambu-dark text-bambu-gray">
-- Select slot --
</option>
{loadedFilaments.map((f) => (
<option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
{f.label}: {f.type} ({f.colorName})
</option>
))}
</select>
{/* Status icon */}
{item.status === 'match' ? (
<Check className="w-3 h-3 text-bambu-green" />
) : item.status === 'type_only' ? (
<span title="Same type, different color">
<AlertTriangle className="w-3 h-3 text-yellow-400" />
</span>
) : (
<span title="Filament type not loaded">
<AlertTriangle className="w-3 h-3 text-orange-400" />
</span>
)}
</div>
))}
{hasTypeMismatch && (
<p className="text-xs text-orange-400 mt-2">Required filament type not found in printer.</p>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,75 @@
import { Layers, Check, AlertTriangle } from 'lucide-react';
import { formatTime } from '../../utils/amsHelpers';
import type { PlateSelectorProps } from './types';
/**
* Plate selection grid for multi-plate 3MF files.
* Shows thumbnails, names, objects, and print times for each plate.
*/
export function PlateSelector({
plates,
isMultiPlate,
selectedPlate,
onSelect,
}: PlateSelectorProps) {
// Only show for multi-plate files with multiple plates
if (!isMultiPlate || plates.length <= 1) {
return null;
}
return (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<Layers className="w-4 h-4 text-bambu-gray" />
<span className="text-sm text-bambu-gray">Select Plate to Print</span>
{!selectedPlate && (
<span className="text-xs text-orange-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Selection required
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{plates.map((plate) => (
<button
key={plate.index}
type="button"
onClick={() => onSelect(plate.index)}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
selectedPlate === plate.index
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
}`}
>
{plate.has_thumbnail && plate.thumbnail_url != null ? (
<img
src={plate.thumbnail_url}
alt={`Plate ${plate.index}`}
className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
/>
) : (
<div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
<Layers className="w-5 h-5 text-bambu-gray" />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm text-white font-medium truncate">
{plate.name || `Plate ${plate.index}`}
</p>
<p className="text-xs text-bambu-gray truncate">
{plate.objects.length > 0
? plate.objects.slice(0, 3).join(', ') +
(plate.objects.length > 3 ? '...' : '')
: `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
{plate.print_time_seconds != null ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
</p>
</div>
{selectedPlate === plate.index && (
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,69 @@
import { useState } from 'react';
import { Settings, ChevronDown, ChevronUp } from 'lucide-react';
import type { PrintOptionsProps, PrintOptions as PrintOptionsType } from './types';
const PRINT_OPTIONS_CONFIG = [
{ key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
{ key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
{ key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
{ key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
{ key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
] as const;
/**
* Print options toggle panel with collapsible UI.
* Shows bed levelling, flow/vibration calibration, layer inspection, and timelapse options.
*/
export function PrintOptionsPanel({
options,
onChange,
defaultExpanded = false,
}: PrintOptionsProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const handleToggle = (key: keyof PrintOptionsType) => {
onChange({ ...options, [key]: !options[key] });
};
return (
<div className="mb-4">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
>
<Settings className="w-4 h-4" />
<span>Print Options</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4 ml-auto" />
) : (
<ChevronDown className="w-4 h-4 ml-auto" />
)}
</button>
{isExpanded && (
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
{PRINT_OPTIONS_CONFIG.map(({ key, label, desc }) => (
<label key={key} className="flex items-center justify-between cursor-pointer group">
<div>
<span className="text-sm text-white">{label}</span>
<p className="text-xs text-bambu-gray">{desc}</p>
</div>
<div
className={`relative w-10 h-5 rounded-full transition-colors ${
options[key] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
onClick={() => handleToggle(key)}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
options[key] ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</div>
</label>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,442 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Printer as PrinterIcon,
Loader2,
AlertCircle,
AlertTriangle,
Check,
Circle,
RefreshCw,
Wand2,
} from 'lucide-react';
import { api } from '../../api/client';
import { getColorName } from '../../utils/colors';
import {
normalizeColorForCompare,
colorsAreSimilar,
} from '../../utils/amsHelpers';
import type { PrinterSelectorProps } from './types';
import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
/** Per-printer mapping results (only used when multiple printers selected) */
printerMappingResults?: PrinterMappingResult[];
/** Filament requirements for the print */
filamentReqs?: { filaments: FilamentRequirement[] };
/** Callback to auto-configure a printer */
onAutoConfigurePrinter?: (printerId: number) => void;
/** Callback to update printer config */
onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
}
/**
* Inline AMS mapping editor for a single printer.
*/
function InlineMappingEditor({
printerResult,
filamentReqs,
onUpdateConfig,
}: {
printerResult: PrinterMappingResult;
filamentReqs: FilamentRequirement[];
onUpdateConfig: (config: Partial<PerPrinterConfig>) => void;
}) {
const queryClient = useQueryClient();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleSlotChange = (slotId: number, value: string) => {
if (slotId <= 0) return;
const newMappings = { ...printerResult.config.manualMappings };
if (value === '') {
delete newMappings[slotId];
} else {
newMappings[slotId] = parseInt(value, 10);
}
onUpdateConfig({
useDefault: false,
manualMappings: newMappings,
autoConfigured: false,
});
};
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await api.refreshPrinterStatus(printerResult.printerId);
await new Promise((r) => setTimeout(r, 500));
await queryClient.refetchQueries({ queryKey: ['printer-status', printerResult.printerId] });
} finally {
setIsRefreshing(false);
}
};
// Compute current slot assignments
const slotAssignments = filamentReqs.map((req) => {
const slotId = req.slot_id || 0;
const currentMapping = printerResult.config.manualMappings[slotId];
let loaded: LoadedFilament | undefined;
let isManual = false;
if (currentMapping !== undefined) {
loaded = printerResult.loadedFilaments.find((f) => f.globalTrayId === currentMapping);
isManual = true;
} else {
// Auto-match logic
const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
const exactMatch = printerResult.loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = exactMatch
? undefined
: printerResult.loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
exactMatch || similarMatch
? undefined
: printerResult.loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
}
// Determine status
let status: 'match' | 'type_only' | 'mismatch' = 'mismatch';
if (loaded) {
const typeMatch = loaded.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch =
normalizeColorForCompare(loaded.color) === normalizeColorForCompare(req.color) ||
colorsAreSimilar(loaded.color, req.color);
if (typeMatch && colorMatch) {
status = 'match';
} else if (typeMatch) {
status = 'type_only';
}
}
return { req, loaded, status, isManual };
});
return (
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-bambu-gray">Custom slot mapping</span>
<button
type="button"
onClick={handleRefresh}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
disabled={isRefreshing}
>
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>Re-read</span>
</button>
</div>
{slotAssignments.map(({ req, loaded, status, isManual }, idx) => (
<div
key={idx}
className="grid items-center gap-2 text-xs"
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
>
<span title={`Required: ${req.type} - ${getColorName(req.color)}`}>
<Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
</span>
<span className="text-white truncate">
{req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
</span>
<span className="text-bambu-gray">→</span>
<select
value={loaded?.globalTrayId ?? ''}
onChange={(e) => handleSlotChange(req.slot_id || 0, e.target.value)}
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
status === 'match'
? 'border-bambu-green/50 text-bambu-green'
: status === 'type_only'
? 'border-yellow-400/50 text-yellow-400'
: 'border-orange-400/50 text-orange-400'
} ${isManual ? 'ring-1 ring-blue-400/50' : ''}`}
title={isManual ? 'Manually selected' : 'Auto-matched'}
>
<option value="" className="bg-bambu-dark text-bambu-gray">
-- Select slot --
</option>
{printerResult.loadedFilaments.map((f) => (
<option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
{f.label}: {f.type} ({f.colorName})
</option>
))}
</select>
{status === 'match' ? (
<Check className="w-3 h-3 text-bambu-green" />
) : status === 'type_only' ? (
<span title="Same type, different color">
<AlertTriangle className="w-3 h-3 text-yellow-400" />
</span>
) : (
<span title="Filament type not loaded">
<AlertTriangle className="w-3 h-3 text-orange-400" />
</span>
)}
</div>
))}
</div>
);
}
/**
* Printer selection component with grid-based UI.
* Supports single or multi-select modes.
* When multiple printers are selected, shows per-printer mapping overrides.
*/
export function PrinterSelector({
printers,
selectedPrinterIds,
onMultiSelect,
isLoading = false,
allowMultiple = false,
showInactive = false,
printerMappingResults,
filamentReqs,
onAutoConfigurePrinter,
onUpdatePrinterConfig,
}: PrinterSelectorWithMappingProps) {
// Filter printers based on showInactive flag
const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
const showMappingOptions = allowMultiple &&
selectedPrinterIds.length > 1 &&
printerMappingResults &&
filamentReqs?.filaments &&
filamentReqs.filaments.length > 0 &&
onAutoConfigurePrinter &&
onUpdatePrinterConfig;
if (isLoading) {
return (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
);
}
if (displayPrinters.length === 0) {
return (
<div className="flex items-center gap-2 text-red-400 text-sm mb-4">
<AlertCircle className="w-4 h-4" />
No {showInactive ? '' : 'active '}printers available
</div>
);
}
const handlePrinterClick = (printerId: number) => {
if (allowMultiple) {
if (selectedPrinterIds.includes(printerId)) {
onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
} else {
onMultiSelect([...selectedPrinterIds, printerId]);
}
} else {
onMultiSelect([printerId]);
}
};
const handleSelectAll = () => {
onMultiSelect(displayPrinters.map((p) => p.id));
};
const handleDeselectAll = () => {
onMultiSelect([]);
};
const handleOverrideToggle = (printerId: number, enabled: boolean, e: React.MouseEvent) => {
e.stopPropagation();
if (!onAutoConfigurePrinter || !onUpdatePrinterConfig) return;
if (enabled) {
onAutoConfigurePrinter(printerId);
} else {
onUpdatePrinterConfig(printerId, {
useDefault: true,
manualMappings: {},
autoConfigured: false,
});
}
};
const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
const selectedCount = selectedPrinterIds.length;
const getPrinterMappingResult = (printerId: number) => {
return printerMappingResults?.find((r) => r.printerId === printerId);
};
return (
<div className="space-y-2 mb-6">
{/* Multi-select header */}
{allowMultiple && displayPrinters.length > 1 && (
<div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
<span>
{selectedCount === 0
? 'Select printers'
: `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
</span>
<div className="flex gap-2">
{selectedCount < displayPrinters.length && (
<button
type="button"
onClick={handleSelectAll}
className="text-bambu-green hover:text-bambu-green/80 transition-colors"
>
Select all
</button>
)}
{selectedCount > 0 && (
<button
type="button"
onClick={handleDeselectAll}
className="text-bambu-gray hover:text-white transition-colors"
>
Clear
</button>
)}
</div>
</div>
)}
{displayPrinters.map((printer) => {
const selected = isSelected(printer.id);
const mappingResult = getPrinterMappingResult(printer.id);
const hasOverride = mappingResult && !mappingResult.config.useDefault;
return (
<div key={printer.id}>
{/* Printer selection button */}
<button
type="button"
onClick={() => handlePrinterClick(printer.id)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
selected
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
} ${!printer.is_active ? 'opacity-60' : ''}`}
>
<div
className={`p-2 rounded-lg ${
selected ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
}`}
>
<PrinterIcon
className={`w-5 h-5 ${
selected ? 'text-bambu-green' : 'text-bambu-gray'
}`}
/>
</div>
<div className="text-left flex-1">
<p className="text-white font-medium">
{printer.name}
{!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
</p>
<p className="text-xs text-bambu-gray">
{printer.model || 'Unknown model'} • {printer.ip_address}
</p>
</div>
{allowMultiple && (
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
selected
? 'bg-bambu-green border-bambu-green'
: 'border-bambu-gray/50'
}`}
>
{selected && <Check className="w-3 h-3 text-white" />}
</div>
)}
</button>
{/* Per-printer override checkbox + mapping (only when selected and multi-printer) */}
{selected && showMappingOptions && mappingResult && (
<div className="ml-4 mt-2 mb-3">
{/* Override checkbox row */}
<div className="flex items-center gap-2">
<label
className="flex items-center gap-2 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={hasOverride}
onChange={(e) => handleOverrideToggle(printer.id, e.target.checked, e as unknown as React.MouseEvent)}
className="w-3.5 h-3.5 rounded border-bambu-gray/30 bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
/>
<span className="text-xs text-bambu-gray">Custom mapping</span>
</label>
{/* Match status indicator */}
<span className={`text-xs ml-2 ${
mappingResult.matchStatus === 'full'
? 'text-bambu-green'
: mappingResult.matchStatus === 'partial'
? 'text-yellow-400'
: 'text-orange-400'
}`}>
({mappingResult.exactMatches}/{mappingResult.totalSlots} matched)
</span>
{/* Loading indicator */}
{mappingResult.isLoading && (
<RefreshCw className="w-3 h-3 text-bambu-gray animate-spin" />
)}
{/* Auto-configure button (when override is enabled) */}
{hasOverride && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAutoConfigurePrinter!(printer.id);
}}
className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
>
<Wand2 className="w-3 h-3" />
Auto
</button>
)}
</div>
{/* Inline mapping editor (shown when override is checked) */}
{hasOverride && (
<InlineMappingEditor
printerResult={mappingResult}
filamentReqs={filamentReqs!.filaments}
onUpdateConfig={(config) => onUpdatePrinterConfig!(printer.id, config)}
/>
)}
</div>
)}
</div>
);
})}
{/* Warning when no printer selected */}
{selectedCount === 0 && (
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Select at least one printer
</p>
)}
</div>
);
}
@@ -0,0 +1,114 @@
import { Calendar, Clock, Hand, Power } from 'lucide-react';
import { getMinDateTime } from '../../utils/amsHelpers';
import type { ScheduleOptionsProps, ScheduleType } from './types';
/**
* Schedule options component for queue items.
* Includes schedule type (ASAP/Scheduled/Queue Only), datetime picker,
* and options for require previous success and auto power off.
*/
export function ScheduleOptionsPanel({ options, onChange }: ScheduleOptionsProps) {
const handleScheduleTypeChange = (scheduleType: ScheduleType) => {
onChange({ ...options, scheduleType });
};
return (
<div className="space-y-4">
{/* Schedule type */}
<div>
<label className="block text-sm text-bambu-gray mb-2">When to print</label>
<div className="flex gap-2">
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
options.scheduleType === 'asap'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => handleScheduleTypeChange('asap')}
>
<Clock className="w-4 h-4" />
ASAP
</button>
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
options.scheduleType === 'scheduled'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => handleScheduleTypeChange('scheduled')}
>
<Calendar className="w-4 h-4" />
Scheduled
</button>
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
options.scheduleType === 'manual'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => handleScheduleTypeChange('manual')}
>
<Hand className="w-4 h-4" />
Queue Only
</button>
</div>
</div>
{/* Scheduled time input */}
{options.scheduleType === 'scheduled' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
<input
type="datetime-local"
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"
value={options.scheduledTime}
onChange={(e) => onChange({ ...options, scheduledTime: e.target.value })}
min={getMinDateTime()}
required
/>
</div>
)}
{/* Require previous success */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="requirePrevious"
checked={options.requirePreviousSuccess}
onChange={(e) => onChange({ ...options, requirePreviousSuccess: e.target.checked })}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
Only start if previous print succeeded
</label>
</div>
{/* Auto power off */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoOffAfter"
checked={options.autoOffAfter}
onChange={(e) => onChange({ ...options, autoOffAfter: e.target.checked })}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
<Power className="w-3.5 h-3.5" />
Power off printer when done
</label>
</div>
{/* Help text */}
<p className="text-xs text-bambu-gray">
{options.scheduleType === 'asap'
? 'Print will start as soon as the printer is idle.'
: options.scheduleType === 'scheduled'
? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
: "Print will be staged but won't start automatically. Use the Start button to release it to the queue."}
</p>
</div>
);
}
@@ -0,0 +1,615 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle } from 'lucide-react';
import { api } from '../../api/client';
import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
import { Card, CardContent } from '../Card';
import { Button } from '../Button';
import { useToast } from '../../contexts/ToastContext';
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
import { isPlaceholderDate } from '../../utils/amsHelpers';
import { PrinterSelector } from './PrinterSelector';
import { PlateSelector } from './PlateSelector';
import { FilamentMapping } from './FilamentMapping';
import { PrintOptionsPanel } from './PrintOptions';
import { ScheduleOptionsPanel } from './ScheduleOptions';
import type {
PrintModalProps,
PrintOptions,
ScheduleOptions,
ScheduleType,
} from './types';
import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
/**
* Unified PrintModal component that handles three modes:
* - 'reprint': Immediate print from archive or library file (supports multi-printer)
* - 'add-to-queue': Schedule print to queue from archive or library file (supports multi-printer)
* - 'edit-queue-item': Edit existing queue item (supports multi-printer)
*
* Both archiveId and libraryFileId are supported. Library files can be printed immediately
* or added to queue (archive is created at print start time, not when queued).
*/
export function PrintModal({
mode,
archiveId,
libraryFileId,
archiveName,
queueItem,
onClose,
onSuccess,
}: PrintModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
// Determine if we're printing a library file
const isLibraryFile = !!libraryFileId && !archiveId;
// Multiple printer selection (used for all modes now)
const [selectedPrinters, setSelectedPrinters] = useState<number[]>(() => {
// Initialize with the queue item's printer if editing
if (mode === 'edit-queue-item' && queueItem?.printer_id) {
return [queueItem.printer_id];
}
return [];
});
const [selectedPlate, setSelectedPlate] = useState<number | null>(() => {
if (mode === 'edit-queue-item' && queueItem) {
return queueItem.plate_id;
}
return null;
});
const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
if (mode === 'edit-queue-item' && queueItem) {
return {
bed_levelling: queueItem.bed_levelling ?? DEFAULT_PRINT_OPTIONS.bed_levelling,
flow_cali: queueItem.flow_cali ?? DEFAULT_PRINT_OPTIONS.flow_cali,
vibration_cali: queueItem.vibration_cali ?? DEFAULT_PRINT_OPTIONS.vibration_cali,
layer_inspect: queueItem.layer_inspect ?? DEFAULT_PRINT_OPTIONS.layer_inspect,
timelapse: queueItem.timelapse ?? DEFAULT_PRINT_OPTIONS.timelapse,
};
}
return DEFAULT_PRINT_OPTIONS;
});
const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
if (mode === 'edit-queue-item' && queueItem) {
let scheduleType: ScheduleType = 'asap';
if (queueItem.manual_start) {
scheduleType = 'manual';
} else if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
scheduleType = 'scheduled';
}
let scheduledTime = '';
if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
const date = new Date(queueItem.scheduled_time);
scheduledTime = date.toISOString().slice(0, 16);
}
return {
scheduleType,
scheduledTime,
requirePreviousSuccess: queueItem.require_previous_success,
autoOffAfter: queueItem.auto_off_after,
};
}
return DEFAULT_SCHEDULE_OPTIONS;
});
// Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers)
const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
if (mode === 'edit-queue-item' && queueItem?.ams_mapping && Array.isArray(queueItem.ams_mapping)) {
const mappings: Record<number, number> = {};
queueItem.ams_mapping.forEach((globalTrayId, idx) => {
if (globalTrayId !== -1) {
mappings[idx + 1] = globalTrayId;
}
});
return mappings;
}
return {};
});
// Per-printer override configs (for multi-printer selection)
const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
// Track initial values for clearing mappings on change (edit mode only)
const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
// Submission state for multi-printer
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });
// Track which printers have had the "Expand custom mapping by default" setting applied
// This ensures the setting only affects initial state, not preventing unchecking
const [initialExpandApplied, setInitialExpandApplied] = useState<Set<number>>(new Set());
// Printer counts and effective printer for filament mapping
const effectivePrinterCount = selectedPrinters.length;
// For filament mapping, use first selected printer (mapping applies to all)
const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;
// Queries
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const { data: printers, isLoading: loadingPrinters } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Fetch plates for archives
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
queryKey: ['archive-plates', archiveId],
queryFn: () => api.getArchivePlates(archiveId!),
enabled: !!archiveId && !isLibraryFile,
retry: false,
});
// Fetch plates for library files
const { data: libraryPlatesData } = useQuery({
queryKey: ['library-file-plates', libraryFileId],
queryFn: () => api.getLibraryFilePlates(libraryFileId!),
enabled: isLibraryFile && !!libraryFileId,
});
// Combine plates data from either source
const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
// Fetch filament requirements for archives
const { data: archiveFilamentReqs, isError: archiveFilamentReqsError } = useQuery({
queryKey: ['archive-filaments', archiveId, selectedPlate],
queryFn: () => api.getArchiveFilamentRequirements(archiveId!, selectedPlate ?? undefined),
enabled: !!archiveId && !isLibraryFile && (selectedPlate !== null || !platesData?.is_multi_plate),
retry: false,
});
// Fetch filament requirements for library files (with plate support)
const { data: libraryFilamentReqs } = useQuery({
queryKey: ['library-file-filaments', libraryFileId, selectedPlate],
queryFn: () => api.getLibraryFileFilamentRequirements(libraryFileId!, selectedPlate ?? undefined),
enabled: isLibraryFile && !!libraryFileId && (selectedPlate !== null || !platesData?.is_multi_plate),
});
// Track if archive data couldn't be loaded (archive deleted or file missing)
const archiveDataMissing = !isLibraryFile && (archivePlatesError || archiveFilamentReqsError);
// Combine filament requirements from either source
const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
// Only fetch printer status when single printer selected (for filament mapping)
const { data: printerStatus } = useQuery({
queryKey: ['printer-status', effectivePrinterId],
queryFn: () => api.getPrinterStatus(effectivePrinterId!),
enabled: !!effectivePrinterId,
});
// Get AMS mapping from hook (only when single printer selected)
const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings);
// Multi-printer filament mapping (for per-printer configuration)
const multiPrinterMapping = useMultiPrinterFilamentMapping(
selectedPrinters,
printers,
effectiveFilamentReqs,
manualMappings,
perPrinterConfigs,
setPerPrinterConfigs
);
// Auto-select first plate for single-plate files
useEffect(() => {
if (platesData?.plates?.length === 1 && !selectedPlate) {
setSelectedPlate(platesData.plates[0].index);
}
}, [platesData, selectedPlate]);
// Auto-select first printer when only one available
useEffect(() => {
// Skip auto-select for edit mode (already initialized from queueItem)
if (mode === 'edit-queue-item') return;
const activePrinters = printers?.filter(p => p.is_active) || [];
if (activePrinters.length === 1 && selectedPrinters.length === 0) {
setSelectedPrinters([activePrinters[0].id]);
}
}, [mode, printers, selectedPrinters.length]);
// Clear manual mappings and per-printer configs when printer or plate changes
useEffect(() => {
if (mode === 'edit-queue-item') {
// For edit mode, clear mappings if printer selection or plate changed from initial
const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());
if (printersChanged || selectedPlate !== initialPlateId) {
setManualMappings({});
setPerPrinterConfigs({});
setInitialExpandApplied(new Set());
}
} else {
setManualMappings({});
setPerPrinterConfigs({});
setInitialExpandApplied(new Set());
}
}, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
// Auto-expand per-printer mapping when setting is enabled and multiple printers selected
// Only applies once per printer on initial selection, not when user unchecks
useEffect(() => {
if (!settings?.per_printer_mapping_expanded) return;
if (selectedPrinters.length <= 1) return;
// Only auto-configure printers that:
// 1. Haven't had initial expand applied yet
// 2. Have their status loaded (so auto-configure will actually work)
const printersReadyForExpand = selectedPrinters.filter(printerId => {
if (initialExpandApplied.has(printerId)) return false;
// Check if this printer has status loaded
const result = multiPrinterMapping.printerResults.find(r => r.printerId === printerId);
return result && result.status && !result.isLoading;
});
if (printersReadyForExpand.length > 0) {
// Mark these printers as having been initially expanded
setInitialExpandApplied(prev => {
const next = new Set(prev);
printersReadyForExpand.forEach(id => next.add(id));
return next;
});
// Auto-configure printers
printersReadyForExpand.forEach(printerId => {
multiPrinterMapping.autoConfigurePrinter(printerId);
});
}
}, [settings?.per_printer_mapping_expanded, selectedPrinters, initialExpandApplied, multiPrinterMapping]);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isSubmitting) onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, isSubmitting]);
const isMultiPlate = platesData?.is_multi_plate ?? false;
const plates = platesData?.plates ?? [];
// Add to queue mutation (single printer)
const addToQueueMutation = useMutation({
mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
});
// Update queue item mutation
const updateQueueMutation = useMutation({
mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(queueItem!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Queue item updated');
onSuccess?.();
onClose();
},
onError: (error: Error) => {
showToast(error.message || 'Failed to update queue item', 'error');
},
});
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
// Validate printer selection
if (selectedPrinters.length === 0) {
showToast('Please select at least one printer', 'error');
return;
}
setIsSubmitting(true);
setSubmitProgress({ current: 0, total: selectedPrinters.length });
const results: { success: number; failed: number; errors: string[] } = {
success: 0,
failed: 0,
errors: [],
};
// Get mapping for a specific printer (per-printer override or default)
const getMappingForPrinter = (printerId: number): number[] | undefined => {
// For multi-printer selection, check if this printer has an override
if (selectedPrinters.length > 1) {
const printerConfig = perPrinterConfigs[printerId];
if (printerConfig && !printerConfig.useDefault) {
return multiPrinterMapping.getFinalMapping(printerId);
}
}
return amsMapping;
};
// Common queue data for add-to-queue and edit modes
const getQueueData = (printerId: number): PrintQueueItemCreate => ({
printer_id: printerId,
// Use library_file_id for library files, archive_id for archives
archive_id: isLibraryFile ? undefined : archiveId,
library_file_id: isLibraryFile ? libraryFileId : undefined,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: getMappingForPrinter(printerId),
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
: undefined,
...printOptions,
});
for (let i = 0; i < selectedPrinters.length; i++) {
const printerId = selectedPrinters[i];
setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
try {
if (mode === 'reprint') {
// Reprint mode - start print immediately
const printerMapping = getMappingForPrinter(printerId);
if (isLibraryFile) {
await api.printLibraryFile(libraryFileId!, printerId, {
ams_mapping: printerMapping,
...printOptions,
});
} else {
await api.reprintArchive(archiveId!, printerId, {
plate_id: selectedPlate ?? undefined,
ams_mapping: printerMapping,
...printOptions,
});
}
} else if (mode === 'edit-queue-item' && i === 0) {
// Edit mode - update the original queue item for the first printer
const printerMapping = getMappingForPrinter(printerId);
const updateData: PrintQueueItemUpdate = {
printer_id: printerId,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: printerMapping,
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
: null,
...printOptions,
};
await updateQueueMutation.mutateAsync(updateData);
} else {
// Add-to-queue mode OR edit mode with additional printers
await addToQueueMutation.mutateAsync(getQueueData(printerId));
}
results.success++;
} catch (error) {
results.failed++;
const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
results.errors.push(`${printerName}: ${(error as Error).message}`);
}
}
setIsSubmitting(false);
// Show result toast
if (results.failed === 0) {
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
if (results.success === 1) {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
} else {
showToast(`Print ${action} ${results.success} printers`);
}
queryClient.invalidateQueries({ queryKey: ['queue'] });
onSuccess?.();
onClose();
} else if (results.success === 0) {
showToast(`Failed: ${results.errors[0]}`, 'error');
} else {
showToast(`${results.success} succeeded, ${results.failed} failed`, 'error');
queryClient.invalidateQueries({ queryKey: ['queue'] });
}
};
const isPending = isSubmitting || updateQueueMutation.isPending;
const canSubmit = useMemo(() => {
if (isPending) return false;
// Need at least one selected printer
if (selectedPrinters.length === 0) return false;
// For multi-plate archive files, need a selected plate (library files skip this)
if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
return true;
}, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
// Modal title and action button text based on mode
const getModalConfig = () => {
const printerCount = selectedPrinters.length;
if (mode === 'reprint') {
return {
title: isLibraryFile ? 'Print' : 'Re-print',
icon: Printer,
submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
submitIcon: Printer,
loadingText: submitProgress.total > 1
? `Sending ${submitProgress.current}/${submitProgress.total}...`
: 'Sending...',
};
}
if (mode === 'add-to-queue') {
return {
title: 'Schedule Print',
icon: Calendar,
submitText: printerCount > 1 ? `Queue to ${printerCount} Printers` : 'Add to Queue',
submitIcon: Calendar,
loadingText: submitProgress.total > 1
? `Adding ${submitProgress.current}/${submitProgress.total}...`
: 'Adding...',
};
}
// edit-queue-item mode
return {
title: 'Edit Queue Item',
icon: Pencil,
submitText: 'Save',
submitIcon: Pencil,
loadingText: submitProgress.total > 1
? `Saving ${submitProgress.current}/${submitProgress.total}...`
: 'Saving...',
};
};
const modalConfig = getModalConfig();
const TitleIcon = modalConfig.icon;
const SubmitIcon = modalConfig.submitIcon;
// Show filament mapping when:
// - Single printer selected
// - For archives: plate is selected (for multi-plate) or not required (single-plate)
// - For library files: always show (no plate selection)
const showFilamentMapping = effectivePrinterId && (
isLibraryFile || (isMultiPlate ? selectedPlate !== null : true)
);
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={isSubmitting ? undefined : onClose}
>
<Card
className="w-full max-w-lg max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<CardContent className={mode === 'reprint' ? '' : 'p-0'}>
{/* Header */}
<div
className={`flex items-center justify-between ${
mode === 'reprint' ? 'mb-4' : 'p-4 border-b border-bambu-dark-tertiary'
}`}
>
<div className="flex items-center gap-2">
<TitleIcon className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{modalConfig.title}</h2>
</div>
<Button variant="ghost" size="sm" onClick={onClose} disabled={isSubmitting}>
<X className="w-5 h-5" />
</Button>
</div>
<form onSubmit={handleSubmit} className={mode === 'reprint' ? '' : 'p-4 space-y-4'}>
{/* Archive name */}
<p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
{mode === 'reprint' ? (
<>
Send <span className="text-white">{archiveName}</span> to printer(s)
</>
) : (
<>
<span className="block text-bambu-gray mb-1">Print Job</span>
<span className="text-white font-medium truncate block">{archiveName}</span>
</>
)}
</p>
{/* Plate selection - first so users know filament requirements before selecting printers */}
<PlateSelector
plates={plates}
isMultiPlate={isMultiPlate}
selectedPlate={selectedPlate}
onSelect={setSelectedPlate}
/>
{/* Printer selection with per-printer mapping */}
<PrinterSelector
printers={printers || []}
selectedPrinterIds={selectedPrinters}
onMultiSelect={setSelectedPrinters}
isLoading={loadingPrinters}
allowMultiple={true}
showInactive={mode === 'edit-queue-item'}
printerMappingResults={multiPrinterMapping.printerResults}
filamentReqs={effectiveFilamentReqs}
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
/>
{/* Warning when archive data couldn't be loaded */}
{archiveDataMissing && (
<div className="flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm">
<AlertCircle className="w-4 h-4 text-orange-400 mt-0.5 flex-shrink-0" />
<p className="text-orange-400">
Archive data unavailable. The source file may have been deleted. Filament mapping is disabled.
</p>
</div>
)}
{/* Filament mapping - only show when single printer selected */}
{showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (
<FilamentMapping
printerId={effectivePrinterId!}
filamentReqs={effectiveFilamentReqs}
manualMappings={manualMappings}
onManualMappingChange={setManualMappings}
defaultExpanded={settings?.per_printer_mapping_expanded ?? false}
/>
)}
{/* Print options */}
{(mode === 'reprint' || effectivePrinterCount > 0) && (
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
)}
{/* Schedule options - only for queue modes */}
{mode !== 'reprint' && (
<ScheduleOptionsPanel options={scheduleOptions} onChange={setScheduleOptions} />
)}
{/* Error message */}
{updateQueueMutation.isError && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
{(updateQueueMutation.error as Error)?.message || 'Failed to complete operation'}
</div>
)}
{/* Actions */}
<div className={`flex gap-3 ${mode === 'reprint' ? '' : 'pt-2'}`}>
<Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
Cancel
</Button>
<Button
type="submit"
disabled={!canSubmit}
className="flex-1"
>
{isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{modalConfig.loadingText}
</>
) : (
<>
<SubmitIcon className="w-4 h-4" />
{modalConfig.submitText}
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
// Re-export types for convenience
export type { PrintModalProps, PrintModalMode } from './types';
+169
View File
@@ -0,0 +1,169 @@
import type { PrintQueueItem, Printer } from '../../api/client';
/**
* Mode of operation for the PrintModal.
* - 'reprint': Immediate print from archive (no schedule options)
* - 'add-to-queue': Schedule print to queue (includes schedule options)
* - 'edit-queue-item': Edit existing queue item (all options + existing values)
*/
export type PrintModalMode = 'reprint' | 'add-to-queue' | 'edit-queue-item';
/**
* Props for the unified PrintModal component.
*
* Either archiveId or libraryFileId must be provided.
* - archiveId: For reprinting/queueing archives
* - libraryFileId: For printing library files directly
*/
export interface PrintModalProps {
/** Modal operation mode */
mode: PrintModalMode;
/** Archive ID to print (mutually exclusive with libraryFileId) */
archiveId?: number;
/** Library file ID to print (mutually exclusive with archiveId) */
libraryFileId?: number;
/** Display name for the print */
archiveName: string;
/** Existing queue item (only for edit-queue-item mode) */
queueItem?: PrintQueueItem;
/** Handler for closing the modal */
onClose: () => void;
/** Handler for successful operation */
onSuccess?: () => void;
}
/**
* Print options that can be configured for a print job.
*/
export interface PrintOptions {
bed_levelling: boolean;
flow_cali: boolean;
vibration_cali: boolean;
layer_inspect: boolean;
timelapse: boolean;
}
/**
* Default print options values.
*/
export const DEFAULT_PRINT_OPTIONS: PrintOptions = {
bed_levelling: true,
flow_cali: false,
vibration_cali: true,
layer_inspect: false,
timelapse: false,
};
/**
* Schedule type for queue items.
*/
export type ScheduleType = 'asap' | 'scheduled' | 'manual';
/**
* Schedule options for queue items.
*/
export interface ScheduleOptions {
scheduleType: ScheduleType;
scheduledTime: string;
requirePreviousSuccess: boolean;
autoOffAfter: boolean;
}
/**
* Default schedule options values.
*/
export const DEFAULT_SCHEDULE_OPTIONS: ScheduleOptions = {
scheduleType: 'asap',
scheduledTime: '',
requirePreviousSuccess: false,
autoOffAfter: false,
};
/**
* Plate information from a multi-plate 3MF file.
*/
export interface PlateInfo {
index: number;
name: string | null;
has_thumbnail: boolean;
thumbnail_url: string | null;
objects: string[];
filaments: Array<{
type: string;
color: string;
}>;
print_time_seconds: number | null;
filament_used_grams: number | null;
}
/**
* Response from the archive plates API.
*/
export interface PlatesResponse {
is_multi_plate: boolean;
plates: PlateInfo[];
}
/**
* Props for the PrinterSelector component.
*/
export interface PrinterSelectorProps {
printers: Printer[];
selectedPrinterIds: number[];
onMultiSelect: (printerIds: number[]) => void;
isLoading?: boolean;
allowMultiple?: boolean;
/** Show inactive printers (for edit mode where original assignment may be inactive) */
showInactive?: boolean;
}
/**
* Props for the PlateSelector component.
*/
export interface PlateSelectorProps {
plates: PlateInfo[];
isMultiPlate: boolean;
selectedPlate: number | null;
onSelect: (plateIndex: number) => void;
}
/**
* Filament requirement data structure.
*/
export interface FilamentReqsData {
filaments: Array<{
slot_id: number;
type: string;
color: string;
used_grams: number;
used_meters: number;
}>;
}
/**
* Props for the FilamentMapping component.
*/
export interface FilamentMappingProps {
printerId: number;
/** Pre-fetched filament requirements data */
filamentReqs: FilamentReqsData | undefined;
manualMappings: Record<number, number>;
onManualMappingChange: (mappings: Record<number, number>) => void;
}
/**
* Props for the PrintOptions component.
*/
export interface PrintOptionsProps {
options: PrintOptions;
onChange: (options: PrintOptions) => void;
defaultExpanded?: boolean;
}
/**
* Props for the ScheduleOptions component.
*/
export interface ScheduleOptionsProps {
options: ScheduleOptions;
onChange: (options: ScheduleOptions) => void;
}
-664
View File
@@ -1,664 +0,0 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Printer, Loader2, AlertTriangle, Check, Circle, RefreshCw, ChevronDown, ChevronUp, Settings, Layers } from 'lucide-react';
import { api } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { getColorName } from '../utils/colors';
interface ReprintModalProps {
archiveId: number;
archiveName: string;
onClose: () => void;
onSuccess: () => void;
}
// Print options with defaults
interface PrintOptions {
timelapse: boolean;
bed_levelling: boolean;
flow_cali: boolean;
vibration_cali: boolean;
layer_inspect: boolean;
}
const DEFAULT_PRINT_OPTIONS: PrintOptions = {
bed_levelling: true,
flow_cali: false,
vibration_cali: true,
layer_inspect: false,
timelapse: false,
};
// Format seconds to human readable time
const formatTime = (seconds: number | null | undefined): string => {
if (!seconds) return '';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: ReprintModalProps) {
const queryClient = useQueryClient();
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showOptions, setShowOptions] = useState(false);
const [printOptions, setPrintOptions] = useState<PrintOptions>(DEFAULT_PRINT_OPTIONS);
// Manual slot overrides: slot_id (1-indexed) -> globalTrayId
const [manualMappings, setManualMappings] = useState<Record<number, number>>({});
// Clear manual mappings when printer or plate changes
useEffect(() => {
setManualMappings({});
}, [selectedPrinter, selectedPlate]);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const { data: printers, isLoading: loadingPrinters } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Fetch available plates from the archived 3MF
const { data: platesData } = useQuery({
queryKey: ['archive-plates', archiveId],
queryFn: () => api.getArchivePlates(archiveId),
});
// Auto-select the first plate for single-plate files, or require selection for multi-plate
useEffect(() => {
if (platesData?.plates?.length === 1) {
setSelectedPlate(platesData.plates[0].index);
}
}, [platesData]);
// Fetch filament requirements from the archived 3MF (filtered by plate if selected)
const { data: filamentReqs } = useQuery({
queryKey: ['archive-filaments', archiveId, selectedPlate],
queryFn: () => api.getArchiveFilamentRequirements(archiveId, selectedPlate ?? undefined),
enabled: selectedPlate !== null || !platesData?.is_multi_plate,
});
// Fetch printer status when a printer is selected
const { data: printerStatus } = useQuery({
queryKey: ['printer-status', selectedPrinter],
queryFn: () => api.getPrinterStatus(selectedPrinter!),
enabled: !!selectedPrinter,
});
const reprintMutation = useMutation({
mutationFn: () => {
if (!selectedPrinter) throw new Error('No printer selected');
return api.reprintArchive(archiveId, selectedPrinter, {
plate_id: selectedPlate ?? undefined,
ams_mapping: amsMapping,
...printOptions,
});
},
onSuccess: () => {
onSuccess();
onClose();
},
});
const activePrinters = printers?.filter((p) => p.is_active) || [];
const isMultiPlate = platesData?.is_multi_plate ?? false;
const plates = platesData?.plates ?? [];
// Helper to normalize color format (API returns "RRGGBBAA", 3MF uses "#RRGGBB")
const normalizeColor = (color: string | null | undefined): string => {
if (!color) return '#808080';
// Remove alpha channel if present (8-char hex to 6-char)
const hex = color.replace('#', '').substring(0, 6);
return `#${hex}`;
};
// Helper to format slot label for display
const formatSlotLabel = (amsId: number, trayId: number, isHt: boolean, isExternal: boolean): string => {
if (isExternal) return 'External';
const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId)); // A, B, C, D
if (isHt) return `HT-${letter}`;
return `AMS-${letter} Slot ${trayId + 1}`;
};
// Calculate global tray ID for MQTT command
// Regular AMS: (ams_id * 4) + slot_id, External: 254
const getGlobalTrayId = (amsId: number, trayId: number, isExternal: boolean): number => {
if (isExternal) return 254;
return amsId * 4 + trayId;
};
// Build a list of all loaded filaments from printer's AMS/HT/External with location info
const loadedFilaments = useMemo(() => {
const filaments: Array<{
type: string;
color: string;
colorName: string;
amsId: number;
trayId: number;
isHt: boolean;
isExternal: boolean;
label: string;
globalTrayId: number;
}> = [];
// Add filaments from all AMS units (regular and HT)
printerStatus?.ams?.forEach((amsUnit) => {
const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
amsUnit.tray.forEach((tray) => {
if (tray.tray_type) {
const color = normalizeColor(tray.tray_color);
filaments.push({
type: tray.tray_type,
color,
colorName: getColorName(color),
amsId: amsUnit.id,
trayId: tray.id,
isHt,
isExternal: false,
label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
});
}
});
});
// Add external spool if loaded
if (printerStatus?.vt_tray?.tray_type) {
const color = normalizeColor(printerStatus.vt_tray.tray_color);
filaments.push({
type: printerStatus.vt_tray.tray_type,
color,
colorName: getColorName(color),
amsId: -1,
trayId: 0,
isHt: false,
isExternal: true,
label: 'External',
globalTrayId: 254,
});
}
return filaments;
}, [printerStatus]);
// Compare required filaments with loaded filaments
// Match by filament TYPE (not slot), since the printer dynamically maps slots
// Respects manual overrides when set
const filamentComparison = useMemo(() => {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
// Helper to normalize color for comparison (case-insensitive, strip #)
const normalizeColorForCompare = (color: string | undefined): string => {
if (!color) return '';
return color.replace('#', '').toLowerCase().substring(0, 6); // Strip alpha
};
// Helper to check if two colors are similar (within threshold)
const colorsAreSimilar = (color1: string | undefined, color2: string | undefined, threshold = 40): boolean => {
const hex1 = normalizeColorForCompare(color1);
const hex2 = normalizeColorForCompare(color2);
if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
const r1 = parseInt(hex1.substring(0, 2), 16);
const g1 = parseInt(hex1.substring(2, 4), 16);
const b1 = parseInt(hex1.substring(4, 6), 16);
const r2 = parseInt(hex2.substring(0, 2), 16);
const g2 = parseInt(hex2.substring(2, 4), 16);
const b2 = parseInt(hex2.substring(4, 6), 16);
// Check if each RGB component is within threshold
return Math.abs(r1 - r2) <= threshold &&
Math.abs(g1 - g2) <= threshold &&
Math.abs(b1 - b2) <= threshold;
};
// Track which trays have been assigned to avoid duplicates
// First, mark all manually assigned trays as used
const usedTrayIds = new Set<number>(Object.values(manualMappings));
return filamentReqs.filaments.map((req) => {
const slotId = req.slot_id || 0;
// Check if there's a manual override for this slot
if (slotId > 0 && manualMappings[slotId] !== undefined) {
const manualTrayId = manualMappings[slotId];
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
if (manualLoaded) {
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch = normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
colorsAreSimilar(manualLoaded.color, req.color);
let status: 'match' | 'type_only' | 'mismatch' | 'empty';
if (typeMatch && colorMatch) {
status = 'match';
} else if (typeMatch) {
status = 'type_only';
} else {
status = 'mismatch';
}
return {
...req,
loaded: manualLoaded,
hasFilament: true,
typeMatch,
colorMatch,
status,
isManual: true,
};
}
}
// Auto-match: Find a loaded filament that matches by TYPE
// Priority: exact color match > similar color match > type-only match
// IMPORTANT: Exclude trays that are already assigned (manually or auto)
const exactMatch = loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = !exactMatch && loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch = !exactMatch && !similarMatch && loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
const hasFilament = !!loaded;
const typeMatch = hasFilament;
const colorMatch = !!exactMatch || !!similarMatch;
// Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
let status: 'match' | 'type_only' | 'mismatch' | 'empty';
if (exactMatch || similarMatch) {
status = 'match';
} else if (typeOnlyMatch) {
status = 'type_only';
} else {
status = 'mismatch';
}
return {
...req,
loaded,
hasFilament,
typeMatch,
colorMatch,
status,
isManual: false,
};
});
}, [filamentReqs, loadedFilaments, manualMappings]);
// Build AMS mapping from auto-matched filaments
// Format: array matching 3MF filament slot structure
// Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
// e.g., slots 1 and 3 used with trays 5 and 2 → [5, -1, 2, -1]
const amsMapping = useMemo(() => {
if (filamentComparison.length === 0) return undefined;
// Find the max slot_id to determine array size
const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
if (maxSlotId <= 0) return undefined;
// Create array with -1 for all positions
const mapping = new Array(maxSlotId).fill(-1);
// Fill in tray IDs at correct positions (slot_id - 1)
filamentComparison.forEach((f) => {
if (f.slot_id && f.slot_id > 0) {
mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
}
});
return mapping;
}, [filamentComparison]);
const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-8">
<Card className="w-full max-w-lg">
<CardContent>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Re-print</h2>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
<p className="text-sm text-bambu-gray mb-4">
Send <span className="text-white">{archiveName}</span> to a printer
</p>
{/* Printer selection */}
{loadingPrinters ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : activePrinters.length === 0 ? (
<div className="text-center py-8 text-bambu-gray">
No active printers available
</div>
) : (
<div className="space-y-2 mb-6">
{activePrinters.map((printer) => (
<button
key={printer.id}
onClick={() => setSelectedPrinter(printer.id)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
selectedPrinter === printer.id
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
}`}
>
<div
className={`p-2 rounded-lg ${
selectedPrinter === printer.id
? 'bg-bambu-green/20'
: 'bg-bambu-dark-tertiary'
}`}
>
<Printer
className={`w-5 h-5 ${
selectedPrinter === printer.id
? 'text-bambu-green'
: 'text-bambu-gray'
}`}
/>
</div>
<div className="text-left">
<p className="text-white font-medium">{printer.name}</p>
<p className="text-xs text-bambu-gray">
{printer.model || 'Unknown model'} • {printer.ip_address}
</p>
</div>
</button>
))}
</div>
)}
{/* Plate selection - show when multi-plate file detected */}
{isMultiPlate && plates.length > 1 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<Layers className="w-4 h-4 text-bambu-gray" />
<span className="text-sm text-bambu-gray">Select Plate to Print</span>
{!selectedPlate && (
<span className="text-xs text-orange-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Selection required
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{plates.map((plate) => (
<button
key={plate.index}
onClick={() => setSelectedPlate(plate.index)}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
selectedPlate === plate.index
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
}`}
>
{plate.has_thumbnail && plate.thumbnail_url ? (
<img
src={plate.thumbnail_url}
alt={`Plate ${plate.index}`}
className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
/>
) : (
<div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
<Layers className="w-5 h-5 text-bambu-gray" />
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-sm text-white font-medium truncate">
Plate {plate.index}
</p>
<p className="text-xs text-bambu-gray truncate">
{plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
{plate.print_time_seconds ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
</p>
</div>
{selectedPlate === plate.index && (
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</button>
))}
</div>
</div>
)}
{/* Filament comparison - show when printer selected and has filament requirements */}
{selectedPrinter && (isMultiPlate ? selectedPlate !== null : true) && filamentComparison.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm text-bambu-gray">Filament Check</span>
<button
onClick={async () => {
if (!selectedPrinter) return;
setIsRefreshing(true);
try {
// Request fresh data from printer via MQTT pushall command
await api.refreshPrinterStatus(selectedPrinter);
// Wait a moment for printer to respond, then refetch
await new Promise((r) => setTimeout(r, 500));
await queryClient.refetchQueries({ queryKey: ['printer-status', selectedPrinter] });
} finally {
setIsRefreshing(false);
}
}}
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
title="Re-read AMS status from printer"
disabled={isRefreshing}
>
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>Re-read</span>
</button>
{hasTypeMismatch ? (
<span className="text-xs text-orange-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Type not found
</span>
) : filamentComparison.some((f) => f.status === 'type_only') ? (
<span className="text-xs text-yellow-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Color mismatch
</span>
) : (
<span className="text-xs text-bambu-green flex items-center gap-1">
<Check className="w-3 h-3" />
Ready
</span>
)}
</div>
<div className="bg-bambu-dark rounded-lg p-3 space-y-2 text-xs">
{filamentComparison.map((item, idx) => (
<div
key={idx}
className="grid items-center gap-2"
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
>
{/* Required color */}
<span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
<Circle
className="w-3 h-3 flex-shrink-0"
fill={item.color}
stroke={item.color}
/>
</span>
{/* Required type + grams */}
<span className="text-white truncate">
{item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
</span>
{/* Arrow */}
<span className="text-bambu-gray">→</span>
{/* Slot selector dropdown */}
<select
value={item.loaded?.globalTrayId ?? ''}
onChange={(e) => {
const slotId = item.slot_id || 0;
if (slotId > 0) {
const value = e.target.value;
if (value === '') {
// Clear manual override
setManualMappings((prev) => {
const next = { ...prev };
delete next[slotId];
return next;
});
} else {
setManualMappings((prev) => ({
...prev,
[slotId]: parseInt(value, 10),
}));
}
}
}}
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
item.status === 'match'
? 'border-bambu-green/50 text-bambu-green'
: item.status === 'type_only'
? 'border-yellow-400/50 text-yellow-400'
: 'border-orange-400/50 text-orange-400'
} ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
title={item.isManual ? 'Manually selected' : 'Auto-matched'}
>
<option value="" className="bg-bambu-dark text-bambu-gray">
-- Select slot --
</option>
{loadedFilaments.map((f) => (
<option
key={f.globalTrayId}
value={f.globalTrayId}
className="bg-bambu-dark text-white"
>
{f.label}: {f.type} ({f.colorName})
</option>
))}
</select>
{/* Status icon */}
{item.status === 'match' ? (
<Check className="w-3 h-3 text-bambu-green" />
) : item.status === 'type_only' ? (
<span title="Same type, different color">
<AlertTriangle className="w-3 h-3 text-yellow-400" />
</span>
) : (
<span title="Filament type not loaded">
<AlertTriangle className="w-3 h-3 text-orange-400" />
</span>
)}
</div>
))}
</div>
{hasTypeMismatch && (
<p className="text-xs text-orange-400 mt-2">
Required filament type not found in printer.
</p>
)}
</div>
)}
{/* Print Options */}
{selectedPrinter && (
<div className="mb-4">
<button
onClick={() => setShowOptions(!showOptions)}
className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
>
<Settings className="w-4 h-4" />
<span>Print Options</span>
{showOptions ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
</button>
{showOptions && (
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
{[
{ key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' },
{ key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' },
{ key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' },
{ key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' },
{ key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' },
].map(({ key, label, desc }) => (
<label key={key} className="flex items-center justify-between cursor-pointer group">
<div>
<span className="text-sm text-white">{label}</span>
<p className="text-xs text-bambu-gray">{desc}</p>
</div>
<div
className={`relative w-10 h-5 rounded-full transition-colors ${
printOptions[key as keyof PrintOptions] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
onClick={() => setPrintOptions((prev) => ({ ...prev, [key]: !prev[key as keyof PrintOptions] }))}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
printOptions[key as keyof PrintOptions] ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</div>
</label>
))}
</div>
)}
</div>
)}
{/* Error message */}
{reprintMutation.isError && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
{(reprintMutation.error as Error).message || 'Failed to start print'}
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<Button variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
onClick={() => reprintMutation.mutate()}
disabled={!selectedPrinter || (isMultiPlate && !selectedPlate) || reprintMutation.isPending}
className="flex-1"
>
{reprintMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Sending...
</>
) : (
<>
<Printer className="w-4 h-4" />
Print
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+390
View File
@@ -0,0 +1,390 @@
import { useMemo } from 'react';
import { getColorName } from '../utils/colors';
import {
normalizeColor,
normalizeColorForCompare,
colorsAreSimilar,
formatSlotLabel,
getGlobalTrayId,
} from '../utils/amsHelpers';
import type { PrinterStatus } from '../api/client';
/**
* Build loaded filaments list from printer status (non-hook version).
* Extracts filaments from all AMS units (regular and HT) and external spool.
*/
export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] {
const filaments: LoadedFilament[] = [];
// Add filaments from all AMS units (regular and HT)
printerStatus?.ams?.forEach((amsUnit) => {
const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
amsUnit.tray.forEach((tray) => {
if (tray.tray_type) {
const color = normalizeColor(tray.tray_color);
filaments.push({
type: tray.tray_type,
color,
colorName: getColorName(color),
amsId: amsUnit.id,
trayId: tray.id,
isHt,
isExternal: false,
label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
});
}
});
});
// Add external spool if loaded
if (printerStatus?.vt_tray?.tray_type) {
const color = normalizeColor(printerStatus.vt_tray.tray_color);
filaments.push({
type: printerStatus.vt_tray.tray_type,
color,
colorName: getColorName(color),
amsId: -1,
trayId: 0,
isHt: false,
isExternal: true,
label: 'External',
globalTrayId: 254,
});
}
return filaments;
}
/**
* Compute AMS mapping for a printer given filament requirements and printer status.
* This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
*
* @param filamentReqs - Required filaments from the 3MF file
* @param printerStatus - Current printer status with AMS information
* @returns AMS mapping array or undefined if no mapping needed
*/
export function computeAmsMapping(
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
printerStatus: PrinterStatus | undefined
): number[] | undefined {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
const loadedFilaments = buildLoadedFilaments(printerStatus);
if (loadedFilaments.length === 0) return undefined;
// Track which trays have been assigned to avoid duplicates
const usedTrayIds = new Set<number>();
const comparisons = filamentReqs.filaments.map((req) => {
// Auto-match: Find a loaded filament that matches by TYPE
// Priority: exact color match > similar color match > type-only match
const exactMatch = loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch =
!exactMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
!exactMatch &&
!similarMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
return {
slot_id: req.slot_id,
globalTrayId: loaded?.globalTrayId ?? -1,
};
});
// Find the max slot_id to determine array size
const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
if (maxSlotId <= 0) return undefined;
// Create array with -1 for all positions
const mapping = new Array(maxSlotId).fill(-1);
// Fill in tray IDs at correct positions (slot_id - 1)
comparisons.forEach((f) => {
if (f.slot_id && f.slot_id > 0) {
mapping[f.slot_id - 1] = f.globalTrayId;
}
});
return mapping;
}
/**
* Represents a loaded filament in the printer's AMS/HT/External spool holder.
*/
export interface LoadedFilament {
type: string;
color: string;
colorName: string;
amsId: number;
trayId: number;
isHt: boolean;
isExternal: boolean;
label: string;
globalTrayId: number;
}
/**
* Represents a required filament from the 3MF file.
*/
export interface FilamentRequirement {
slot_id: number;
type: string;
color: string;
used_grams: number;
}
/**
* Status of filament comparison between required and loaded.
*/
export type FilamentStatus = 'match' | 'type_only' | 'mismatch' | 'empty';
/**
* Result of comparing a required filament with loaded filaments.
*/
export interface FilamentComparison extends FilamentRequirement {
loaded: LoadedFilament | undefined;
hasFilament: boolean;
typeMatch: boolean;
colorMatch: boolean;
status: FilamentStatus;
isManual: boolean;
}
interface FilamentRequirementsResponse {
filaments: FilamentRequirement[];
}
interface UseFilamentMappingResult {
/** List of all filaments loaded in the printer */
loadedFilaments: LoadedFilament[];
/** Comparison results for each required filament */
filamentComparison: FilamentComparison[];
/** AMS mapping array for the print command */
amsMapping: number[] | undefined;
/** Whether any required filament type is not loaded */
hasTypeMismatch: boolean;
/** Whether any required filament has a color mismatch */
hasColorMismatch: boolean;
}
/**
* Hook to build loaded filaments list from printer status.
* Extracts filaments from all AMS units (regular and HT) and external spool.
*/
export function useLoadedFilaments(
printerStatus: PrinterStatus | undefined
): LoadedFilament[] {
return useMemo(() => {
const filaments: LoadedFilament[] = [];
// Add filaments from all AMS units (regular and HT)
printerStatus?.ams?.forEach((amsUnit) => {
const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
amsUnit.tray.forEach((tray) => {
if (tray.tray_type) {
const color = normalizeColor(tray.tray_color);
filaments.push({
type: tray.tray_type,
color,
colorName: getColorName(color),
amsId: amsUnit.id,
trayId: tray.id,
isHt,
isExternal: false,
label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
});
}
});
});
// Add external spool if loaded
if (printerStatus?.vt_tray?.tray_type) {
const color = normalizeColor(printerStatus.vt_tray.tray_color);
filaments.push({
type: printerStatus.vt_tray.tray_type,
color,
colorName: getColorName(color),
amsId: -1,
trayId: 0,
isHt: false,
isExternal: true,
label: 'External',
globalTrayId: 254,
});
}
return filaments;
}, [printerStatus]);
}
/**
* Hook to compare required filaments with loaded filaments and build AMS mapping.
* Handles both auto-matching and manual overrides.
*
* @param filamentReqs - Required filaments from the 3MF file
* @param printerStatus - Current printer status with AMS information
* @param manualMappings - Manual slot overrides (slot_id -> globalTrayId)
*/
export function useFilamentMapping(
filamentReqs: FilamentRequirementsResponse | undefined,
printerStatus: PrinterStatus | undefined,
manualMappings: Record<number, number>
): UseFilamentMappingResult {
const loadedFilaments = useLoadedFilaments(printerStatus);
const filamentComparison = useMemo(() => {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
// Track which trays have been assigned to avoid duplicates
// First, mark all manually assigned trays as used
const usedTrayIds = new Set<number>(Object.values(manualMappings));
return filamentReqs.filaments.map((req) => {
const slotId = req.slot_id || 0;
// Check if there's a manual override for this slot
if (slotId > 0 && manualMappings[slotId] !== undefined) {
const manualTrayId = manualMappings[slotId];
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
if (manualLoaded) {
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch =
normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
colorsAreSimilar(manualLoaded.color, req.color);
let status: FilamentStatus;
if (typeMatch && colorMatch) {
status = 'match';
} else if (typeMatch) {
status = 'type_only';
} else {
status = 'mismatch';
}
return {
...req,
loaded: manualLoaded,
hasFilament: true,
typeMatch,
colorMatch,
status,
isManual: true,
};
}
}
// Auto-match: Find a loaded filament that matches by TYPE
// Priority: exact color match > similar color match > type-only match
// IMPORTANT: Exclude trays that are already assigned (manually or auto)
const exactMatch = loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch =
!exactMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
!exactMatch &&
!similarMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
const hasFilament = !!loaded;
const typeMatch = hasFilament;
const colorMatch = !!exactMatch || !!similarMatch;
// Status: match (type+color or similar), type_only (type ok, color very different), mismatch (type not found)
let status: FilamentStatus;
if (exactMatch || similarMatch) {
status = 'match';
} else if (typeOnlyMatch) {
status = 'type_only';
} else {
status = 'mismatch';
}
return {
...req,
loaded,
hasFilament,
typeMatch,
colorMatch,
status,
isManual: false,
};
});
}, [filamentReqs, loadedFilaments, manualMappings]);
// Build AMS mapping from matched filaments
// Format: array matching 3MF filament slot structure
// Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
const amsMapping = useMemo(() => {
if (filamentComparison.length === 0) return undefined;
// Find the max slot_id to determine array size
const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
if (maxSlotId <= 0) return undefined;
// Create array with -1 for all positions
const mapping = new Array(maxSlotId).fill(-1);
// Fill in tray IDs at correct positions (slot_id - 1)
filamentComparison.forEach((f) => {
if (f.slot_id && f.slot_id > 0) {
mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
}
});
return mapping;
}, [filamentComparison]);
const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
const hasColorMismatch = filamentComparison.some((f) => f.status === 'type_only');
return {
loadedFilaments,
filamentComparison,
amsMapping,
hasTypeMismatch,
hasColorMismatch,
};
}
@@ -0,0 +1,385 @@
import { useMemo } from 'react';
import { useQueries } from '@tanstack/react-query';
import { api } from '../api/client';
import type { PrinterStatus, Printer } from '../api/client';
import {
buildLoadedFilaments,
computeAmsMapping,
type LoadedFilament,
type FilamentRequirement,
} from './useFilamentMapping';
import {
normalizeColorForCompare,
colorsAreSimilar,
} from '../utils/amsHelpers';
/**
* Match status for a single printer's filament configuration.
*/
export type PrinterMatchStatus = 'full' | 'partial' | 'missing';
/**
* Per-printer configuration for AMS mapping.
*/
export interface PerPrinterConfig {
/** Whether this printer uses the default mapping or has custom config */
useDefault: boolean;
/** Manual slot overrides for this printer (slot_id -> globalTrayId) */
manualMappings: Record<number, number>;
/** Whether this mapping was auto-configured */
autoConfigured: boolean;
}
/**
* Result of filament mapping for a single printer.
*/
export interface PrinterMappingResult {
printerId: number;
printerName: string;
/** Printer status data */
status: PrinterStatus | undefined;
/** Whether status is still loading */
isLoading: boolean;
/** List of loaded filaments in this printer */
loadedFilaments: LoadedFilament[];
/** Auto-computed AMS mapping for this printer */
autoMapping: number[] | undefined;
/** Final AMS mapping (considering manual overrides) */
finalMapping: number[] | undefined;
/** Match status: full (all exact), partial (some mismatches), missing (type not found) */
matchStatus: PrinterMatchStatus;
/** Number of slots with exact match (type + color) */
exactMatches: number;
/** Number of slots with type-only match */
typeOnlyMatches: number;
/** Number of slots with missing type */
missingTypes: number;
/** Total required slots */
totalSlots: number;
/** Per-printer config */
config: PerPrinterConfig;
}
/**
* Result of the useMultiPrinterFilamentMapping hook.
*/
export interface UseMultiPrinterFilamentMappingResult {
/** Results for each selected printer */
printerResults: PrinterMappingResult[];
/** Whether any printer data is still loading */
isLoading: boolean;
/** Per-printer configurations */
perPrinterConfigs: Record<number, PerPrinterConfig>;
/** Update config for a specific printer */
updatePrinterConfig: (printerId: number, config: Partial<PerPrinterConfig>) => void;
/** Auto-configure all printers based on their loaded filaments */
autoConfigureAll: () => void;
/** Auto-configure a specific printer */
autoConfigurePrinter: (printerId: number) => void;
/** Get final mapping for a specific printer (for submission) */
getFinalMapping: (printerId: number) => number[] | undefined;
/** Check if all printers have acceptable mappings */
allPrintersReady: boolean;
}
/**
* Compute match details for a printer given filament requirements and loaded filaments.
*/
function computeMatchDetails(
filamentReqs: FilamentRequirement[] | undefined,
loadedFilaments: LoadedFilament[],
manualMappings: Record<number, number>
): { exactMatches: number; typeOnlyMatches: number; missingTypes: number; totalSlots: number; status: PrinterMatchStatus } {
if (!filamentReqs || filamentReqs.length === 0) {
return { exactMatches: 0, typeOnlyMatches: 0, missingTypes: 0, totalSlots: 0, status: 'full' };
}
let exactMatches = 0;
let typeOnlyMatches = 0;
let missingTypes = 0;
const usedTrayIds = new Set<number>(Object.values(manualMappings));
for (const req of filamentReqs) {
const slotId = req.slot_id || 0;
// Check manual override first
if (slotId > 0 && manualMappings[slotId] !== undefined) {
const manualTrayId = manualMappings[slotId];
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
if (manualLoaded) {
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch =
normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
colorsAreSimilar(manualLoaded.color, req.color);
if (typeMatch && colorMatch) {
exactMatches++;
} else if (typeMatch) {
typeOnlyMatches++;
} else {
missingTypes++;
}
continue;
}
}
// Auto-match
const exactMatch = loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = exactMatch
? undefined
: loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
exactMatch || similarMatch
? undefined
: loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
if (exactMatch || similarMatch) {
exactMatches++;
} else if (typeOnlyMatch) {
typeOnlyMatches++;
} else {
missingTypes++;
}
}
const totalSlots = filamentReqs.length;
let status: PrinterMatchStatus = 'full';
if (missingTypes > 0) {
status = 'missing';
} else if (typeOnlyMatches > 0) {
status = 'partial';
}
return { exactMatches, typeOnlyMatches, missingTypes, totalSlots, status };
}
/**
* Compute AMS mapping with manual overrides applied.
*/
function computeMappingWithOverrides(
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
printerStatus: PrinterStatus | undefined,
manualMappings: Record<number, number>
): number[] | undefined {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
const loadedFilaments = buildLoadedFilaments(printerStatus);
if (loadedFilaments.length === 0) return undefined;
const usedTrayIds = new Set<number>(Object.values(manualMappings));
const comparisons: { slot_id: number; globalTrayId: number }[] = [];
for (const req of filamentReqs.filaments) {
const slotId = req.slot_id || 0;
// Check manual override first
if (slotId > 0 && manualMappings[slotId] !== undefined) {
comparisons.push({ slot_id: slotId, globalTrayId: manualMappings[slotId] });
continue;
}
// Auto-match
const exactMatch = loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = exactMatch
? undefined
: loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
exactMatch || similarMatch
? undefined
: loadedFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
comparisons.push({ slot_id: slotId, globalTrayId: loaded?.globalTrayId ?? -1 });
}
const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
if (maxSlotId <= 0) return undefined;
const mapping = new Array(maxSlotId).fill(-1);
comparisons.forEach((f) => {
if (f.slot_id && f.slot_id > 0) {
mapping[f.slot_id - 1] = f.globalTrayId;
}
});
return mapping;
}
/**
* Default per-printer config (use default mapping).
*/
const DEFAULT_PRINTER_CONFIG: PerPrinterConfig = {
useDefault: true,
manualMappings: {},
autoConfigured: false,
};
/**
* Hook to manage filament mapping for multiple printers.
* Fetches printer status for all selected printers and computes per-printer mappings.
*/
export function useMultiPrinterFilamentMapping(
selectedPrinterIds: number[],
printers: Printer[] | undefined,
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
defaultMappings: Record<number, number>,
perPrinterConfigs: Record<number, PerPrinterConfig>,
setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>
): UseMultiPrinterFilamentMappingResult {
// Fetch printer status for all selected printers in parallel
const statusQueries = useQueries({
queries: selectedPrinterIds.map((printerId) => ({
queryKey: ['printer-status', printerId],
queryFn: () => api.getPrinterStatus(printerId),
enabled: selectedPrinterIds.length > 0,
staleTime: 5000, // Consider data fresh for 5 seconds
})),
});
// Build results for each printer
const printerResults = useMemo((): PrinterMappingResult[] => {
return selectedPrinterIds.map((printerId, index) => {
const query = statusQueries[index];
const printerStatus = query?.data;
const printer = printers?.find((p) => p.id === printerId);
const printerName = printer?.name || `Printer ${printerId}`;
const loadedFilaments = buildLoadedFilaments(printerStatus);
const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG;
// Compute auto mapping for this printer
const autoMapping = computeAmsMapping(filamentReqs, printerStatus);
// Determine which mappings to use:
// If printer has override (useDefault=false), use its custom mappings
// Otherwise use the default mappings
const effectiveMappings = !config.useDefault
? config.manualMappings
: defaultMappings;
// Compute final mapping with overrides
const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings);
// Compute match details
const matchDetails = computeMatchDetails(
filamentReqs?.filaments,
loadedFilaments,
effectiveMappings
);
return {
printerId,
printerName,
status: printerStatus,
isLoading: query?.isLoading ?? false,
loadedFilaments,
autoMapping,
finalMapping,
matchStatus: matchDetails.status,
exactMatches: matchDetails.exactMatches,
typeOnlyMatches: matchDetails.typeOnlyMatches,
missingTypes: matchDetails.missingTypes,
totalSlots: matchDetails.totalSlots,
config,
};
});
}, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings]);
const isLoading = statusQueries.some((q) => q.isLoading);
// Update config for a specific printer
const updatePrinterConfig = (printerId: number, updates: Partial<PerPrinterConfig>) => {
setPerPrinterConfigs((prev) => ({
...prev,
[printerId]: {
...(prev[printerId] || DEFAULT_PRINTER_CONFIG),
...updates,
},
}));
};
// Auto-configure a specific printer based on its loaded filaments
const autoConfigurePrinter = (printerId: number) => {
const result = printerResults.find((r) => r.printerId === printerId);
if (!result || !result.status || !filamentReqs?.filaments) return;
// Compute optimal mapping for this printer
const autoMapping = computeAmsMapping(filamentReqs, result.status);
if (!autoMapping) return;
// Convert autoMapping array to manualMappings record
const manualMappings: Record<number, number> = {};
autoMapping.forEach((globalTrayId, index) => {
if (globalTrayId !== -1) {
manualMappings[index + 1] = globalTrayId;
}
});
updatePrinterConfig(printerId, {
useDefault: false,
manualMappings,
autoConfigured: true,
});
};
// Auto-configure all printers
const autoConfigureAll = () => {
for (const printerId of selectedPrinterIds) {
autoConfigurePrinter(printerId);
}
};
// Get final mapping for a specific printer (for submission)
const getFinalMapping = (printerId: number): number[] | undefined => {
const result = printerResults.find((r) => r.printerId === printerId);
return result?.finalMapping;
};
// Check if all printers have acceptable mappings (no missing types)
const allPrintersReady = printerResults.every((r) => r.matchStatus !== 'missing');
return {
printerResults,
isLoading,
perPrinterConfigs,
updatePrinterConfig,
autoConfigureAll,
autoConfigurePrinter,
getFinalMapping,
allPrintersReady,
};
}
+27 -17
View File
@@ -50,7 +50,7 @@ import type { Archive, ProjectListItem } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ModelViewerModal } from '../components/ModelViewerModal';
import { ReprintModal } from '../components/ReprintModal';
import { PrintModal } from '../components/PrintModal';
import { UploadModal } from '../components/UploadModal';
import { ConfirmModal } from '../components/ConfirmModal';
import { EditArchiveModal } from '../components/EditArchiveModal';
@@ -62,7 +62,6 @@ import { QRCodeModal } from '../components/QRCodeModal';
import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
import { ProjectPageModal } from '../components/ProjectPageModal';
import { TimelapseViewer } from '../components/TimelapseViewer';
import { AddToQueueModal } from '../components/AddToQueueModal';
import { CompareArchivesModal } from '../components/CompareArchivesModal';
import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
import { useToast } from '../contexts/ToastContext';
@@ -80,6 +79,17 @@ function formatDuration(seconds: number): string {
return `${minutes}m`;
}
/**
* Check if an archive filename represents a sliced/printable file.
* Matches: .gcode, .gcode.3mf, .gcode.anything
*/
function isSlicedFile(filename: string | null | undefined): boolean {
if (!filename) return false;
const lower = filename.toLowerCase();
// Match .gcode at end OR .gcode. followed by anything (like .gcode.3mf)
return lower.endsWith('.gcode') || lower.includes('.gcode.');
}
// formatDate imported from '../utils/date' - handles UTC conversion
function ArchiveCard({
@@ -246,7 +256,7 @@ function ArchiveCard({
setContextMenu({ x: e.clientX, y: e.clientY });
};
const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.');
const isGcodeFile = isSlicedFile(archive.filename);
const contextMenuItems: ContextMenuItem[] = [
// For gcode files: show Print option
@@ -632,17 +642,17 @@ function ArchiveCard({
{/* File type badge */}
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
archive.filename?.toLowerCase().includes('.gcode.')
isSlicedFile(archive.filename)
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-orange-500/20 text-orange-400'
}`}
title={
archive.filename?.toLowerCase().includes('.gcode.')
isSlicedFile(archive.filename)
? 'Sliced file - ready to print'
: 'Source file only - no AMS mapping available'
}
>
{archive.filename?.toLowerCase().includes('.gcode.') ? 'GCODE' : 'SOURCE'}
{isSlicedFile(archive.filename) ? 'GCODE' : 'SOURCE'}
</span>
{archive.project_name && (
<span
@@ -755,7 +765,7 @@ function ArchiveCard({
{/* Actions */}
<div className="flex gap-1 mt-3">
{archive.filename?.toLowerCase().includes('.gcode.') ? (
{isSlicedFile(archive.filename) ? (
// Sliced file - can print directly
<>
<Button
@@ -871,13 +881,11 @@ function ArchiveCard({
{/* Reprint Modal */}
{showReprint && (
<ReprintModal
<PrintModal
mode="reprint"
archiveId={archive.id}
archiveName={archive.print_name || archive.filename}
onClose={() => setShowReprint(false)}
onSuccess={() => {
// Could show a toast notification here
}}
/>
)}
@@ -1045,7 +1053,8 @@ function ArchiveCard({
)}
{showSchedule && (
<AddToQueueModal
<PrintModal
mode="add-to-queue"
archiveId={archive.id}
archiveName={archive.print_name || archive.filename}
onClose={() => setShowSchedule(false)}
@@ -1239,7 +1248,7 @@ function ArchiveListRow({
setContextMenu({ x: e.clientX, y: e.clientY });
};
const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.');
const isGcodeFile = isSlicedFile(archive.filename);
const contextMenuItems: ContextMenuItem[] = [
...(isGcodeFile ? [
@@ -1619,11 +1628,11 @@ function ArchiveListRow({
{/* Reprint Modal */}
{showReprint && (
<ReprintModal
<PrintModal
mode="reprint"
archiveId={archive.id}
archiveName={archive.print_name || archive.filename}
onClose={() => setShowReprint(false)}
onSuccess={() => {}}
/>
)}
@@ -1779,7 +1788,8 @@ function ArchiveListRow({
{/* Schedule Modal */}
{showSchedule && (
<AddToQueueModal
<PrintModal
mode="add-to-queue"
archiveId={archive.id}
archiveName={archive.print_name || archive.filename}
onClose={() => setShowSchedule(false)}
@@ -2067,7 +2077,7 @@ export function ArchivesPage() {
const matchesTag = !filterTag || archiveTags.includes(filterTag);
// File type filter (gcode = sliced, source = project file only)
const isGcodeFile = a.filename?.toLowerCase().includes('.gcode.');
const isGcodeFile = isSlicedFile(a.filename);
const matchesFileType = filterFileType === 'all' ||
(filterFileType === 'gcode' && isGcodeFile) ||
(filterFileType === 'source' && !isGcodeFile);
+213 -20
View File
@@ -34,6 +34,8 @@ import {
Archive as ArchiveIcon,
Briefcase,
Printer,
Pencil,
Play,
} from 'lucide-react';
import { api } from '../api/client';
import type {
@@ -46,6 +48,7 @@ import type {
} from '../api/client';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { PrintModal } from '../components/PrintModal';
import { useToast } from '../contexts/ToastContext';
type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
@@ -119,6 +122,59 @@ function NewFolderModal({ parentId, onClose, onSave, isLoading }: NewFolderModal
);
}
// Rename Modal
interface RenameModalProps {
type: 'file' | 'folder';
currentName: string;
onClose: () => void;
onSave: (newName: string) => void;
isLoading: boolean;
}
function RenameModal({ type, currentName, onClose, onSave, isLoading }: RenameModalProps) {
const [name, setName] = useState(currentName);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim() && name.trim() !== currentName) {
onSave(name.trim());
}
};
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
<div className="p-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">Rename {type === 'file' ? 'File' : 'Folder'}</h2>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
autoFocus
required
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim() || name.trim() === currentName || isLoading}>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Rename'}
</Button>
</div>
</form>
</div>
</div>
);
}
// Move Files Modal
interface MoveFilesModalProps {
folders: LibraryFolderTree[];
@@ -567,10 +623,11 @@ interface FolderTreeItemProps {
onSelect: (id: number | null) => void;
onDelete: (id: number) => void;
onLink: (folder: LibraryFolderTree) => void;
onRename: (folder: LibraryFolderTree) => void;
depth?: number;
}
function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, depth = 0 }: FolderTreeItemProps) {
function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0 }: FolderTreeItemProps) {
const [expanded, setExpanded] = useState(true);
const [showActions, setShowActions] = useState(false);
const hasChildren = folder.children.length > 0;
@@ -642,6 +699,13 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
<>
<div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
<div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
<button
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onRename(folder); setShowActions(false); }}
>
<Pencil className="w-3.5 h-3.5" />
Rename
</button>
<button
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onLink(folder); setShowActions(false); }}
@@ -672,6 +736,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
onSelect={onSelect}
onDelete={onDelete}
onLink={onLink}
onRename={onRename}
depth={depth + 1}
/>
))}
@@ -695,9 +760,11 @@ interface FileCardProps {
onDelete: (id: number) => void;
onDownload: (id: number) => void;
onAddToQueue?: (id: number) => void;
onPrint?: (file: LibraryFileListItem) => void;
onRename?: (file: LibraryFileListItem) => void;
}
function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue }: FileCardProps) {
function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) {
const [showActions, setShowActions] = useState(false);
return (
@@ -759,8 +826,8 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
)}
</div>
{/* Actions */}
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
{/* Actions - always visible on mobile, hover on desktop */}
<div className="absolute bottom-2 right-2 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setShowActions(!showActions)}
className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
@@ -771,12 +838,21 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
<>
<div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
<div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
{onAddToQueue && isSlicedFilename(file.filename) && (
{onPrint && isSlicedFilename(file.filename) && (
<button
className="w-full px-3 py-1.5 text-left text-sm text-bambu-green hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
onClick={() => { onPrint(file); setShowActions(false); }}
>
<Printer className="w-3.5 h-3.5" />
Print
</button>
)}
{onAddToQueue && isSlicedFilename(file.filename) && (
<button
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
>
<Clock className="w-3.5 h-3.5" />
Add to Queue
</button>
)}
@@ -787,6 +863,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
<Download className="w-3.5 h-3.5" />
Download
</button>
{onRename && (
<button
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onRename(file); setShowActions(false); }}
>
<Pencil className="w-3.5 h-3.5" />
Rename
</button>
)}
<button
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onDelete(file.id); setShowActions(false); }}
@@ -799,11 +884,11 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
)}
</div>
{/* Selection checkbox */}
{/* Selection checkbox - always visible on mobile, hover on desktop */}
<div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
isSelected
? 'bg-bambu-green border-bambu-green'
: 'border-white/30 bg-black/30 opacity-0 group-hover:opacity-100'
: 'border-white/30 bg-black/30 opacity-100 md:opacity-0 md:group-hover:opacity-100'
}`}>
{isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
</div>
@@ -828,6 +913,9 @@ export function FileManagerPage() {
const [showUploadModal, setShowUploadModal] = useState(false);
const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
});
@@ -841,8 +929,8 @@ export function FileManagerPage() {
// Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
useEffect(() => {
const folderParam = searchParams.get('folder');
const newFolderId = folderParam ? parseInt(folderParam, 10) : null;
if (newFolderId !== selectedFolderId) {
if (folderParam) {
const newFolderId = parseInt(folderParam, 10);
setSelectedFolderId(newFolderId);
}
}, [searchParams]);
@@ -1006,6 +1094,7 @@ export function FileManagerPage() {
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['library-files'] });
queryClient.invalidateQueries({ queryKey: ['queue'] });
queryClient.invalidateQueries({ queryKey: ['archives'] }); // Archives are created when adding to queue
setSelectedFiles([]);
if (result.added.length > 0 && result.errors.length === 0) {
@@ -1025,6 +1114,36 @@ export function FileManagerPage() {
onError: (error: Error) => showToast(error.message, 'error'),
});
const renameFileMutation = useMutation({
mutationFn: ({ id, filename }: { id: number; filename: string }) =>
api.updateLibraryFile(id, { filename }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['library-files'] });
setRenameItem(null);
showToast('File renamed', 'success');
},
onError: (error: Error) => {
setRenameItem(null);
showToast(error.message, 'error');
},
});
const renameFolderMutation = useMutation({
mutationFn: ({ id, name }: { id: number; name: string }) =>
api.updateLibraryFolder(id, { name }),
onSuccess: () => {
// Invalidate both folders and files - files may display folder info
queryClient.invalidateQueries({ queryKey: ['library-folders'] });
queryClient.invalidateQueries({ queryKey: ['library-files'] });
setRenameItem(null);
showToast('Folder renamed', 'success');
},
onError: (error: Error) => {
setRenameItem(null);
showToast(error.message, 'error');
},
});
// Helper to check if a file is sliced (printable)
const isSlicedFile = useCallback((filename: string) => {
const lower = filename.toLowerCase();
@@ -1213,6 +1332,7 @@ export function FileManagerPage() {
onSelect={setSelectedFolderId}
onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
onLink={setLinkFolder}
onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
/>
))}
</div>
@@ -1317,14 +1437,24 @@ export function FileManagerPage() {
{selectedFiles.length} selected
</span>
<div className="flex-1" />
{selectedSlicedFiles.length > 0 && (
{selectedSlicedFiles.length === 1 && (
<Button
variant="primary"
size="sm"
onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
>
<Play className="w-4 h-4 mr-1" />
Print
</Button>
)}
{selectedSlicedFiles.length > 0 && (
<Button
variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
size="sm"
onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
disabled={addToQueueMutation.isPending}
>
<Printer className="w-4 h-4 mr-1" />
<Clock className="w-4 h-4 mr-1" />
{addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}
</Button>
)}
@@ -1413,6 +1543,8 @@ export function FileManagerPage() {
onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
onDownload={handleDownload}
onAddToQueue={(id) => addToQueueMutation.mutate([id])}
onPrint={setPrintFile}
onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
/>
))}
</div>
@@ -1503,14 +1635,23 @@ export function FileManagerPage() {
{/* Actions */}
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{isSlicedFilename(file.filename) && (
<button
onClick={() => addToQueueMutation.mutate([file.id])}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
title="Add to Queue"
disabled={addToQueueMutation.isPending}
>
<Printer className="w-4 h-4" />
</button>
<>
<button
onClick={() => setPrintFile(file)}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
title="Print"
>
<Printer className="w-4 h-4" />
</button>
<button
onClick={() => addToQueueMutation.mutate([file.id])}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
title="Add to Queue"
disabled={addToQueueMutation.isPending}
>
<Clock className="w-4 h-4" />
</button>
</>
)}
<button
onClick={() => handleDownload(file.id)}
@@ -1519,6 +1660,13 @@ export function FileManagerPage() {
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => setRenameItem({ type: 'file', id: file.id, name: file.filename })}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
title="Rename"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
@@ -1595,6 +1743,51 @@ export function FileManagerPage() {
onCancel={() => setDeleteConfirm(null)}
/>
)}
{printFile && (
<PrintModal
mode="reprint"
libraryFileId={printFile.id}
archiveName={printFile.print_name || printFile.filename}
onClose={() => setPrintFile(null)}
onSuccess={() => {
setPrintFile(null);
queryClient.invalidateQueries({ queryKey: ['library-files'] });
queryClient.invalidateQueries({ queryKey: ['archives'] });
}}
/>
)}
{printMultiFile && (
<PrintModal
mode="reprint"
libraryFileId={printMultiFile.id}
archiveName={printMultiFile.print_name || printMultiFile.filename}
onClose={() => setPrintMultiFile(null)}
onSuccess={() => {
setPrintMultiFile(null);
setSelectedFiles([]);
queryClient.invalidateQueries({ queryKey: ['library-files'] });
queryClient.invalidateQueries({ queryKey: ['archives'] });
}}
/>
)}
{renameItem && (
<RenameModal
type={renameItem.type}
currentName={renameItem.name}
onClose={() => setRenameItem(null)}
onSave={(newName) => {
if (renameItem.type === 'file') {
renameFileMutation.mutate({ id: renameItem.id, filename: newName });
} else {
renameFolderMutation.mutate({ id: renameItem.id, name: newName });
}
}}
isLoading={renameFileMutation.isPending || renameFolderMutation.isPending}
/>
)}
</div>
);
}
+201 -35
View File
@@ -58,12 +58,14 @@ import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { FileManagerModal } from '../components/FileManagerModal';
import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer';
import { MQTTDebugModal } from '../components/MQTTDebugModal';
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 { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
import { useToast } from '../contexts/ToastContext';
import { ChamberLight } from '../components/icons/ChamberLight';
@@ -378,6 +380,10 @@ function hexToBasicColorName(hex: string | null | undefined): string {
}
// Classify by hue
// Brown is orange/yellow hue with lower lightness
if (h >= 15 && h < 45 && l < 0.45) return 'Brown';
if (h >= 45 && h < 70 && l < 0.40) return 'Brown';
if (h < 15 || h >= 345) return 'Red';
if (h < 45) return 'Orange';
if (h < 70) return 'Yellow';
@@ -904,6 +910,8 @@ function PrinterCard({
spoolmanEnabled = false,
hasUnlinkedSpools = false,
timeFormat = 'system',
cameraViewMode = 'window',
onOpenEmbeddedCamera,
}: {
printer: Printer;
hideIfDisconnected?: boolean;
@@ -919,6 +927,8 @@ function PrinterCard({
spoolmanEnabled?: boolean;
hasUnlinkedSpools?: boolean;
timeFormat?: 'system' | '12h' | '24h';
cameraViewMode?: 'window' | 'embedded';
onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
@@ -945,6 +955,15 @@ function PrinterCard({
trayUuid: string;
trayInfo: { type: string; color: string; location: string };
} | null>(null);
const [configureSlotModal, setConfigureSlotModal] = useState<{
amsId: number;
trayId: number;
trayCount: number;
trayType?: string;
trayColor?: string;
traySubBrands?: string;
trayInfoIdx?: string;
} | null>(null);
const [showFirmwareModal, setShowFirmwareModal] = useState(false);
const { data: status } = useQuery({
@@ -987,6 +1006,13 @@ function PrinterCard({
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Fetch slot preset mappings (stores preset name for user-configured slots)
const { data: slotPresets } = useQuery({
queryKey: ['slotPresets', printer.id],
queryFn: () => api.getSlotPresets(printer.id),
staleTime: 2 * 60 * 1000, // 2 minutes
});
// Cache WiFi signal to prevent it disappearing on updates
const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
useEffect(() => {
@@ -1712,45 +1738,54 @@ function PrinterCard({
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;
const isDualNozzle = printer.nozzle_count === 2 || status.temperatures.nozzle_2 !== undefined;
// active_extruder: 0=right, 1=left
const activeNozzle = status.active_extruder === 1 ? 'L' : 'R';
return (
<div className="grid grid-cols-3 gap-2">
<div className="flex items-center gap-1.5">
{/* Nozzle temp - combined for dual nozzle */}
<div className="text-center p-2 bg-bambu-dark rounded-lg">
<HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-orange-400" isHeating={nozzleHeating} />
<div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1">
<HeaterThermometer className="w-3.5 h-3.5 mx-auto mb-0.5" color="text-orange-400" isHeating={nozzleHeating} />
{status.temperatures.nozzle_2 !== undefined ? (
<>
<p className="text-[10px] text-bambu-gray">L / R</p>
<p className="text-xs text-white">
<p className="text-[9px] text-bambu-gray">L / R</p>
<p className="text-[11px] text-white">
{Math.round(status.temperatures.nozzle || 0)}° / {Math.round(status.temperatures.nozzle_2 || 0)}°
</p>
</>
) : (
<>
<p className="text-[10px] text-bambu-gray">Nozzle</p>
<p className="text-xs text-white">
<p className="text-[9px] text-bambu-gray">Nozzle</p>
<p className="text-[11px] text-white">
{Math.round(status.temperatures.nozzle || 0)}°C
</p>
</>
)}
</div>
<div className="text-center p-2 bg-bambu-dark rounded-lg">
<HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-blue-400" isHeating={bedHeating} />
<p className="text-[10px] text-bambu-gray">Bed</p>
<p className="text-xs text-white">
<div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1">
<HeaterThermometer className="w-3.5 h-3.5 mx-auto mb-0.5" color="text-blue-400" isHeating={bedHeating} />
<p className="text-[9px] text-bambu-gray">Bed</p>
<p className="text-[11px] text-white">
{Math.round(status.temperatures.bed || 0)}°C
</p>
</div>
{status.temperatures.chamber !== undefined ? (
<div className="text-center p-2 bg-bambu-dark rounded-lg">
<HeaterThermometer className="w-4 h-4 mx-auto mb-1" color="text-green-400" isHeating={chamberHeating} />
<p className="text-[10px] text-bambu-gray">Chamber</p>
<p className="text-xs text-white">
{status.temperatures.chamber !== undefined && (
<div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1">
<HeaterThermometer className="w-3.5 h-3.5 mx-auto mb-0.5" color="text-green-400" isHeating={chamberHeating} />
<p className="text-[9px] text-bambu-gray">Chamber</p>
<p className="text-[11px] text-white">
{Math.round(status.temperatures.chamber || 0)}°C
</p>
</div>
) : (
<div /> /* Empty placeholder to maintain grid */
)}
{/* Active nozzle indicator for dual-nozzle printers */}
{isDualNozzle && (
<div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg" title={`Active: ${activeNozzle === 'L' ? 'Left' : 'Right'} nozzle`}>
<p className={`text-[11px] font-bold ${activeNozzle === 'L' ? 'text-amber-400' : 'text-gray-500'}`}>L</p>
<p className="text-[9px] text-bambu-gray">Nozzle</p>
<p className={`text-[11px] font-bold ${activeNozzle === 'R' ? 'text-amber-400' : 'text-gray-500'}`}>R</p>
</div>
)}
</div>
);
@@ -1947,11 +1982,13 @@ function PrinterCard({
const isActive = effectiveTrayNow === globalTrayId;
// Get cloud preset info if available
const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
// Get saved slot preset mapping (for user-configured slots)
const slotPreset = slotPresets?.[globalTrayId];
// Build filament data for hover card
const filamentData = tray?.tray_type ? {
vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
colorHex: tray.tray_color || null,
kFactor: formatKValue(tray.k),
@@ -2057,11 +2094,32 @@ function PrinterCard({
});
} : undefined,
}}
configureSlot={{
enabled: true,
onConfigure: () => setConfigureSlotModal({
amsId: ams.id,
trayId: slotIdx,
trayCount: ams.tray.length,
trayType: tray?.tray_type || undefined,
trayColor: tray?.tray_color || undefined,
traySubBrands: tray?.tray_sub_brands || undefined,
trayInfoIdx: tray?.tray_info_idx || undefined,
}),
}}
>
{slotVisual}
</FilamentHoverCard>
) : (
<EmptySlotHoverCard>
<EmptySlotHoverCard
configureSlot={{
enabled: true,
onConfigure: () => setConfigureSlotModal({
amsId: ams.id,
trayId: slotIdx,
trayCount: ams.tray.length,
}),
}}
>
{slotVisual}
</EmptySlotHoverCard>
)}
@@ -2094,11 +2152,13 @@ function PrinterCard({
const isActive = effectiveTrayNow === globalTrayId;
// Get cloud preset info if available
const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
// Get saved slot preset mapping (for user-configured slots)
const slotPreset = slotPresets?.[globalTrayId];
// Build filament data for hover card
const filamentData = tray?.tray_type ? {
vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
colorHex: tray.tray_color || null,
kFactor: formatKValue(tray.k),
@@ -2217,11 +2277,32 @@ function PrinterCard({
});
} : undefined,
}}
configureSlot={{
enabled: true,
onConfigure: () => setConfigureSlotModal({
amsId: ams.id,
trayId: htSlotId,
trayCount: ams.tray.length,
trayType: tray?.tray_type || undefined,
trayColor: tray?.tray_color || undefined,
traySubBrands: tray?.tray_sub_brands || undefined,
trayInfoIdx: tray?.tray_info_idx || undefined,
}),
}}
>
{slotVisual}
</FilamentHoverCard>
) : (
<EmptySlotHoverCard>
<EmptySlotHoverCard
configureSlot={{
enabled: true,
onConfigure: () => setConfigureSlotModal({
amsId: ams.id,
trayId: htSlotId,
trayCount: ams.tray.length,
}),
}}
>
{slotVisual}
</EmptySlotHoverCard>
)}
@@ -2268,11 +2349,13 @@ function PrinterCard({
const isExtActive = effectiveTrayNow === 254;
// Get cloud preset info if available
const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
// Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
const extSlotPreset = slotPresets?.[255 * 4 + 0];
// Build filament data for hover card
const extFilamentData = {
vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
profile: extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
colorHex: extTray.tray_color || null,
kFactor: formatKValue(extTray.k),
@@ -2322,6 +2405,18 @@ function PrinterCard({
});
} : undefined,
}}
configureSlot={{
enabled: true,
onConfigure: () => setConfigureSlotModal({
amsId: 255, // External spool indicator
trayId: 0,
trayCount: 1, // External = single slot
trayType: extTray.tray_type || undefined,
trayColor: extTray.tray_color || undefined,
traySubBrands: extTray.tray_sub_brands || undefined,
trayInfoIdx: extTray.tray_info_idx || undefined,
}),
}}
>
{extSlotContent}
</FilamentHoverCard>
@@ -2444,20 +2539,24 @@ function PrinterCard({
variant="secondary"
size="sm"
onClick={() => {
// Use saved window state or defaults
const saved = localStorage.getItem('cameraWindowState');
const state = saved ? JSON.parse(saved) : { width: 640, height: 400 };
const features = [
`width=${state.width}`,
`height=${state.height}`,
state.left !== undefined ? `left=${state.left}` : '',
state.top !== undefined ? `top=${state.top}` : '',
'menubar=no,toolbar=no,location=no,status=no,noopener',
].filter(Boolean).join(',');
window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
if (cameraViewMode === 'embedded' && onOpenEmbeddedCamera) {
onOpenEmbeddedCamera(printer.id, printer.name);
} else {
// Use saved window state or defaults
const saved = localStorage.getItem('cameraWindowState');
const state = saved ? JSON.parse(saved) : { width: 640, height: 400 };
const features = [
`width=${state.width}`,
`height=${state.height}`,
state.left !== undefined ? `left=${state.left}` : '',
state.top !== undefined ? `top=${state.top}` : '',
'menubar=no,toolbar=no,location=no,status=no,noopener',
].filter(Boolean).join(',');
window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
}
}}
disabled={!status?.connected}
title="Open camera in new window"
title={cameraViewMode === 'embedded' ? 'Open camera overlay' : 'Open camera in new window'}
>
<Video className="w-4 h-4" />
</Button>
@@ -2821,6 +2920,22 @@ function PrinterCard({
/>
)}
{/* Configure AMS Slot Modal */}
{configureSlotModal && (
<ConfigureAmsSlotModal
isOpen={!!configureSlotModal}
onClose={() => setConfigureSlotModal(null)}
printerId={printer.id}
slotInfo={configureSlotModal}
onSuccess={() => {
// Refresh slot presets to show updated profile name
queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] });
// Printer status will update automatically via WebSocket when AMS data changes
queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
}}
/>
)}
{/* Edit Printer Modal */}
{showEditModal && (
<EditPrinterModal
@@ -3687,6 +3802,31 @@ export function PrintersPage() {
// Derive viewMode from cardSize: S=compact, M/L/XL=expanded
const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
const queryClient = useQueryClient();
// Embedded camera viewer state - supports multiple simultaneous viewers
// Persisted to localStorage so cameras reopen after navigation
const [embeddedCameraPrinters, setEmbeddedCameraPrinters] = useState<Map<number, { id: number; name: string }>>(() => {
// Initialize from localStorage if camera_view_mode is embedded
const saved = localStorage.getItem('openEmbeddedCameras');
if (saved) {
try {
const cameras = JSON.parse(saved) as Array<{ id: number; name: string }>;
return new Map(cameras.map(c => [c.id, c]));
} catch {
return new Map();
}
}
return new Map();
});
// Persist open cameras to localStorage when they change
useEffect(() => {
const cameras = Array.from(embeddedCameraPrinters.values());
if (cameras.length > 0) {
localStorage.setItem('openEmbeddedCameras', JSON.stringify(cameras));
} else {
localStorage.removeItem('openEmbeddedCameras');
}
}, [embeddedCameraPrinters]);
const { data: printers, isLoading } = useQuery({
queryKey: ['printers'],
@@ -3699,6 +3839,13 @@ export function PrintersPage() {
queryFn: api.getSettings,
});
// Close embedded cameras if mode changes to 'window'
useEffect(() => {
if (settings?.camera_view_mode === 'window' && embeddedCameraPrinters.size > 0) {
setEmbeddedCameraPrinters(new Map());
}
}, [settings?.camera_view_mode, embeddedCameraPrinters.size]);
// Fetch all smart plugs to know which printers have them
const { data: smartPlugs } = useQuery({
queryKey: ['smart-plugs'],
@@ -4027,6 +4174,8 @@ export function PrintersPage() {
spoolmanEnabled={spoolmanEnabled}
hasUnlinkedSpools={hasUnlinkedSpools}
timeFormat={settings?.time_format || 'system'}
cameraViewMode={settings?.camera_view_mode || 'window'}
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
/>
))}
</div>
@@ -4053,6 +4202,8 @@ export function PrintersPage() {
tempFair: Number(settings.ams_temp_fair) || 35,
} : undefined}
timeFormat={settings?.time_format || 'system'}
cameraViewMode={settings?.camera_view_mode || 'window'}
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
/>
))}
</div>
@@ -4065,6 +4216,21 @@ export function PrintersPage() {
existingSerials={printers?.map(p => p.serial_number) || []}
/>
)}
{/* Embedded Camera Viewers - multiple viewers can be open simultaneously */}
{Array.from(embeddedCameraPrinters.values()).map((camera, index) => (
<EmbeddedCameraViewer
key={camera.id}
printerId={camera.id}
printerName={camera.name}
viewerIndex={index}
onClose={() => setEmbeddedCameraPrinters(prev => {
const next = new Map(prev);
next.delete(camera.id);
return next;
})}
/>
))}
</div>
);
}
+46 -21
View File
@@ -48,8 +48,7 @@ import type { PrintQueueItem } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { EditQueueItemModal } from '../components/EditQueueItemModal';
import { AddToQueueModal } from '../components/AddToQueueModal';
import { PrintModal } from '../components/PrintModal';
import { useToast } from '../contexts/ToastContext';
function formatDuration(seconds: number | null | undefined): string {
@@ -168,7 +167,13 @@ function SortableQueueItem({
<div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
{item.archive_thumbnail ? (
<img
src={api.getArchiveThumbnail(item.archive_id)}
src={api.getArchiveThumbnail(item.archive_id!)}
alt=""
className="w-full h-full object-cover"
/>
) : item.library_file_thumbnail ? (
<img
src={api.getLibraryFileThumbnailUrl(item.library_file_id!)}
alt=""
className="w-full h-full object-cover"
/>
@@ -183,15 +188,25 @@ function SortableQueueItem({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="text-white font-medium truncate">
{item.archive_name || `Archive #${item.archive_id}`}
{item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
</p>
<Link
to={`/archives?highlight=${item.archive_id}`}
className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
title="View archive"
>
<ExternalLink className="w-3.5 h-3.5" />
</Link>
{item.archive_id ? (
<Link
to={`/archives?highlight=${item.archive_id}`}
className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
title="View archive"
>
<ExternalLink className="w-3.5 h-3.5" />
</Link>
) : item.library_file_id ? (
<Link
to={`/library?highlight=${item.library_file_id}`}
className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
title="View in File Manager"
>
<ExternalLink className="w-3.5 h-3.5" />
</Link>
) : null}
</div>
<div className="flex items-center gap-3 text-sm text-bambu-gray">
@@ -473,7 +488,9 @@ export function QueuePage() {
return [...items].sort((a, b) => {
let cmp: number;
if (pendingSortBy === 'name') {
cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
const aName = a.archive_name || a.library_file_name || '';
const bName = b.archive_name || b.library_file_name || '';
cmp = aName.localeCompare(bName);
} else if (pendingSortBy === 'printer') {
cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
} else if (pendingSortBy === 'time') {
@@ -491,7 +508,9 @@ export function QueuePage() {
return [...items].sort((a, b) => {
let cmp: number;
if (historySortBy === 'name') {
cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
const aName = a.archive_name || a.library_file_name || '';
const bName = b.archive_name || b.library_file_name || '';
cmp = aName.localeCompare(bName);
} else if (historySortBy === 'printer') {
cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
} else {
@@ -802,17 +821,23 @@ export function QueuePage() {
{/* Edit Modal */}
{editItem && (
<EditQueueItemModal
item={editItem}
<PrintModal
mode="edit-queue-item"
archiveId={editItem.archive_id ?? undefined}
libraryFileId={editItem.library_file_id ?? undefined}
archiveName={editItem.archive_name || editItem.library_file_name || `File #${editItem.archive_id || editItem.library_file_id}`}
queueItem={editItem}
onClose={() => setEditItem(null)}
/>
)}
{/* Re-queue Modal */}
{requeueItem && (
<AddToQueueModal
archiveId={requeueItem.archive_id}
archiveName={requeueItem.archive_name || `Archive #${requeueItem.archive_id}`}
<PrintModal
mode="add-to-queue"
archiveId={requeueItem.archive_id ?? undefined}
libraryFileId={requeueItem.library_file_id ?? undefined}
archiveName={requeueItem.archive_name || requeueItem.library_file_name || `File #${requeueItem.archive_id || requeueItem.library_file_id}`}
onClose={() => setRequeueItem(null)}
/>
)}
@@ -827,10 +852,10 @@ export function QueuePage() {
}
message={
confirmAction.type === 'cancel'
? `Are you sure you want to cancel "${confirmAction.item.archive_name || 'this print'}"?`
? `Are you sure you want to cancel "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"?`
: confirmAction.type === 'stop'
? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || 'this print'}"? This will cancel the print job on the printer.`
: `Are you sure you want to remove "${confirmAction.item.archive_name || 'this item'}" from the queue history?`
? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"? This will cancel the print job on the printer.`
: `Are you sure you want to remove "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this item'}" from the queue history?`
}
confirmText={
confirmAction.type === 'cancel' ? 'Cancel Print' :
+64 -3
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home } from 'lucide-react';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import { formatDateOnly } from '../utils/date';
@@ -361,6 +361,7 @@ export function SettingsPage() {
settings.ams_temp_good !== localSettings.ams_temp_good ||
settings.ams_temp_fair !== localSettings.ams_temp_fair ||
settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
settings.date_format !== localSettings.date_format ||
settings.time_format !== localSettings.time_format ||
settings.default_printer_id !== localSettings.default_printer_id ||
@@ -379,7 +380,8 @@ export function SettingsPage() {
settings.ha_url !== localSettings.ha_url ||
settings.ha_token !== localSettings.ha_token ||
(settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5);
Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
(settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window');
if (!hasChanges) {
return;
@@ -419,6 +421,7 @@ export function SettingsPage() {
ams_temp_good: localSettings.ams_temp_good,
ams_temp_fair: localSettings.ams_temp_fair,
ams_history_retention_days: localSettings.ams_history_retention_days,
per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
date_format: localSettings.date_format,
time_format: localSettings.time_format,
default_printer_id: localSettings.default_printer_id,
@@ -438,6 +441,7 @@ export function SettingsPage() {
ha_token: localSettings.ha_token,
library_archive_mode: localSettings.library_archive_mode,
library_disk_warning_gb: localSettings.library_disk_warning_gb,
camera_view_mode: localSettings.camera_view_mode,
};
updateMutation.mutate(settingsToSave);
}, 500);
@@ -874,8 +878,38 @@ export function SettingsPage() {
</div>
{/* Second Column - Cost, AMS & Spoolman */}
{/* Second Column - Camera, Cost, AMS & Spoolman */}
<div className="space-y-6 flex-1 lg:max-w-md">
{/* Camera Settings */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Video className="w-5 h-5 text-bambu-green" />
Camera
</h2>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">
Camera View Mode
</label>
<select
value={localSettings.camera_view_mode ?? 'window'}
onChange={(e) => updateSetting('camera_view_mode', e.target.value as 'window' | 'embedded')}
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"
>
<option value="window">New Window</option>
<option value="embedded">Embedded Overlay</option>
</select>
<p className="text-xs text-bambu-gray mt-1">
{localSettings.camera_view_mode === 'embedded'
? 'Camera opens in a resizable overlay on the main screen'
: 'Camera opens in a separate browser window'}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
@@ -2487,6 +2521,33 @@ export function SettingsPage() {
Older humidity and temperature data will be automatically deleted
</p>
</div>
{/* Per-Printer Mapping Default */}
<div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
<div className="flex items-center gap-2 text-white">
<Printer className="w-4 h-4 text-bambu-green" />
<span className="font-medium">Print Modal</span>
</div>
<div className="flex items-center justify-between">
<div>
<label className="block text-sm text-white">
Expand custom mapping by default
</label>
<p className="text-xs text-bambu-gray mt-0.5">
When printing to multiple printers, show per-printer AMS mapping expanded
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.per_printer_mapping_expanded ?? false}
onChange={(e) => updateSetting('per_printer_mapping_expanded', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</div>
</CardContent>
</Card>
</div>
+4
View File
@@ -24,6 +24,7 @@ import {
} from 'lucide-react';
import { api, supportApi } from '../api/client';
import { Card } from '../components/Card';
import { LogViewer } from '../components/LogViewer';
import { formatDateTime, type TimeFormat } from '../utils/date';
function formatBytes(bytes: number): string {
@@ -341,6 +342,9 @@ export function SystemInfoPage() {
{t('support.privacyNote', 'IP addresses in logs are replaced with [IP] and email addresses with [EMAIL].')}
</p>
</div>
{/* Log Viewer */}
<LogViewer />
</div>
</Section>
+124
View File
@@ -0,0 +1,124 @@
/**
* AMS (Automatic Material System) helper utilities for Bambu Lab printers.
* These functions handle color normalization, slot labeling, and tray ID calculations
* for AMS, AMS-HT, and external spool configurations.
*/
/**
* Normalize color format from various sources.
* API returns "RRGGBBAA" (8-char), 3MF uses "#RRGGBB" (7-char with hash).
* This normalizes to "#RRGGBB" format.
*/
export function normalizeColor(color: string | null | undefined): string {
if (!color) return '#808080';
// Remove alpha channel if present (8-char hex to 6-char)
const hex = color.replace('#', '').substring(0, 6);
return `#${hex}`;
}
/**
* Normalize color for comparison (case-insensitive, strip hash and alpha).
*/
export function normalizeColorForCompare(color: string | undefined): string {
if (!color) return '';
return color.replace('#', '').toLowerCase().substring(0, 6);
}
/**
* Check if two colors are visually similar within a threshold.
* Uses RGB component comparison with configurable tolerance.
* @param color1 - First hex color
* @param color2 - Second hex color
* @param threshold - Maximum difference per RGB component (default: 40)
*/
export function colorsAreSimilar(
color1: string | undefined,
color2: string | undefined,
threshold = 40
): boolean {
const hex1 = normalizeColorForCompare(color1);
const hex2 = normalizeColorForCompare(color2);
if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
const r1 = parseInt(hex1.substring(0, 2), 16);
const g1 = parseInt(hex1.substring(2, 4), 16);
const b1 = parseInt(hex1.substring(4, 6), 16);
const r2 = parseInt(hex2.substring(0, 2), 16);
const g2 = parseInt(hex2.substring(2, 4), 16);
const b2 = parseInt(hex2.substring(4, 6), 16);
return (
Math.abs(r1 - r2) <= threshold &&
Math.abs(g1 - g2) <= threshold &&
Math.abs(b1 - b2) <= threshold
);
}
/**
* Format slot label for display in the UI.
* @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
* @param trayId - Tray/slot ID within the AMS unit (0-3)
* @param isHt - Whether this is an AMS-HT unit (single tray)
* @param isExternal - Whether this is the external spool holder
*/
export function formatSlotLabel(
amsId: number,
trayId: number,
isHt: boolean,
isExternal: boolean
): string {
if (isExternal) return 'External';
// Convert AMS ID to letter (A, B, C, D)
// AMS-HT uses IDs starting at 128
const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
if (isHt) return `HT-${letter}`;
return `AMS-${letter} Slot ${trayId + 1}`;
}
/**
* Calculate global tray ID for MQTT command.
* Used in the ams_mapping array sent to the printer.
* @param amsId - AMS unit ID
* @param trayId - Tray/slot ID within the AMS unit
* @param isExternal - Whether this is the external spool holder
* @returns Global tray ID (0-15 for AMS, 254 for external)
*/
export function getGlobalTrayId(
amsId: number,
trayId: number,
isExternal: boolean
): number {
if (isExternal) return 254;
return amsId * 4 + trayId;
}
/**
* Format seconds to human readable time string.
*/
export function formatTime(seconds: number | null | undefined): string {
if (!seconds) return '';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
/**
* Get minimum datetime for scheduling (now + 1 minute).
* Returns ISO string format for datetime-local input.
*/
export function getMinDateTime(): string {
const now = new Date();
now.setMinutes(now.getMinutes() + 1);
return now.toISOString().slice(0, 16);
}
/**
* Check if a scheduled time is a placeholder far-future date.
* Placeholder dates (more than 6 months out) are treated as ASAP.
*/
export function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {
if (!scheduledTime) return false;
const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
return new Date(scheduledTime).getTime() > sixMonthsFromNow;
}
+6 -2
View File
@@ -2,6 +2,10 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// Backend port for dev server proxy (default: 8000)
const backendPort = process.env.BACKEND_PORT || '8000'
const backendUrl = `http://localhost:${backendPort}`
export default defineConfig({
plugins: [react()],
build: {
@@ -12,12 +16,12 @@ export default defineConfig({
server: {
proxy: {
'/api/v1/ws': {
target: 'http://localhost:8000',
target: backendUrl,
ws: true,
changeOrigin: true,
},
'/api': {
target: 'http://localhost:8000',
target: backendUrl,
changeOrigin: true,
},
},
+2 -2
View File
@@ -52,12 +52,12 @@ def on_message(client, userdata, msg):
# Always log calibration messages with full detail
if is_cali_msg:
print(f"\n{'='*80}")
print(f"\n{'=' * 80}")
print(f"[{datetime.now().strftime('%H:%M:%S.%f')[:-3]}] *** CALIBRATION COMMAND: {command} ***")
print(f"Topic: {msg.topic}")
print("Full payload:")
print(json.dumps(payload, indent=2))
print(f"{'='*80}\n")
print(f"{'=' * 80}\n")
else:
# For other messages, just show a brief summary
if "print" in payload:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CBKbW_8F.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DMQ1f41h.css">
<script type="module" crossorigin src="/assets/index-DFo1_Rau.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BmODu1qm.css">
</head>
<body>
<div id="root"></div>
+6 -3
View File
@@ -6,6 +6,9 @@
set -e
# Configuration
PORT=${PORT:-8000}
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -214,7 +217,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
print_info "Running integration tests..."
# Test health endpoint
HEALTH_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
HEALTH_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:${PORT}/health)
if echo "$HEALTH_RESPONSE" | grep -q "healthy"; then
print_success "Health endpoint responds correctly"
else
@@ -222,7 +225,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
fi
# Test API endpoints
API_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings)
API_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:${PORT}/api/v1/settings)
if echo "$API_RESPONSE" | grep -q "settings"; then
print_success "Settings API endpoint responds"
else
@@ -231,7 +234,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
fi
# Test static files
STATIC_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
STATIC_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:${PORT}/)
if [ "$STATIC_RESPONSE" = "200" ]; then
print_success "Static files served correctly"
else
+3 -2
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env python3
"""Comprehensive end-to-end test for Bambuddy application."""
import os
import time
from playwright.sync_api import expect, sync_playwright
BASE_URL = "http://localhost:8000"
BASE_URL = os.environ.get("BAMBUDDY_URL", "http://localhost:8000")
def test_navigation_and_sidebar(page):
@@ -365,7 +366,7 @@ def run_comprehensive_test():
except Exception as e:
print(f"\n❌ {test_name} FAILED: {e}")
results[test_name] = False
page.screenshot(path=f'/tmp/bambuddy_error_{test_name.lower().replace(" ", "_")}.png')
page.screenshot(path=f"/tmp/bambuddy_error_{test_name.lower().replace(' ', '_')}.png")
browser.close()
+8 -7
View File
@@ -5,11 +5,12 @@ These tests verify that toggle settings (auto_off, notification events, etc.)
are properly persisted to the database and survive page reloads.
"""
import os
import time
from playwright.sync_api import expect, sync_playwright
BASE_URL = "http://localhost:8000"
BASE_URL = os.environ.get("BAMBUDDY_URL", "http://localhost:8000")
def test_smart_plug_auto_off_toggle_persistence(page):
@@ -88,9 +89,9 @@ def test_smart_plug_auto_off_toggle_persistence(page):
toggle = page.locator('button[role="switch"]').nth(1)
persisted_state = toggle.get_attribute("aria-checked")
assert (
persisted_state == new_state
), f"State should persist after reload. Expected {new_state}, got {persisted_state}"
assert persisted_state == new_state, (
f"State should persist after reload. Expected {new_state}, got {persisted_state}"
)
print(f"✓ Toggle state persisted after reload: {persisted_state}")
# Restore original state
@@ -172,9 +173,9 @@ def test_notification_event_toggle_persistence(page):
if toggle.is_visible():
persisted_state = toggle.get_attribute("aria-checked")
assert (
persisted_state == new_state
), f"State should persist after reload. Expected {new_state}, got {persisted_state}"
assert persisted_state == new_state, (
f"State should persist after reload. Expected {new_state}, got {persisted_state}"
)
print(f"✓ Toggle state persisted after reload: {persisted_state}")
# Restore original state