mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
Issue #162
This commit is contained in:
@@ -505,10 +505,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
---
|
||||
|
||||
If you like Bambuddy and want to support it, you can <a href="https://ko-fi.com/maziggy" target=_blank>buy Martin a coffee</a>.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License — see [LICENSE](LICENSE) for details.
|
||||
@@ -523,6 +519,10 @@ MIT License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
If you like Bambuddy and want to support it, you can <a href="https://ko-fi.com/maziggy" target=_blank>buy Martin a coffee</a>.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ for the 3D printing community
|
||||
<br><br>
|
||||
|
||||
@@ -78,6 +78,7 @@ def archive_to_response(
|
||||
"nozzle_diameter": archive.nozzle_diameter,
|
||||
"bed_temperature": archive.bed_temperature,
|
||||
"nozzle_temperature": archive.nozzle_temperature,
|
||||
"sliced_for_model": archive.sliced_for_model,
|
||||
"status": archive.status,
|
||||
"started_at": archive.started_at,
|
||||
"completed_at": archive.completed_at,
|
||||
|
||||
@@ -42,6 +42,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
||||
item_dict = {
|
||||
"id": item.id,
|
||||
"printer_id": item.printer_id,
|
||||
"target_model": item.target_model,
|
||||
"archive_id": item.archive_id,
|
||||
"library_file_id": item.library_file_id,
|
||||
"position": item.position,
|
||||
@@ -124,12 +125,24 @@ async def add_to_queue(
|
||||
if not data.archive_id and not data.library_file_id:
|
||||
raise HTTPException(400, "Either archive_id or library_file_id must be provided")
|
||||
|
||||
# Cannot specify both printer_id and target_model
|
||||
if data.printer_id and data.target_model:
|
||||
raise HTTPException(400, "Cannot specify both printer_id and target_model")
|
||||
|
||||
# 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 target_model has active printers
|
||||
if data.target_model:
|
||||
result = await db.execute(
|
||||
select(Printer).where(Printer.model == data.target_model).where(Printer.is_active == True) # noqa: E712
|
||||
)
|
||||
if not result.scalars().first():
|
||||
raise HTTPException(400, f"No active printers for model: {data.target_model}")
|
||||
|
||||
# Validate archive exists (if provided)
|
||||
if data.archive_id:
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
|
||||
@@ -142,7 +155,7 @@ async def add_to_queue(
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(400, "Library file not found")
|
||||
|
||||
# Get next position for this printer (or for unassigned items)
|
||||
# Get next position for this printer (or for unassigned/model-based items)
|
||||
if data.printer_id is not None:
|
||||
result = await db.execute(
|
||||
select(func.max(PrintQueueItem.position))
|
||||
@@ -150,7 +163,7 @@ async def add_to_queue(
|
||||
.where(PrintQueueItem.status == "pending")
|
||||
)
|
||||
else:
|
||||
# For unassigned items, get max position across all unassigned
|
||||
# For unassigned/model-based items, get max position across all unassigned
|
||||
result = await db.execute(
|
||||
select(func.max(PrintQueueItem.position))
|
||||
.where(PrintQueueItem.printer_id.is_(None))
|
||||
@@ -160,6 +173,7 @@ async def add_to_queue(
|
||||
|
||||
item = PrintQueueItem(
|
||||
printer_id=data.printer_id,
|
||||
target_model=data.target_model,
|
||||
archive_id=data.archive_id,
|
||||
library_file_id=data.library_file_id,
|
||||
scheduled_time=data.scheduled_time,
|
||||
@@ -185,7 +199,8 @@ async def add_to_queue(
|
||||
await db.refresh(item, ["archive", "printer", "library_file"])
|
||||
|
||||
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'}")
|
||||
target_desc = data.printer_id or (f"model {data.target_model}" if data.target_model else "unassigned")
|
||||
logger.info(f"Added {source_name} to queue for {target_desc}")
|
||||
|
||||
# MQTT relay - publish queue job added
|
||||
try:
|
||||
@@ -287,12 +302,26 @@ async def update_queue_item(
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Cannot specify both printer_id and target_model
|
||||
new_printer_id = update_data.get("printer_id", item.printer_id)
|
||||
new_target_model = update_data.get("target_model", item.target_model)
|
||||
if new_printer_id and new_target_model:
|
||||
raise HTTPException(400, "Cannot specify both printer_id and target_model")
|
||||
|
||||
# Validate new printer_id if being changed (and not None)
|
||||
if "printer_id" in update_data and update_data["printer_id"] is not None:
|
||||
result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(400, "Printer not found")
|
||||
|
||||
# Validate target_model has active printers
|
||||
if "target_model" in update_data and update_data["target_model"]:
|
||||
result = await db.execute(
|
||||
select(Printer).where(Printer.model == update_data["target_model"]).where(Printer.is_active == True) # noqa: E712
|
||||
)
|
||||
if not result.scalars().first():
|
||||
raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
|
||||
|
||||
# Serialize ams_mapping to JSON for TEXT column storage
|
||||
if "ams_mapping" in update_data:
|
||||
update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
|
||||
|
||||
@@ -760,6 +760,18 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add sliced_for_model column to print_archives for printer model from 3MF
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add target_model column to print_queue for model-based assignment
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
|
||||
@@ -35,6 +35,9 @@ class PrintArchive(Base):
|
||||
bed_temperature: Mapped[int | None] = mapped_column(Integer)
|
||||
nozzle_temperature: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
# Printer model this file was sliced for (extracted from 3MF metadata)
|
||||
sliced_for_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Print result
|
||||
status: Mapped[str] = mapped_column(String(20), default="completed")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
@@ -15,6 +15,9 @@ class PrintQueueItem(Base):
|
||||
|
||||
# Links
|
||||
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), nullable=True)
|
||||
# Target printer model for model-based assignment (mutually exclusive with printer_id)
|
||||
# When set, scheduler assigns to any idle printer of matching model
|
||||
target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
# 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(
|
||||
|
||||
@@ -63,6 +63,8 @@ class ArchiveResponse(BaseModel):
|
||||
bed_temperature: int | None
|
||||
nozzle_temperature: int | None
|
||||
|
||||
sliced_for_model: str | None = None # Printer model this file was sliced for
|
||||
|
||||
status: str
|
||||
started_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
|
||||
@@ -17,6 +17,7 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
|
||||
|
||||
class PrintQueueItemCreate(BaseModel):
|
||||
printer_id: int | None = None # None = unassigned, user assigns later
|
||||
target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
|
||||
# Either archive_id OR library_file_id must be provided
|
||||
archive_id: int | None = None
|
||||
library_file_id: int | None = None
|
||||
@@ -40,6 +41,7 @@ class PrintQueueItemCreate(BaseModel):
|
||||
|
||||
class PrintQueueItemUpdate(BaseModel):
|
||||
printer_id: int | None = None
|
||||
target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
|
||||
position: int | None = None
|
||||
scheduled_time: datetime | None = None
|
||||
require_previous_success: bool | None = None
|
||||
@@ -59,6 +61,7 @@ class PrintQueueItemUpdate(BaseModel):
|
||||
class PrintQueueItemResponse(BaseModel):
|
||||
id: int
|
||||
printer_id: int | None # None = unassigned
|
||||
target_model: str | None = None # Target printer model for model-based assignment
|
||||
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
|
||||
|
||||
@@ -67,6 +67,19 @@ class ThreeMFParser:
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
# Extract printer_model_id from plate metadata
|
||||
# Format: <plate><metadata key="printer_model_id" value="C11" /></plate>
|
||||
for meta in root.findall(".//metadata"):
|
||||
key = meta.get("key")
|
||||
value = meta.get("value")
|
||||
if key == "printer_model_id" and value:
|
||||
from backend.app.utils.printer_models import normalize_printer_model_id
|
||||
|
||||
normalized = normalize_printer_model_id(value)
|
||||
if normalized:
|
||||
self.metadata["sliced_for_model"] = normalized
|
||||
break
|
||||
|
||||
# Find the plate element (single-plate exports only have one plate)
|
||||
plate = root.find(".//plate")
|
||||
|
||||
@@ -156,7 +169,7 @@ class ThreeMFParser:
|
||||
pass
|
||||
|
||||
def _parse_gcode_header(self, zf: zipfile.ZipFile):
|
||||
"""Parse G-code file header for total layer count."""
|
||||
"""Parse G-code file header for total layer count and printer model."""
|
||||
import re
|
||||
|
||||
try:
|
||||
@@ -165,15 +178,25 @@ class ThreeMFParser:
|
||||
if not gcode_files:
|
||||
return
|
||||
|
||||
# Read first 2KB of G-code (header contains the layer count)
|
||||
# Read first 4KB of G-code (header contains metadata)
|
||||
gcode_path = gcode_files[0]
|
||||
with zf.open(gcode_path) as f:
|
||||
header = f.read(2048).decode("utf-8", errors="ignore")
|
||||
header = f.read(4096).decode("utf-8", errors="ignore")
|
||||
|
||||
# Look for "; total layer number: XX" pattern
|
||||
match = re.search(r";\s*total\s+layer\s+number[:\s]+(\d+)", header, re.IGNORECASE)
|
||||
if match:
|
||||
self.metadata["total_layers"] = int(match.group(1))
|
||||
|
||||
# Look for printer_model in gcode header (fallback if not found in slice_info)
|
||||
# Format: "; printer_model = Bambu Lab X1 Carbon" or "; printer_model = X1C"
|
||||
if "sliced_for_model" not in self.metadata:
|
||||
match = re.search(r";\s*printer_model\s*=\s*(.+)", header, re.IGNORECASE)
|
||||
if match:
|
||||
from backend.app.utils.printer_models import normalize_printer_model
|
||||
|
||||
raw_model = match.group(1).strip()
|
||||
self.metadata["sliced_for_model"] = normalize_printer_model(raw_model)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -256,6 +279,12 @@ class ThreeMFParser:
|
||||
elif isinstance(val, (int, float, str)):
|
||||
self.metadata["nozzle_temperature"] = int(float(val))
|
||||
break
|
||||
|
||||
# Printer model (extract and normalize)
|
||||
if "printer_model" in data:
|
||||
from backend.app.utils.printer_models import normalize_printer_model
|
||||
|
||||
self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -877,6 +906,7 @@ class ArchiveService:
|
||||
nozzle_diameter=metadata.get("nozzle_diameter"),
|
||||
bed_temperature=metadata.get("bed_temperature"),
|
||||
nozzle_temperature=metadata.get("nozzle_temperature"),
|
||||
sliced_for_model=metadata.get("sliced_for_model"),
|
||||
makerworld_url=metadata.get("makerworld_url"),
|
||||
designer=metadata.get("designer"),
|
||||
status=status,
|
||||
|
||||
@@ -62,13 +62,10 @@ class PrintScheduler:
|
||||
if not items:
|
||||
return
|
||||
|
||||
# Group by printer - only process first item per printer
|
||||
processed_printers = set()
|
||||
# Track busy printers to avoid assigning multiple items to same printer
|
||||
busy_printers: set[int] = set()
|
||||
|
||||
for item in items:
|
||||
if item.printer_id in processed_printers:
|
||||
continue
|
||||
|
||||
# Check scheduled time first (scheduled_time is stored in UTC from ISO string)
|
||||
if item.scheduled_time and item.scheduled_time > datetime.utcnow():
|
||||
continue
|
||||
@@ -77,46 +74,91 @@ class PrintScheduler:
|
||||
if item.manual_start:
|
||||
continue
|
||||
|
||||
# Check if printer is idle
|
||||
printer_idle = self._is_printer_idle(item.printer_id)
|
||||
printer_connected = printer_manager.is_connected(item.printer_id)
|
||||
if item.printer_id:
|
||||
# Specific printer assignment (existing behavior)
|
||||
if item.printer_id in busy_printers:
|
||||
continue
|
||||
|
||||
# If printer not connected, try to power on via smart plug
|
||||
if not printer_connected:
|
||||
plug = await self._get_smart_plug(db, item.printer_id)
|
||||
if plug and plug.auto_on and plug.enabled:
|
||||
logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug")
|
||||
powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
|
||||
if powered_on:
|
||||
printer_connected = True
|
||||
printer_idle = self._is_printer_idle(item.printer_id)
|
||||
# Check if printer is idle
|
||||
printer_idle = self._is_printer_idle(item.printer_id)
|
||||
printer_connected = printer_manager.is_connected(item.printer_id)
|
||||
|
||||
# If printer not connected, try to power on via smart plug
|
||||
if not printer_connected:
|
||||
plug = await self._get_smart_plug(db, item.printer_id)
|
||||
if plug and plug.auto_on and plug.enabled:
|
||||
logger.info(f"Printer {item.printer_id} offline, attempting to power on via smart plug")
|
||||
powered_on = await self._power_on_and_wait(plug, item.printer_id, db)
|
||||
if powered_on:
|
||||
printer_connected = True
|
||||
printer_idle = self._is_printer_idle(item.printer_id)
|
||||
else:
|
||||
logger.warning(f"Could not power on printer {item.printer_id} via smart plug")
|
||||
busy_printers.add(item.printer_id)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"Could not power on printer {item.printer_id} via smart plug")
|
||||
processed_printers.add(item.printer_id)
|
||||
# No plug or auto_on disabled
|
||||
busy_printers.add(item.printer_id)
|
||||
continue
|
||||
else:
|
||||
# No plug or auto_on disabled
|
||||
processed_printers.add(item.printer_id)
|
||||
|
||||
# Check if printer is idle (busy with another print)
|
||||
if not printer_idle:
|
||||
busy_printers.add(item.printer_id)
|
||||
continue
|
||||
|
||||
# Check if printer is idle (busy with another print)
|
||||
if not printer_idle:
|
||||
processed_printers.add(item.printer_id)
|
||||
continue
|
||||
# Check condition (previous print success)
|
||||
if item.require_previous_success:
|
||||
if not await self._check_previous_success(db, item):
|
||||
item.status = "skipped"
|
||||
item.error_message = "Previous print failed or was aborted"
|
||||
item.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
logger.info(f"Skipped queue item {item.id} - previous print failed")
|
||||
continue
|
||||
|
||||
# Check condition (previous print success)
|
||||
if item.require_previous_success:
|
||||
if not await self._check_previous_success(db, item):
|
||||
item.status = "skipped"
|
||||
item.error_message = "Previous print failed or was aborted"
|
||||
item.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
logger.info(f"Skipped queue item {item.id} - previous print failed")
|
||||
continue
|
||||
# Start the print
|
||||
await self._start_print(db, item)
|
||||
busy_printers.add(item.printer_id)
|
||||
|
||||
# Start the print
|
||||
await self._start_print(db, item)
|
||||
processed_printers.add(item.printer_id)
|
||||
elif item.target_model:
|
||||
# Model-based assignment - find any idle printer of matching model
|
||||
printer_id = await self._find_idle_printer_for_model(db, item.target_model, busy_printers)
|
||||
if printer_id:
|
||||
# Check condition (previous print success) before assigning
|
||||
if item.require_previous_success:
|
||||
if not await self._check_previous_success(db, item):
|
||||
item.status = "skipped"
|
||||
item.error_message = "Previous print failed or was aborted"
|
||||
item.completed_at = datetime.now()
|
||||
await db.commit()
|
||||
logger.info(f"Skipped queue item {item.id} - previous print failed")
|
||||
continue
|
||||
|
||||
# Assign printer and start
|
||||
item.printer_id = printer_id
|
||||
logger.info(f"Model-based assignment: queue item {item.id} assigned to printer {printer_id}")
|
||||
await self._start_print(db, item)
|
||||
busy_printers.add(printer_id)
|
||||
|
||||
async def _find_idle_printer_for_model(self, db: AsyncSession, model: str, exclude_ids: set[int]) -> int | None:
|
||||
"""Find an idle, connected printer matching the model.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
model: Printer model to match (e.g., "X1C", "P1S")
|
||||
exclude_ids: Printer IDs to exclude (already busy)
|
||||
|
||||
Returns:
|
||||
Printer ID if found, None otherwise
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Printer).where(Printer.model == model).where(Printer.is_active == True) # noqa: E712
|
||||
)
|
||||
for printer in result.scalars().all():
|
||||
if printer.id not in exclude_ids:
|
||||
if self._is_printer_idle(printer.id) and printer_manager.is_connected(printer.id):
|
||||
return printer.id
|
||||
return None
|
||||
|
||||
def _is_printer_idle(self, printer_id: int) -> bool:
|
||||
"""Check if a printer is connected and idle."""
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Printer model normalization utilities.
|
||||
|
||||
Converts 3MF printer model names (e.g., "Bambu Lab X1 Carbon") to
|
||||
normalized short names (e.g., "X1C") that match database storage.
|
||||
"""
|
||||
|
||||
# Map from 3MF printer_model strings to normalized short names
|
||||
PRINTER_MODEL_MAP = {
|
||||
"Bambu Lab X1 Carbon": "X1C",
|
||||
"Bambu Lab X1": "X1",
|
||||
"Bambu Lab X1E": "X1E",
|
||||
"Bambu Lab P1S": "P1S",
|
||||
"Bambu Lab P1P": "P1P",
|
||||
"Bambu Lab P2S": "P2S",
|
||||
"Bambu Lab A1": "A1",
|
||||
"Bambu Lab A1 Mini": "A1 Mini",
|
||||
"Bambu Lab A1 mini": "A1 Mini",
|
||||
"Bambu Lab H2D": "H2D",
|
||||
"Bambu Lab H2D Pro": "H2D Pro",
|
||||
}
|
||||
|
||||
# Map from printer_model_id (internal codes in slice_info.config) to short names
|
||||
# These are the codes Bambu Studio uses internally
|
||||
PRINTER_MODEL_ID_MAP = {
|
||||
# X1 series
|
||||
"C11": "X1C",
|
||||
"C12": "X1",
|
||||
"C13": "X1E",
|
||||
# P1 series
|
||||
"P1P": "P1P",
|
||||
"P1S": "P1S",
|
||||
# P2 series
|
||||
"P2S": "P2S",
|
||||
# A1 series
|
||||
"A11": "A1",
|
||||
"A12": "A1 Mini",
|
||||
"N1": "A1",
|
||||
"N2S": "A1 Mini",
|
||||
"A04": "A1 Mini",
|
||||
# H2D series (Office/H series)
|
||||
"O1D": "H2D",
|
||||
"O2D": "H2D Pro",
|
||||
}
|
||||
|
||||
|
||||
def normalize_printer_model_id(model_id: str | None) -> str | None:
|
||||
"""Convert printer_model_id (internal code) to normalized short name.
|
||||
|
||||
Args:
|
||||
model_id: The printer_model_id from slice_info.config (e.g., "C11", "O1D")
|
||||
|
||||
Returns:
|
||||
Normalized short name (e.g., "X1C", "H2D") or the original ID if unknown.
|
||||
"""
|
||||
if not model_id:
|
||||
return None
|
||||
|
||||
# Check known mappings
|
||||
if model_id in PRINTER_MODEL_ID_MAP:
|
||||
return PRINTER_MODEL_ID_MAP[model_id]
|
||||
|
||||
# Return original if unknown (might already be a short name)
|
||||
return model_id
|
||||
|
||||
|
||||
def normalize_printer_model(raw_model: str | None) -> str | None:
|
||||
"""Convert 3MF printer_model to normalized short name.
|
||||
|
||||
Args:
|
||||
raw_model: The printer_model string from 3MF metadata
|
||||
(e.g., "Bambu Lab X1 Carbon")
|
||||
|
||||
Returns:
|
||||
Normalized short name (e.g., "X1C") or None if input is empty.
|
||||
Unknown models have "Bambu Lab " prefix stripped.
|
||||
"""
|
||||
if not raw_model:
|
||||
return None
|
||||
|
||||
# Check known mappings first
|
||||
if raw_model in PRINTER_MODEL_MAP:
|
||||
return PRINTER_MODEL_MAP[raw_model]
|
||||
|
||||
# Strip "Bambu Lab " prefix for unknown models
|
||||
stripped = raw_model.replace("Bambu Lab ", "").strip()
|
||||
return stripped or None
|
||||
@@ -300,6 +300,7 @@ export interface Archive {
|
||||
nozzle_diameter: number | null;
|
||||
bed_temperature: number | null;
|
||||
nozzle_temperature: number | null;
|
||||
sliced_for_model: string | null; // Printer model this file was sliced for
|
||||
status: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
@@ -1000,6 +1001,7 @@ export interface DiscoveredTasmotaDevice {
|
||||
export interface PrintQueueItem {
|
||||
id: number;
|
||||
printer_id: number | null; // null = unassigned
|
||||
target_model: string | null; // Target printer model for model-based assignment
|
||||
// Either archive_id OR library_file_id must be set (archive created at print start)
|
||||
archive_id: number | null;
|
||||
library_file_id: number | null;
|
||||
@@ -1032,6 +1034,7 @@ export interface PrintQueueItem {
|
||||
|
||||
export interface PrintQueueItemCreate {
|
||||
printer_id?: number | null; // null = unassigned
|
||||
target_model?: string | null; // Target printer model (mutually exclusive with printer_id)
|
||||
// Either archive_id OR library_file_id must be provided
|
||||
archive_id?: number | null;
|
||||
library_file_id?: number | null;
|
||||
@@ -1052,6 +1055,7 @@ export interface PrintQueueItemCreate {
|
||||
|
||||
export interface PrintQueueItemUpdate {
|
||||
printer_id?: number | null; // null = unassign
|
||||
target_model?: string | null; // Target printer model (mutually exclusive with printer_id)
|
||||
position?: number;
|
||||
scheduled_time?: string | null;
|
||||
require_previous_success?: boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Printer as PrinterIcon,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Circle,
|
||||
RefreshCw,
|
||||
Wand2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import { getColorName } from '../../utils/colors';
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
normalizeColorForCompare,
|
||||
colorsAreSimilar,
|
||||
} from '../../utils/amsHelpers';
|
||||
import type { PrinterSelectorProps } from './types';
|
||||
import type { PrinterSelectorProps, AssignmentMode } from './types';
|
||||
import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
|
||||
import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
|
||||
|
||||
@@ -29,6 +30,16 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
|
||||
onAutoConfigurePrinter?: (printerId: number) => void;
|
||||
/** Callback to update printer config */
|
||||
onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
|
||||
/** Current assignment mode */
|
||||
assignmentMode?: AssignmentMode;
|
||||
/** Handler for assignment mode change */
|
||||
onAssignmentModeChange?: (mode: AssignmentMode) => void;
|
||||
/** Selected target model (when assignmentMode is 'model') */
|
||||
targetModel?: string | null;
|
||||
/** Handler for target model change */
|
||||
onTargetModelChange?: (model: string | null) => void;
|
||||
/** Suggested model from sliced file (for pre-selection) */
|
||||
slicedForModel?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,9 +223,42 @@ export function PrinterSelector({
|
||||
filamentReqs,
|
||||
onAutoConfigurePrinter,
|
||||
onUpdatePrinterConfig,
|
||||
assignmentMode = 'printer',
|
||||
onAssignmentModeChange,
|
||||
targetModel,
|
||||
onTargetModelChange,
|
||||
slicedForModel,
|
||||
}: PrinterSelectorWithMappingProps) {
|
||||
// State for showing all printers vs only matching model
|
||||
const [showAllPrinters, setShowAllPrinters] = useState(false);
|
||||
|
||||
// Filter printers based on showInactive flag
|
||||
const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
|
||||
const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active);
|
||||
|
||||
// Filter by sliced model (only in printer mode, when slicedForModel is set)
|
||||
const displayPrinters = useMemo(() => {
|
||||
if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) {
|
||||
return activePrinters;
|
||||
}
|
||||
// Filter to only show printers matching the sliced model
|
||||
const matching = activePrinters.filter((p) => p.model === slicedForModel);
|
||||
// If no matching printers, show all
|
||||
return matching.length > 0 ? matching : activePrinters;
|
||||
}, [activePrinters, assignmentMode, slicedForModel, showAllPrinters]);
|
||||
|
||||
// Check if there are hidden printers due to model filtering
|
||||
const hiddenPrinterCount = activePrinters.length - displayPrinters.length;
|
||||
|
||||
// Get unique models from available printers (for model-based assignment)
|
||||
const uniqueModels = useMemo(() => {
|
||||
const models = activePrinters
|
||||
.map(p => p.model)
|
||||
.filter((m): m is string => Boolean(m));
|
||||
return [...new Set(models)].sort();
|
||||
}, [activePrinters]);
|
||||
|
||||
// Check if model-based assignment is available (need callbacks and multiple printers of same model)
|
||||
const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
|
||||
|
||||
const showMappingOptions = allowMultiple &&
|
||||
selectedPrinterIds.length > 1 &&
|
||||
@@ -285,8 +329,56 @@ export function PrinterSelector({
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mb-6">
|
||||
{/* Multi-select header */}
|
||||
{allowMultiple && displayPrinters.length > 1 && (
|
||||
{/* Assignment mode toggle (model vs specific printer) */}
|
||||
{modelAssignmentAvailable && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAssignmentModeChange!('printer');
|
||||
onTargetModelChange!(null);
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
|
||||
assignmentMode === 'printer'
|
||||
? 'border-bambu-green bg-bambu-green/10 text-white'
|
||||
: 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<PrinterIcon className="w-4 h-4" />
|
||||
<span className="text-sm">Specific Printer</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAssignmentModeChange!('model');
|
||||
onMultiSelect([]);
|
||||
// Pre-select the sliced-for model if available, otherwise first model
|
||||
const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
|
||||
? slicedForModel
|
||||
: uniqueModels[0];
|
||||
onTargetModelChange!(defaultModel);
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
|
||||
assignmentMode === 'model'
|
||||
? 'border-bambu-green bg-bambu-green/10 text-white'
|
||||
: 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-sm">Any {slicedForModel || 'Model'}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model info (when in model mode) */}
|
||||
{assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
|
||||
<p className="text-xs text-bambu-gray mb-4">
|
||||
Scheduler will assign to first available idle {targetModel} printer
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Multi-select header (only in printer mode) */}
|
||||
{assignmentMode === 'printer' && allowMultiple && displayPrinters.length > 1 && (
|
||||
<div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
|
||||
<span>
|
||||
{selectedCount === 0
|
||||
@@ -316,7 +408,8 @@ export function PrinterSelector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayPrinters.map((printer) => {
|
||||
{/* Printer list (only in printer mode) */}
|
||||
{assignmentMode === 'printer' && displayPrinters.map((printer) => {
|
||||
const selected = isSelected(printer.id);
|
||||
const mappingResult = getPrinterMappingResult(printer.id);
|
||||
const hasOverride = mappingResult && !mappingResult.config.useDefault;
|
||||
@@ -430,13 +523,45 @@ export function PrinterSelector({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Warning when no printer selected */}
|
||||
{selectedCount === 0 && (
|
||||
{/* Show hidden printers toggle */}
|
||||
{assignmentMode === 'printer' && hiddenPrinterCount > 0 && !showAllPrinters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllPrinters(true)}
|
||||
className="text-xs text-bambu-gray hover:text-white transition-colors mt-2 flex items-center gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-yellow-400" />
|
||||
{hiddenPrinterCount} other printer{hiddenPrinterCount > 1 ? 's' : ''} hidden (different model) —
|
||||
<span className="underline">show all</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show matching only toggle */}
|
||||
{assignmentMode === 'printer' && showAllPrinters && slicedForModel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllPrinters(false)}
|
||||
className="text-xs text-bambu-gray hover:text-white transition-colors mt-2"
|
||||
>
|
||||
<span className="underline">Show only {slicedForModel} printers</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Warning when no printer selected (only in printer mode) */}
|
||||
{assignmentMode === 'printer' && 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>
|
||||
)}
|
||||
|
||||
{/* Warning when no model selected (only in model mode) */}
|
||||
{assignmentMode === 'model' && !targetModel && (
|
||||
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Select a target printer model
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
|
||||
import { Card, CardContent } from '../Card';
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
PrintOptions,
|
||||
ScheduleOptions,
|
||||
ScheduleType,
|
||||
AssignmentMode,
|
||||
} from './types';
|
||||
import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
|
||||
|
||||
@@ -117,6 +118,23 @@ export function PrintModal({
|
||||
// Per-printer override configs (for multi-printer selection)
|
||||
const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
|
||||
|
||||
// Assignment mode: 'printer' (specific) or 'model' (any of model)
|
||||
const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
|
||||
// Initialize from queue item if editing with target_model
|
||||
if (mode === 'edit-queue-item' && queueItem?.target_model) {
|
||||
return 'model';
|
||||
}
|
||||
return 'printer';
|
||||
});
|
||||
|
||||
// Target model for model-based assignment
|
||||
const [targetModel, setTargetModel] = useState<string | null>(() => {
|
||||
if (mode === 'edit-queue-item' && queueItem?.target_model) {
|
||||
return queueItem.target_model;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// 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));
|
||||
@@ -145,6 +163,16 @@ export function PrintModal({
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
// Fetch archive details to get sliced_for_model
|
||||
const { data: archiveDetails } = useQuery({
|
||||
queryKey: ['archive', archiveId],
|
||||
queryFn: () => api.getArchive(archiveId!),
|
||||
enabled: !!archiveId && !isLibraryFile,
|
||||
});
|
||||
|
||||
// Get sliced_for_model from archive or library file
|
||||
const slicedForModel = archiveDetails?.sliced_for_model || null;
|
||||
|
||||
// Fetch plates for archives
|
||||
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
|
||||
queryKey: ['archive-plates', archiveId],
|
||||
@@ -304,14 +332,20 @@ export function PrintModal({
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
|
||||
// Validate printer selection
|
||||
if (selectedPrinters.length === 0) {
|
||||
// Validate printer/model selection
|
||||
if (assignmentMode === 'printer' && selectedPrinters.length === 0) {
|
||||
showToast('Please select at least one printer', 'error');
|
||||
return;
|
||||
}
|
||||
if (assignmentMode === 'model' && !targetModel) {
|
||||
showToast('Please select a target printer model', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitProgress({ current: 0, total: selectedPrinters.length });
|
||||
// For model-based assignment, we just make one API call
|
||||
const totalCount = assignmentMode === 'model' ? 1 : selectedPrinters.length;
|
||||
setSubmitProgress({ current: 0, total: totalCount });
|
||||
|
||||
const results: { success: number; failed: number; errors: string[] } = {
|
||||
success: 0,
|
||||
@@ -332,15 +366,16 @@ export function PrintModal({
|
||||
};
|
||||
|
||||
// Common queue data for add-to-queue and edit modes
|
||||
const getQueueData = (printerId: number): PrintQueueItemCreate => ({
|
||||
printer_id: printerId,
|
||||
const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
|
||||
printer_id: assignmentMode === 'printer' ? printerId : null,
|
||||
target_model: assignmentMode === 'model' ? targetModel : null,
|
||||
// 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),
|
||||
ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
|
||||
plate_id: selectedPlate,
|
||||
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
||||
? new Date(scheduleOptions.scheduledTime).toISOString()
|
||||
@@ -348,35 +383,24 @@ export function PrintModal({
|
||||
...printOptions,
|
||||
});
|
||||
|
||||
for (let i = 0; i < selectedPrinters.length; i++) {
|
||||
const printerId = selectedPrinters[i];
|
||||
setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
|
||||
|
||||
// Model-based assignment: single API call
|
||||
if (assignmentMode === 'model') {
|
||||
setSubmitProgress({ current: 1, total: 1 });
|
||||
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);
|
||||
// Model-based reprint not supported (need specific printer for immediate print)
|
||||
showToast('Model-based assignment only works with queue mode', 'error');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
} else if (mode === 'edit-queue-item') {
|
||||
// Edit mode - update with target_model
|
||||
const updateData: PrintQueueItemUpdate = {
|
||||
printer_id: printerId,
|
||||
printer_id: null,
|
||||
target_model: targetModel,
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
manual_start: scheduleOptions.scheduleType === 'manual',
|
||||
ams_mapping: printerMapping,
|
||||
ams_mapping: undefined,
|
||||
plate_id: selectedPlate,
|
||||
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
||||
? new Date(scheduleOptions.scheduledTime).toISOString()
|
||||
@@ -385,14 +409,63 @@ export function PrintModal({
|
||||
};
|
||||
await updateQueueMutation.mutateAsync(updateData);
|
||||
} else {
|
||||
// Add-to-queue mode OR edit mode with additional printers
|
||||
await addToQueueMutation.mutateAsync(getQueueData(printerId));
|
||||
// Add-to-queue mode with model-based assignment
|
||||
await addToQueueMutation.mutateAsync(getQueueData(null));
|
||||
}
|
||||
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}`);
|
||||
results.errors.push((error as Error).message);
|
||||
}
|
||||
} else {
|
||||
// Printer-based assignment: loop through selected printers
|
||||
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,
|
||||
target_model: null,
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,11 +473,15 @@ export function PrintModal({
|
||||
|
||||
// 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`);
|
||||
if (assignmentMode === 'model') {
|
||||
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
|
||||
} else {
|
||||
showToast(`Print ${action} ${results.success} printers`);
|
||||
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?.();
|
||||
@@ -422,14 +499,18 @@ export function PrintModal({
|
||||
const canSubmit = useMemo(() => {
|
||||
if (isPending) return false;
|
||||
|
||||
// Need at least one selected printer
|
||||
if (selectedPrinters.length === 0) return false;
|
||||
// Need valid printer/model selection
|
||||
if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
|
||||
if (assignmentMode === 'model' && !targetModel) return false;
|
||||
|
||||
// Model-based assignment only works in queue modes (not immediate reprint)
|
||||
if (assignmentMode === 'model' && mode === 'reprint') 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]);
|
||||
}, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
|
||||
|
||||
// Modal title and action button text based on mode
|
||||
const getModalConfig = () => {
|
||||
@@ -541,8 +622,29 @@ export function PrintModal({
|
||||
filamentReqs={effectiveFilamentReqs}
|
||||
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
|
||||
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
|
||||
assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
|
||||
onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
|
||||
targetModel={targetModel}
|
||||
onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
|
||||
slicedForModel={slicedForModel}
|
||||
/>
|
||||
|
||||
{/* Compatibility warning when sliced model doesn't match selected printer */}
|
||||
{slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
|
||||
const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
|
||||
if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
|
||||
return (
|
||||
<div className="p-3 mb-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
|
||||
<span className="text-sm text-yellow-400">
|
||||
File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* 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">
|
||||
@@ -565,7 +667,7 @@ export function PrintModal({
|
||||
)}
|
||||
|
||||
{/* Print options */}
|
||||
{(mode === 'reprint' || effectivePrinterCount > 0) && (
|
||||
{(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
|
||||
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -104,6 +104,13 @@ export interface PlatesResponse {
|
||||
plates: PlateInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assignment mode for queue items.
|
||||
* - 'printer': Assign to specific printer(s)
|
||||
* - 'model': Assign to any printer of a specific model (load balancing)
|
||||
*/
|
||||
export type AssignmentMode = 'printer' | 'model';
|
||||
|
||||
/**
|
||||
* Props for the PrinterSelector component.
|
||||
*/
|
||||
@@ -115,6 +122,16 @@ export interface PrinterSelectorProps {
|
||||
allowMultiple?: boolean;
|
||||
/** Show inactive printers (for edit mode where original assignment may be inactive) */
|
||||
showInactive?: boolean;
|
||||
/** Current assignment mode */
|
||||
assignmentMode?: AssignmentMode;
|
||||
/** Handler for assignment mode change */
|
||||
onAssignmentModeChange?: (mode: AssignmentMode) => void;
|
||||
/** Selected target model (when assignmentMode is 'model') */
|
||||
targetModel?: string | null;
|
||||
/** Handler for target model change */
|
||||
onTargetModelChange?: (model: string | null) => void;
|
||||
/** Suggested model from sliced file (for pre-selection) */
|
||||
slicedForModel?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { BulkUploadResult } from '../api/client';
|
||||
@@ -27,7 +27,6 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
|
||||
const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
|
||||
|
||||
// Close on Escape key
|
||||
@@ -39,14 +38,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const { data: printers } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (filesToUpload: File[]) =>
|
||||
api.uploadArchivesBulk(filesToUpload, selectedPrinter),
|
||||
api.uploadArchivesBulk(filesToUpload),
|
||||
onSuccess: (result) => {
|
||||
setUploadResult(result);
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
@@ -200,26 +194,11 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional Printer Selection */}
|
||||
{/* Info about printer model extraction */}
|
||||
<div className="px-4 pb-4">
|
||||
<label className="block text-sm text-bambu-gray mb-2">
|
||||
Associate with printer (optional)
|
||||
</label>
|
||||
<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={selectedPrinter || ''}
|
||||
onChange={(e) =>
|
||||
setSelectedPrinter(e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<option value="">No printer</option>
|
||||
{printers?.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
The printer model will be automatically extracted from the 3MF file metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
|
||||
@@ -716,6 +716,12 @@ function ArchiveCard({
|
||||
{archive.object_count} object{archive.object_count > 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
{archive.sliced_for_model && (
|
||||
<div className="flex items-center gap-1.5 text-bambu-gray" title={`Sliced for ${archive.sliced_for_model}`}>
|
||||
<Printer className="w-3 h-3" />
|
||||
{archive.sliced_for_model}
|
||||
</div>
|
||||
)}
|
||||
{archive.filament_type && (
|
||||
<div className="flex items-center gap-1.5 col-span-2">
|
||||
<span className="text-bambu-gray text-xs">{archive.filament_type}</span>
|
||||
@@ -1531,9 +1537,20 @@ function ArchiveListRow({
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{archive.filament_type && (
|
||||
{(archive.filament_type || archive.sliced_for_model) && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
|
||||
{archive.sliced_for_model && (
|
||||
<span className="text-xs text-bambu-gray flex items-center gap-1" title={`Sliced for ${archive.sliced_for_model}`}>
|
||||
<Printer className="w-2.5 h-2.5" />
|
||||
{archive.sliced_for_model}
|
||||
</span>
|
||||
)}
|
||||
{archive.sliced_for_model && archive.filament_type && (
|
||||
<span className="text-bambu-gray/50">·</span>
|
||||
)}
|
||||
{archive.filament_type && (
|
||||
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
|
||||
)}
|
||||
{archive.filament_color && (
|
||||
<div className="flex items-center gap-0.5 flex-wrap">
|
||||
{archive.filament_color.split(',').map((color, i) => (
|
||||
@@ -2674,7 +2691,7 @@ export function ArchivesPage() {
|
||||
<ArchiveCard
|
||||
key={archive.id}
|
||||
archive={archive}
|
||||
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
|
||||
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
|
||||
isSelected={selectedIds.has(archive.id)}
|
||||
onSelect={toggleSelect}
|
||||
selectionMode={selectionMode}
|
||||
@@ -2701,7 +2718,7 @@ export function ArchivesPage() {
|
||||
<ArchiveListRow
|
||||
key={archive.id}
|
||||
archive={archive}
|
||||
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
|
||||
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
|
||||
isSelected={selectedIds.has(archive.id)}
|
||||
onSelect={toggleSelect}
|
||||
selectionMode={selectionMode}
|
||||
|
||||
@@ -391,9 +391,13 @@ function SortableQueueItem({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-bambu-gray">
|
||||
<span className={`flex items-center gap-1.5 ${item.printer_id === null ? 'text-orange-400' : ''}`}>
|
||||
<span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
|
||||
<Printer className="w-3.5 h-3.5" />
|
||||
{item.printer_id === null ? 'Unassigned' : (item.printer_name || `Printer #${item.printer_id}`)}
|
||||
{item.target_model
|
||||
? `Any ${item.target_model}`
|
||||
: item.printer_id === null
|
||||
? 'Unassigned'
|
||||
: (item.printer_name || `Printer #${item.printer_id}`)}
|
||||
</span>
|
||||
{item.print_time_seconds && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BkOQ27rS.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CeGzUNH5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nwJjDqT-.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user