New Features:

- Print from File Manager with plate selection, AMS mapping, and print options. Closes #94
- Add to queue without creating archive (reduces clutter)
- Queue displays library file name, thumbnail, and print time

Fixes:
Queue items from File Manager now reference library files directly instead of
creating archives upfront. Archives are created automatically when prints start.
This commit is contained in:
maziggy
2026-01-20 15:37:20 +01:00
parent ec66e0a665
commit 02f6de4adc
21 changed files with 1322 additions and 381 deletions
+15 -1
View File
@@ -2,7 +2,7 @@
All notable changes to Bambuddy will be documented in this file.
## [0.1.6b11] - 2026-01-20
## [Unreleased]
### New Features
- **Unified Print Modal** - Consolidated three separate modals into one unified component:
@@ -18,11 +18,25 @@ All notable changes to Bambuddy will be documented in this file.
- **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.)
- **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
### 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
## [0.1.6b10] - 2026-01-20
+8
View File
@@ -78,6 +78,14 @@
- 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
- 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
### 📁 Projects
- Group related prints (e.g., "Voron Build")
- Track plates (print jobs) and parts separately
+508 -22
View File
@@ -30,6 +30,7 @@ from backend.app.schemas.library import (
FileDuplicate,
FileListResponse,
FileMoveRequest,
FilePrintRequest,
FileResponse as FileResponseSchema,
FileUpdate,
FileUploadResponse,
@@ -755,10 +756,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 +769,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 +788,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 +797,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 +814,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 +826,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 ============
+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)
+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."""
+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
+3 -3
View File
@@ -159,9 +159,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 +219,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
@@ -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
+64 -3
View File
@@ -845,7 +845,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;
@@ -867,13 +869,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;
@@ -2594,6 +2600,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
@@ -12,9 +12,7 @@ import type { FilamentMappingProps } from './types';
*/
export function FilamentMapping({
printerId,
archiveId,
selectedPlate,
isMultiPlate,
filamentReqs,
manualMappings,
onManualMappingChange,
}: FilamentMappingProps) {
@@ -22,13 +20,6 @@ export function FilamentMapping({
const [isRefreshing, setIsRefreshing] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// 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 || !isMultiPlate,
});
// Fetch printer status
const { data: printerStatus } = useQuery({
queryKey: ['printer-status', printerId],
@@ -2,21 +2,19 @@ import { Printer as PrinterIcon, Loader2, AlertCircle, Check } from 'lucide-reac
import type { PrinterSelectorProps } from './types';
/**
* Printer selection component with multiple modes:
* - Grid mode (default): Shows printers as selectable cards (single or multi-select)
* - Dropdown mode: Shows printers in a select dropdown (used when allowUnassigned is true)
* Printer selection component with grid-based UI.
* Supports single or multi-select modes.
*/
export function PrinterSelector({
printers,
selectedPrinterId,
selectedPrinterIds = [],
onSelect,
selectedPrinterIds,
onMultiSelect,
isLoading = false,
allowUnassigned = false,
allowMultiple = false,
showInactive = false,
}: PrinterSelectorProps) {
const activePrinters = printers.filter((p) => p.is_active);
// Filter printers based on showInactive flag
const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
if (isLoading) {
return (
@@ -26,53 +24,17 @@ export function PrinterSelector({
);
}
// Use dropdown mode for edit scenarios (allows unassigning printer)
if (allowUnassigned) {
if (displayPrinters.length === 0) {
return (
<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 ${
selectedPrinterId === null ? 'border-orange-400' : 'border-bambu-dark-tertiary'
}`}
value={selectedPrinterId ?? ''}
onChange={(e) => onSelect(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>
{selectedPrinterId === 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 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>
);
}
// Grid mode for reprint/add-to-queue (only active printers)
if (activePrinters.length === 0) {
return (
<div className="text-center py-8 text-bambu-gray">No active printers available</div>
);
}
const handlePrinterClick = (printerId: number) => {
if (allowMultiple && onMultiSelect) {
if (allowMultiple) {
// Multi-select mode: toggle printer in selection
if (selectedPrinterIds.includes(printerId)) {
onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
@@ -80,36 +42,27 @@ export function PrinterSelector({
onMultiSelect([...selectedPrinterIds, printerId]);
}
} else {
// Single-select mode
onSelect(printerId);
// Single-select mode: replace selection
onMultiSelect([printerId]);
}
};
const handleSelectAll = () => {
if (onMultiSelect) {
onMultiSelect(activePrinters.map((p) => p.id));
}
onMultiSelect(displayPrinters.map((p) => p.id));
};
const handleDeselectAll = () => {
if (onMultiSelect) {
onMultiSelect([]);
}
onMultiSelect([]);
};
const isSelected = (printerId: number) => {
if (allowMultiple) {
return selectedPrinterIds.includes(printerId);
}
return selectedPrinterId === printerId;
};
const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
const selectedCount = allowMultiple ? selectedPrinterIds.length : (selectedPrinterId ? 1 : 0);
const selectedCount = selectedPrinterIds.length;
return (
<div className="space-y-2 mb-6">
{/* Multi-select header */}
{allowMultiple && activePrinters.length > 1 && (
{allowMultiple && displayPrinters.length > 1 && (
<div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
<span>
{selectedCount === 0
@@ -117,7 +70,7 @@ export function PrinterSelector({
: `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
</span>
<div className="flex gap-2">
{selectedCount < activePrinters.length && (
{selectedCount < displayPrinters.length && (
<button
type="button"
onClick={handleSelectAll}
@@ -139,7 +92,7 @@ export function PrinterSelector({
</div>
)}
{activePrinters.map((printer) => (
{displayPrinters.map((printer) => (
<button
key={printer.id}
type="button"
@@ -148,7 +101,7 @@ export function PrinterSelector({
isSelected(printer.id)
? '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 ${
@@ -162,7 +115,10 @@ export function PrinterSelector({
/>
</div>
<div className="text-left flex-1">
<p className="text-white font-medium">{printer.name}</p>
<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>
@@ -181,6 +137,14 @@ export function PrinterSelector({
)}
</button>
))}
{/* 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>
);
}
+140 -98
View File
@@ -23,13 +23,17 @@ import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
/**
* Unified PrintModal component that handles three modes:
* - 'reprint': Immediate print from archive (supports multi-printer)
* - 'add-to-queue': Schedule print to queue (supports multi-printer)
* - 'edit-queue-item': Edit existing queue item (single printer only)
* - '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,
@@ -38,16 +42,17 @@ export function PrintModal({
const queryClient = useQueryClient();
const { showToast } = useToast();
// Single printer selection (for edit mode and backward compatibility)
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(() => {
if (mode === 'edit-queue-item' && queueItem) {
return queueItem.printer_id;
}
return null;
});
// Determine if we're printing a library file
const isLibraryFile = !!libraryFileId && !archiveId;
// Multiple printer selection (for reprint and add-to-queue modes)
const [selectedPrinters, setSelectedPrinters] = useState<number[]>([]);
// 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) {
@@ -109,20 +114,17 @@ export function PrintModal({
});
// Track initial values for clearing mappings on change (edit mode only)
const [initialPrinterId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.printer_id : null));
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 });
// Determine if we're in multi-printer mode
const isMultiPrinterMode = mode !== 'edit-queue-item';
const effectivePrinterCount = isMultiPrinterMode ? selectedPrinters.length : (selectedPrinter ? 1 : 0);
// 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 = isMultiPrinterMode
? (selectedPrinters.length > 0 ? selectedPrinters[0] : null)
: selectedPrinter;
const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;
// Queries
const { data: printers, isLoading: loadingPrinters } = useQuery({
@@ -130,17 +132,45 @@ export function PrintModal({
queryFn: api.getPrinters,
});
const { data: platesData } = useQuery({
// Fetch plates for archives
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
queryKey: ['archive-plates', archiveId],
queryFn: () => api.getArchivePlates(archiveId),
queryFn: () => api.getArchivePlates(archiveId!),
enabled: !!archiveId && !isLibraryFile,
retry: false,
});
const { data: filamentReqs } = useQuery({
queryKey: ['archive-filaments', archiveId, selectedPlate],
queryFn: () => api.getArchiveFilamentRequirements(archiveId, selectedPlate ?? undefined),
enabled: selectedPlate !== null || !platesData?.is_multi_plate,
// 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],
@@ -149,7 +179,7 @@ export function PrintModal({
});
// Get AMS mapping from hook (only when single printer selected)
const { amsMapping } = useFilamentMapping(filamentReqs, printerStatus, manualMappings);
const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings);
// Auto-select first plate for single-plate files
useEffect(() => {
@@ -158,8 +188,9 @@ export function PrintModal({
}
}, [platesData, selectedPlate]);
// Auto-select first printer when only one available (non-multi mode)
// 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) {
@@ -170,13 +201,15 @@ export function PrintModal({
// Clear manual mappings when printer or plate changes
useEffect(() => {
if (mode === 'edit-queue-item') {
if (selectedPrinter !== initialPrinterId || selectedPlate !== initialPlateId) {
// 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({});
}
} else {
setManualMappings({});
}
}, [mode, selectedPrinter, selectedPrinters, selectedPlate, initialPrinterId, initialPlateId]);
}, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
// Close on Escape key
useEffect(() => {
@@ -212,29 +245,7 @@ export function PrintModal({
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (mode === 'edit-queue-item') {
// Edit mode - single printer update
const data: PrintQueueItemUpdate = {
printer_id: selectedPrinter,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: amsMapping,
plate_id: selectedPlate,
...printOptions,
};
if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) {
data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString();
} else {
data.scheduled_time = null;
}
updateQueueMutation.mutate(data);
return;
}
// Multi-printer modes (reprint or add-to-queue)
// Validate printer selection
if (selectedPrinters.length === 0) {
showToast('Please select at least one printer', 'error');
return;
@@ -249,37 +260,60 @@ export function PrintModal({
errors: [],
};
// 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: amsMapping,
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 {
// Use the same AMS mapping for all printers (configured via UI based on first printer)
// This assumes all printers have the same filament configuration
if (mode === 'reprint') {
await api.reprintArchive(archiveId, printerId, {
plate_id: selectedPlate ?? undefined,
ams_mapping: amsMapping,
...printOptions,
});
} else {
// add-to-queue mode
const data: PrintQueueItemCreate = {
// Reprint mode - start print immediately
if (isLibraryFile) {
await api.printLibraryFile(libraryFileId!, printerId, {
ams_mapping: amsMapping,
...printOptions,
});
} else {
await api.reprintArchive(archiveId!, printerId, {
plate_id: selectedPlate ?? undefined,
ams_mapping: amsMapping,
...printOptions,
});
}
} else if (mode === 'edit-queue-item' && i === 0) {
// Edit mode - update the original queue item for the first printer
const updateData: PrintQueueItemUpdate = {
printer_id: printerId,
archive_id: archiveId,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: amsMapping,
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
: null,
...printOptions,
};
if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) {
data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString();
}
await addToQueueMutation.mutateAsync(data);
await updateQueueMutation.mutateAsync(updateData);
} else {
// Add-to-queue mode OR edit mode with additional printers
await addToQueueMutation.mutateAsync(getQueueData(printerId));
}
results.success++;
} catch (error) {
@@ -293,9 +327,9 @@ export function PrintModal({
// Show result toast
if (results.failed === 0) {
const action = mode === 'reprint' ? 'sent to' : 'queued for';
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
if (results.success === 1) {
showToast(`Print ${action} printer`);
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
} else {
showToast(`Print ${action} ${results.success} printers`);
}
@@ -315,27 +349,22 @@ export function PrintModal({
const canSubmit = useMemo(() => {
if (isPending) return false;
// For edit mode, printer can be null (unassigned)
if (mode === 'edit-queue-item') {
return (printers?.length ?? 0) > 0;
}
// For reprint and add-to-queue, need at least one selected printer
// Need at least one selected printer
if (selectedPrinters.length === 0) return false;
// For multi-plate files, need a selected plate
if (isMultiPlate && !selectedPlate) return false;
// For multi-plate archive files, need a selected plate (library files skip this)
if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
return true;
}, [mode, selectedPrinters.length, isMultiPlate, selectedPlate, isPending, printers]);
}, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
// Modal title and action button text based on mode
const getModalConfig = () => {
const printerCount = isMultiPrinterMode ? selectedPrinters.length : 1;
const printerCount = selectedPrinters.length;
if (mode === 'reprint') {
return {
title: 'Re-print',
title: isLibraryFile ? 'Print' : 'Re-print',
icon: Printer,
submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
submitIcon: Printer,
@@ -355,12 +384,15 @@ export function PrintModal({
: 'Adding...',
};
}
// edit-queue-item mode
return {
title: 'Edit Queue Item',
icon: Pencil,
submitText: 'Save Changes',
submitText: 'Save',
submitIcon: Pencil,
loadingText: 'Saving...',
loadingText: submitProgress.total > 1
? `Saving ${submitProgress.current}/${submitProgress.total}...`
: 'Saving...',
};
};
@@ -368,8 +400,13 @@ export function PrintModal({
const TitleIcon = modalConfig.icon;
const SubmitIcon = modalConfig.submitIcon;
// Show filament mapping only when single printer selected
const showFilamentMapping = effectivePrinterId && (isMultiPlate ? selectedPlate !== null : true);
// 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
@@ -401,8 +438,7 @@ export function PrintModal({
<p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
{mode === 'reprint' ? (
<>
Send <span className="text-white">{archiveName}</span> to{' '}
{isMultiPrinterMode ? 'printer(s)' : 'a printer'}
Send <span className="text-white">{archiveName}</span> to printer(s)
</>
) : (
<>
@@ -415,17 +451,15 @@ export function PrintModal({
{/* Printer selection */}
<PrinterSelector
printers={printers || []}
selectedPrinterId={selectedPrinter}
selectedPrinterIds={selectedPrinters}
onSelect={setSelectedPrinter}
onMultiSelect={setSelectedPrinters}
isLoading={loadingPrinters}
allowUnassigned={mode === 'edit-queue-item'}
allowMultiple={isMultiPrinterMode}
allowMultiple={true}
showInactive={mode === 'edit-queue-item'}
/>
{/* Multi-printer filament mapping note */}
{isMultiPrinterMode && selectedPrinters.length > 1 && (
{selectedPrinters.length > 1 && (
<div className="flex items-start gap-2 p-3 mb-2 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm">
<AlertCircle className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
<p className="text-blue-400">
@@ -442,13 +476,21 @@ export function PrintModal({
onSelect={setSelectedPlate}
/>
{/* Filament mapping - show when single printer selected and plate ready */}
{showFilamentMapping && (
{/* 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 - show when printer selected and filament requirements available */}
{showFilamentMapping && !archiveDataMissing && (
<FilamentMapping
printerId={effectivePrinterId!}
archiveId={archiveId}
selectedPlate={selectedPlate}
isMultiPlate={isMultiPlate}
filamentReqs={effectiveFilamentReqs}
manualMappings={manualMappings}
onManualMappingChange={setManualMappings}
/>
+28 -11
View File
@@ -10,13 +10,19 @@ 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 */
archiveId: number;
/** Archive display name */
/** 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;
@@ -103,13 +109,12 @@ export interface PlatesResponse {
*/
export interface PrinterSelectorProps {
printers: Printer[];
selectedPrinterId: number | null;
selectedPrinterIds?: number[];
onSelect: (printerId: number | null) => void;
onMultiSelect?: (printerIds: number[]) => void;
selectedPrinterIds: number[];
onMultiSelect: (printerIds: number[]) => void;
isLoading?: boolean;
allowUnassigned?: boolean;
allowMultiple?: boolean;
/** Show inactive printers (for edit mode where original assignment may be inactive) */
showInactive?: boolean;
}
/**
@@ -122,14 +127,26 @@ export interface PlateSelectorProps {
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;
archiveId: number;
selectedPlate: number | null;
isMultiPlate: boolean;
/** Pre-fetched filament requirements data */
filamentReqs: FilamentReqsData | undefined;
manualMappings: Record<number, number>;
onManualMappingChange: (mappings: Record<number, number>) => void;
}
+47 -11
View File
@@ -46,6 +46,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';
@@ -695,9 +696,10 @@ interface FileCardProps {
onDelete: (id: number) => void;
onDownload: (id: number) => void;
onAddToQueue?: (id: number) => void;
onPrint?: (file: LibraryFileListItem) => void;
}
function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue }: FileCardProps) {
function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint }: FileCardProps) {
const [showActions, setShowActions] = useState(false);
return (
@@ -771,12 +773,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>
)}
@@ -828,6 +839,7 @@ 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 [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
});
@@ -1416,6 +1428,7 @@ export function FileManagerPage() {
onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
onDownload={handleDownload}
onAddToQueue={(id) => addToQueueMutation.mutate([id])}
onPrint={setPrintFile}
/>
))}
</div>
@@ -1506,14 +1519,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)}
@@ -1598,6 +1620,20 @@ 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'] });
}}
/>
)}
</div>
);
}
+40 -18
View File
@@ -167,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"
/>
@@ -182,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">
@@ -472,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') {
@@ -490,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 {
@@ -803,8 +823,9 @@ export function QueuePage() {
{editItem && (
<PrintModal
mode="edit-queue-item"
archiveId={editItem.archive_id}
archiveName={editItem.archive_name || `Archive #${editItem.archive_id}`}
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)}
/>
@@ -814,8 +835,9 @@ export function QueuePage() {
{requeueItem && (
<PrintModal
mode="add-to-queue"
archiveId={requeueItem.archive_id}
archiveName={requeueItem.archive_name || `Archive #${requeueItem.archive_id}`}
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)}
/>
)}
@@ -830,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' :
+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
+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-DE9m9l_f.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-t4RhRNeD.css">
<script type="module" crossorigin src="/assets/index-BJM8SBBV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-5D76qqt8.css">
</head>
<body>
<div id="root"></div>