This commit is contained in:
maziggy
2026-01-28 09:14:22 +01:00
parent ead2bfc822
commit 2eb21e8b42
21 changed files with 9256 additions and 8861 deletions
+4 -4
View File
@@ -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>
+1
View File
@@ -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,
+32 -3
View File
@@ -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
+12
View File
@@ -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."""
+3
View File
@@ -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)
+3
View File
@@ -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(
+2
View File
@@ -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
+3
View File
@@ -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
+33 -3
View File
@@ -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,
+80 -38
View File
@@ -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."""
+86
View File
@@ -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
+4
View File
@@ -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>
);
}
+144 -42
View File
@@ -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;
}
/**
+6 -27
View File
@@ -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 */}
+21 -4
View File
@@ -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}
+6 -2
View File
@@ -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
View File
@@ -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>