mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Add per-printer AMS mapping for multi-printer selection
- New useMultiPrinterFilamentMapping hook for parallel printer status fetching - Per-printer custom slot configuration with "Custom mapping" checkbox - Auto-configure using RFID data to match filaments by type/color - Match status indicator (exact/partial/missing) under each printer - Re-read button to refresh loaded filaments - New setting: "Expand custom mapping by default" in Settings → Filament - FilamentMapping component now accepts defaultExpanded prop Frontend: - PrinterSelector: Inline mapping editor for each selected printer - PrintModal: Fetches settings, auto-expands mapping when setting enabled - SettingsPage: Toggle for per_printer_mapping_expanded Backend: - Added per_printer_mapping_expanded to AppSettings schema - Added boolean parsing in settings routes - Added integration tests for new setting Closes #104
This commit is contained in:
+15
-15
@@ -23,9 +23,15 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Multi-Printer Selection** - Send prints or queue items to multiple printers at once:
|
||||
- Checkbox selection for multiple printers in reprint and add-to-queue modes
|
||||
- "Select all" / "Clear" buttons for quick selection
|
||||
- Same filament slot mapping applies to all selected printers
|
||||
- Progress indicator during multi-printer submission
|
||||
- Ideal for print farms with identical filament configurations
|
||||
- **Per-Printer AMS Mapping** - Configure filament slot mapping individually for each printer:
|
||||
- Enable "Custom mapping" checkbox under each selected printer
|
||||
- Auto-configure uses RFID data to match filaments automatically
|
||||
- Manual override for specific slot assignments
|
||||
- Match status indicator shows exact/partial/missing matches
|
||||
- Re-read button to refresh printer's loaded filaments
|
||||
- New setting in Settings → Filament to expand custom mapping by default
|
||||
- **Enhanced Add-to-Queue** - Now includes plate selection and print options:
|
||||
- Configure all print settings upfront instead of editing afterward
|
||||
- Filament mapping with manual override capability
|
||||
@@ -38,20 +44,6 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- Archives are created automatically when prints start
|
||||
- Reduces clutter in Archives from unprinted queued files
|
||||
- Queue displays library file name, thumbnail, and print time
|
||||
|
||||
### Changed
|
||||
- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
|
||||
- **Edit Queue Item button** - Changed from "Print to X Printers" to "Save"
|
||||
|
||||
### Fixed
|
||||
- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
|
||||
- Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
|
||||
- Folder navigation now works correctly without resetting
|
||||
- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
|
||||
|
||||
## [0.1.6b10] - 2026-01-20
|
||||
|
||||
### New Features
|
||||
- **Expandable Color Picker** - Configure AMS Slot modal now has an expandable color palette:
|
||||
- 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray)
|
||||
- Click "+" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream)
|
||||
@@ -67,7 +59,15 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- Embedded viewer is draggable and resizable with persistent position/size
|
||||
- Configure in Settings → General → Camera section
|
||||
|
||||
### Changed
|
||||
- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
|
||||
- **Edit Queue Item button** - Changed from "Print to X Printers" to "Save"
|
||||
|
||||
### Fixed
|
||||
- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root:
|
||||
- Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop
|
||||
- Folder navigation now works correctly without resetting
|
||||
- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
|
||||
- **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:
|
||||
- Now correctly derives `tray_info_idx` from the preset's `base_id` when `filament_id` is null
|
||||
- User presets that inherit from Bambu presets (e.g., "# Overture Matte PLA @BBL H2D") now work correctly
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
### ⏰ Scheduling & Automation
|
||||
- Print queue with drag-and-drop
|
||||
- Multi-printer selection (send to multiple printers at once)
|
||||
- Per-printer AMS mapping (individual slot configuration for print farms)
|
||||
- Scheduled prints (date/time)
|
||||
- Queue Only mode (stage without auto-start)
|
||||
- Smart plug integration (Tasmota, Home Assistant)
|
||||
|
||||
@@ -78,6 +78,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
|
||||
"mqtt_enabled",
|
||||
"mqtt_use_tls",
|
||||
"ha_enabled",
|
||||
"per_printer_mapping_expanded",
|
||||
]:
|
||||
settings_dict[setting.key] = setting.value.lower() == "true"
|
||||
elif setting.key in [
|
||||
|
||||
@@ -41,6 +41,11 @@ class AppSettings(BaseModel):
|
||||
)
|
||||
ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
|
||||
|
||||
# Print modal settings
|
||||
per_printer_mapping_expanded: bool = Field(
|
||||
default=False, description="Expand custom filament mapping by default in print modal"
|
||||
)
|
||||
|
||||
# Date/time display format
|
||||
date_format: str = Field(default="system", description="Date format: system, us, eu, iso")
|
||||
time_format: str = Field(default="system", description="Time format: system, 12h, 24h")
|
||||
@@ -127,6 +132,7 @@ class AppSettingsUpdate(BaseModel):
|
||||
ams_temp_good: float | None = None
|
||||
ams_temp_fair: float | None = None
|
||||
ams_history_retention_days: int | None = None
|
||||
per_printer_mapping_expanded: bool | None = None
|
||||
date_format: str | None = None
|
||||
time_format: str | None = None
|
||||
default_printer_id: int | None = None
|
||||
|
||||
@@ -328,3 +328,45 @@ class TestSettingsAPI:
|
||||
assert "camera_view_mode" in result
|
||||
# Default is 'window' as defined in schema
|
||||
assert result["camera_view_mode"] in ["window", "embedded"]
|
||||
|
||||
# ========================================================================
|
||||
# Per-printer mapping settings tests
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_per_printer_mapping_expanded(self, async_client: AsyncClient):
|
||||
"""Verify per_printer_mapping_expanded can be updated."""
|
||||
response = await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["per_printer_mapping_expanded"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_per_printer_mapping_expanded_persists(self, async_client: AsyncClient):
|
||||
"""CRITICAL: Verify per_printer_mapping_expanded persists after update."""
|
||||
# Update to True
|
||||
await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": True})
|
||||
|
||||
# Verify persistence in new request
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
assert response.json()["per_printer_mapping_expanded"] is True
|
||||
|
||||
# Update back to False
|
||||
await async_client.put("/api/v1/settings/", json={"per_printer_mapping_expanded": False})
|
||||
|
||||
# Verify persistence
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
assert response.json()["per_printer_mapping_expanded"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_per_printer_mapping_expanded_default(self, async_client: AsyncClient):
|
||||
"""Verify per_printer_mapping_expanded has correct default value."""
|
||||
response = await async_client.get("/api/v1/settings/")
|
||||
result = response.json()
|
||||
|
||||
assert "per_printer_mapping_expanded" in result
|
||||
# Default is False as defined in schema
|
||||
assert isinstance(result["per_printer_mapping_expanded"], bool)
|
||||
|
||||
@@ -558,6 +558,8 @@ export interface AppSettings {
|
||||
ams_temp_good: number; // <= this is green/blue
|
||||
ams_temp_fair: number; // <= this is orange, > is red
|
||||
ams_history_retention_days: number; // days to keep AMS sensor history
|
||||
// Print modal settings
|
||||
per_printer_mapping_expanded: boolean; // Whether custom mapping is expanded by default in print modal
|
||||
// Date/time format settings
|
||||
date_format: 'system' | 'us' | 'eu' | 'iso';
|
||||
time_format: 'system' | '12h' | '24h';
|
||||
|
||||
@@ -15,10 +15,11 @@ export function FilamentMapping({
|
||||
filamentReqs,
|
||||
manualMappings,
|
||||
onManualMappingChange,
|
||||
}: FilamentMappingProps) {
|
||||
defaultExpanded = false,
|
||||
}: FilamentMappingProps & { defaultExpanded?: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
// Fetch printer status
|
||||
const { data: printerStatus } = useQuery({
|
||||
|
||||
@@ -1,9 +1,205 @@
|
||||
import { Printer as PrinterIcon, Loader2, AlertCircle, Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Printer as PrinterIcon,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Circle,
|
||||
RefreshCw,
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import { getColorName } from '../../utils/colors';
|
||||
import {
|
||||
normalizeColorForCompare,
|
||||
colorsAreSimilar,
|
||||
} from '../../utils/amsHelpers';
|
||||
import type { PrinterSelectorProps } from './types';
|
||||
import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
|
||||
import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
|
||||
|
||||
interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
|
||||
/** Per-printer mapping results (only used when multiple printers selected) */
|
||||
printerMappingResults?: PrinterMappingResult[];
|
||||
/** Filament requirements for the print */
|
||||
filamentReqs?: { filaments: FilamentRequirement[] };
|
||||
/** Callback to auto-configure a printer */
|
||||
onAutoConfigurePrinter?: (printerId: number) => void;
|
||||
/** Callback to update printer config */
|
||||
onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline AMS mapping editor for a single printer.
|
||||
*/
|
||||
function InlineMappingEditor({
|
||||
printerResult,
|
||||
filamentReqs,
|
||||
onUpdateConfig,
|
||||
}: {
|
||||
printerResult: PrinterMappingResult;
|
||||
filamentReqs: FilamentRequirement[];
|
||||
onUpdateConfig: (config: Partial<PerPrinterConfig>) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleSlotChange = (slotId: number, value: string) => {
|
||||
if (slotId <= 0) return;
|
||||
|
||||
const newMappings = { ...printerResult.config.manualMappings };
|
||||
if (value === '') {
|
||||
delete newMappings[slotId];
|
||||
} else {
|
||||
newMappings[slotId] = parseInt(value, 10);
|
||||
}
|
||||
|
||||
onUpdateConfig({
|
||||
useDefault: false,
|
||||
manualMappings: newMappings,
|
||||
autoConfigured: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await api.refreshPrinterStatus(printerResult.printerId);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await queryClient.refetchQueries({ queryKey: ['printer-status', printerResult.printerId] });
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Compute current slot assignments
|
||||
const slotAssignments = filamentReqs.map((req) => {
|
||||
const slotId = req.slot_id || 0;
|
||||
const currentMapping = printerResult.config.manualMappings[slotId];
|
||||
|
||||
let loaded: LoadedFilament | undefined;
|
||||
let isManual = false;
|
||||
|
||||
if (currentMapping !== undefined) {
|
||||
loaded = printerResult.loadedFilaments.find((f) => f.globalTrayId === currentMapping);
|
||||
isManual = true;
|
||||
} else {
|
||||
// Auto-match logic
|
||||
const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
|
||||
|
||||
const exactMatch = printerResult.loadedFilaments.find(
|
||||
(f) =>
|
||||
!usedTrayIds.has(f.globalTrayId) &&
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
|
||||
);
|
||||
const similarMatch = exactMatch
|
||||
? undefined
|
||||
: printerResult.loadedFilaments.find(
|
||||
(f) =>
|
||||
!usedTrayIds.has(f.globalTrayId) &&
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
colorsAreSimilar(f.color, req.color)
|
||||
);
|
||||
const typeOnlyMatch =
|
||||
exactMatch || similarMatch
|
||||
? undefined
|
||||
: printerResult.loadedFilaments.find(
|
||||
(f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
|
||||
);
|
||||
loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
|
||||
}
|
||||
|
||||
// Determine status
|
||||
let status: 'match' | 'type_only' | 'mismatch' = 'mismatch';
|
||||
if (loaded) {
|
||||
const typeMatch = loaded.type?.toUpperCase() === req.type?.toUpperCase();
|
||||
const colorMatch =
|
||||
normalizeColorForCompare(loaded.color) === normalizeColorForCompare(req.color) ||
|
||||
colorsAreSimilar(loaded.color, req.color);
|
||||
|
||||
if (typeMatch && colorMatch) {
|
||||
status = 'match';
|
||||
} else if (typeMatch) {
|
||||
status = 'type_only';
|
||||
}
|
||||
}
|
||||
|
||||
return { req, loaded, status, isManual };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-bambu-gray">Custom slot mapping</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
<span>Re-read</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{slotAssignments.map(({ req, loaded, status, isManual }, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="grid items-center gap-2 text-xs"
|
||||
style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
|
||||
>
|
||||
<span title={`Required: ${req.type} - ${getColorName(req.color)}`}>
|
||||
<Circle className="w-3 h-3" fill={req.color} stroke={req.color} />
|
||||
</span>
|
||||
<span className="text-white truncate">
|
||||
{req.type} <span className="text-bambu-gray">({req.used_grams}g)</span>
|
||||
</span>
|
||||
<span className="text-bambu-gray">→</span>
|
||||
<select
|
||||
value={loaded?.globalTrayId ?? ''}
|
||||
onChange={(e) => handleSlotChange(req.slot_id || 0, e.target.value)}
|
||||
className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
|
||||
status === 'match'
|
||||
? 'border-bambu-green/50 text-bambu-green'
|
||||
: status === 'type_only'
|
||||
? 'border-yellow-400/50 text-yellow-400'
|
||||
: 'border-orange-400/50 text-orange-400'
|
||||
} ${isManual ? 'ring-1 ring-blue-400/50' : ''}`}
|
||||
title={isManual ? 'Manually selected' : 'Auto-matched'}
|
||||
>
|
||||
<option value="" className="bg-bambu-dark text-bambu-gray">
|
||||
-- Select slot --
|
||||
</option>
|
||||
{printerResult.loadedFilaments.map((f) => (
|
||||
<option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
|
||||
{f.label}: {f.type} ({f.colorName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{status === 'match' ? (
|
||||
<Check className="w-3 h-3 text-bambu-green" />
|
||||
) : status === 'type_only' ? (
|
||||
<span title="Same type, different color">
|
||||
<AlertTriangle className="w-3 h-3 text-yellow-400" />
|
||||
</span>
|
||||
) : (
|
||||
<span title="Filament type not loaded">
|
||||
<AlertTriangle className="w-3 h-3 text-orange-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Printer selection component with grid-based UI.
|
||||
* Supports single or multi-select modes.
|
||||
* When multiple printers are selected, shows per-printer mapping overrides.
|
||||
*/
|
||||
export function PrinterSelector({
|
||||
printers,
|
||||
@@ -12,10 +208,22 @@ export function PrinterSelector({
|
||||
isLoading = false,
|
||||
allowMultiple = false,
|
||||
showInactive = false,
|
||||
}: PrinterSelectorProps) {
|
||||
printerMappingResults,
|
||||
filamentReqs,
|
||||
onAutoConfigurePrinter,
|
||||
onUpdatePrinterConfig,
|
||||
}: PrinterSelectorWithMappingProps) {
|
||||
// Filter printers based on showInactive flag
|
||||
const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
|
||||
|
||||
const showMappingOptions = allowMultiple &&
|
||||
selectedPrinterIds.length > 1 &&
|
||||
printerMappingResults &&
|
||||
filamentReqs?.filaments &&
|
||||
filamentReqs.filaments.length > 0 &&
|
||||
onAutoConfigurePrinter &&
|
||||
onUpdatePrinterConfig;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
@@ -35,14 +243,12 @@ export function PrinterSelector({
|
||||
|
||||
const handlePrinterClick = (printerId: number) => {
|
||||
if (allowMultiple) {
|
||||
// Multi-select mode: toggle printer in selection
|
||||
if (selectedPrinterIds.includes(printerId)) {
|
||||
onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId));
|
||||
} else {
|
||||
onMultiSelect([...selectedPrinterIds, printerId]);
|
||||
}
|
||||
} else {
|
||||
// Single-select mode: replace selection
|
||||
onMultiSelect([printerId]);
|
||||
}
|
||||
};
|
||||
@@ -55,10 +261,28 @@ export function PrinterSelector({
|
||||
onMultiSelect([]);
|
||||
};
|
||||
|
||||
const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
|
||||
const handleOverrideToggle = (printerId: number, enabled: boolean, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!onAutoConfigurePrinter || !onUpdatePrinterConfig) return;
|
||||
|
||||
if (enabled) {
|
||||
onAutoConfigurePrinter(printerId);
|
||||
} else {
|
||||
onUpdatePrinterConfig(printerId, {
|
||||
useDefault: true,
|
||||
manualMappings: {},
|
||||
autoConfigured: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (printerId: number) => selectedPrinterIds.includes(printerId);
|
||||
const selectedCount = selectedPrinterIds.length;
|
||||
|
||||
const getPrinterMappingResult = (printerId: number) => {
|
||||
return printerMappingResults?.find((r) => r.printerId === printerId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mb-6">
|
||||
{/* Multi-select header */}
|
||||
@@ -92,51 +316,119 @@ export function PrinterSelector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayPrinters.map((printer) => (
|
||||
<button
|
||||
key={printer.id}
|
||||
type="button"
|
||||
onClick={() => handlePrinterClick(printer.id)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
|
||||
isSelected(printer.id)
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
|
||||
} ${!printer.is_active ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
isSelected(printer.id) ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<PrinterIcon
|
||||
className={`w-5 h-5 ${
|
||||
isSelected(printer.id) ? 'text-bambu-green' : 'text-bambu-gray'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left flex-1">
|
||||
<p className="text-white font-medium">
|
||||
{printer.name}
|
||||
{!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{printer.model || 'Unknown model'} • {printer.ip_address}
|
||||
</p>
|
||||
</div>
|
||||
{/* Checkbox indicator for multi-select */}
|
||||
{allowMultiple && (
|
||||
<div
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
isSelected(printer.id)
|
||||
? 'bg-bambu-green border-bambu-green'
|
||||
: 'border-bambu-gray/50'
|
||||
}`}
|
||||
{displayPrinters.map((printer) => {
|
||||
const selected = isSelected(printer.id);
|
||||
const mappingResult = getPrinterMappingResult(printer.id);
|
||||
const hasOverride = mappingResult && !mappingResult.config.useDefault;
|
||||
|
||||
return (
|
||||
<div key={printer.id}>
|
||||
{/* Printer selection button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePrinterClick(printer.id)}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
|
||||
selected
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
|
||||
} ${!printer.is_active ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{isSelected(printer.id) && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
selected ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<PrinterIcon
|
||||
className={`w-5 h-5 ${
|
||||
selected ? 'text-bambu-green' : 'text-bambu-gray'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left flex-1">
|
||||
<p className="text-white font-medium">
|
||||
{printer.name}
|
||||
{!printer.is_active && <span className="text-bambu-gray text-xs ml-2">(inactive)</span>}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{printer.model || 'Unknown model'} • {printer.ip_address}
|
||||
</p>
|
||||
</div>
|
||||
{allowMultiple && (
|
||||
<div
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
selected
|
||||
? 'bg-bambu-green border-bambu-green'
|
||||
: 'border-bambu-gray/50'
|
||||
}`}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Per-printer override checkbox + mapping (only when selected and multi-printer) */}
|
||||
{selected && showMappingOptions && mappingResult && (
|
||||
<div className="ml-4 mt-2 mb-3">
|
||||
{/* Override checkbox row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasOverride}
|
||||
onChange={(e) => handleOverrideToggle(printer.id, e.target.checked, e as unknown as React.MouseEvent)}
|
||||
className="w-3.5 h-3.5 rounded border-bambu-gray/30 bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-xs text-bambu-gray">Custom mapping</span>
|
||||
</label>
|
||||
|
||||
{/* Match status indicator */}
|
||||
<span className={`text-xs ml-2 ${
|
||||
mappingResult.matchStatus === 'full'
|
||||
? 'text-bambu-green'
|
||||
: mappingResult.matchStatus === 'partial'
|
||||
? 'text-yellow-400'
|
||||
: 'text-orange-400'
|
||||
}`}>
|
||||
({mappingResult.exactMatches}/{mappingResult.totalSlots} matched)
|
||||
</span>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{mappingResult.isLoading && (
|
||||
<RefreshCw className="w-3 h-3 text-bambu-gray animate-spin" />
|
||||
)}
|
||||
|
||||
{/* Auto-configure button (when override is enabled) */}
|
||||
{hasOverride && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAutoConfigurePrinter!(printer.id);
|
||||
}}
|
||||
className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-bambu-gray/30 hover:border-bambu-gray hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
|
||||
>
|
||||
<Wand2 className="w-3 h-3" />
|
||||
Auto
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline mapping editor (shown when override is checked) */}
|
||||
{hasOverride && (
|
||||
<InlineMappingEditor
|
||||
printerResult={mappingResult}
|
||||
filamentReqs={filamentReqs!.filaments}
|
||||
onUpdateConfig={(config) => onUpdatePrinterConfig!(printer.id, config)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Warning when no printer selected */}
|
||||
{selectedCount === 0 && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent } from '../Card';
|
||||
import { Button } from '../Button';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
|
||||
import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
|
||||
import { isPlaceholderDate } from '../../utils/amsHelpers';
|
||||
import { PrinterSelector } from './PrinterSelector';
|
||||
import { PlateSelector } from './PlateSelector';
|
||||
@@ -99,7 +100,7 @@ export function PrintModal({
|
||||
return DEFAULT_SCHEDULE_OPTIONS;
|
||||
});
|
||||
|
||||
// Manual slot overrides: slot_id (1-indexed) -> globalTrayId
|
||||
// Manual slot overrides: slot_id (1-indexed) -> globalTrayId (default mapping for single printer or all printers)
|
||||
const [manualMappings, setManualMappings] = useState<Record<number, number>>(() => {
|
||||
if (mode === 'edit-queue-item' && queueItem?.ams_mapping && Array.isArray(queueItem.ams_mapping)) {
|
||||
const mappings: Record<number, number> = {};
|
||||
@@ -113,6 +114,9 @@ export function PrintModal({
|
||||
return {};
|
||||
});
|
||||
|
||||
// Per-printer override configs (for multi-printer selection)
|
||||
const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
|
||||
|
||||
// Track initial values for clearing mappings on change (edit mode only)
|
||||
const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
|
||||
const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
|
||||
@@ -127,6 +131,11 @@ export function PrintModal({
|
||||
const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;
|
||||
|
||||
// Queries
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: api.getSettings,
|
||||
});
|
||||
|
||||
const { data: printers, isLoading: loadingPrinters } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: api.getPrinters,
|
||||
@@ -181,6 +190,16 @@ export function PrintModal({
|
||||
// Get AMS mapping from hook (only when single printer selected)
|
||||
const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings);
|
||||
|
||||
// Multi-printer filament mapping (for per-printer configuration)
|
||||
const multiPrinterMapping = useMultiPrinterFilamentMapping(
|
||||
selectedPrinters,
|
||||
printers,
|
||||
effectiveFilamentReqs,
|
||||
manualMappings,
|
||||
perPrinterConfigs,
|
||||
setPerPrinterConfigs
|
||||
);
|
||||
|
||||
// Auto-select first plate for single-plate files
|
||||
useEffect(() => {
|
||||
if (platesData?.plates?.length === 1 && !selectedPlate) {
|
||||
@@ -198,19 +217,39 @@ export function PrintModal({
|
||||
}
|
||||
}, [mode, printers, selectedPrinters.length]);
|
||||
|
||||
// Clear manual mappings when printer or plate changes
|
||||
// Clear manual mappings and per-printer configs when printer or plate changes
|
||||
useEffect(() => {
|
||||
if (mode === 'edit-queue-item') {
|
||||
// For edit mode, clear mappings if printer selection or plate changed from initial
|
||||
const printersChanged = JSON.stringify(selectedPrinters.sort()) !== JSON.stringify(initialPrinterIds.sort());
|
||||
if (printersChanged || selectedPlate !== initialPlateId) {
|
||||
setManualMappings({});
|
||||
setPerPrinterConfigs({});
|
||||
}
|
||||
} else {
|
||||
setManualMappings({});
|
||||
setPerPrinterConfigs({});
|
||||
}
|
||||
}, [mode, selectedPrinters, selectedPlate, initialPrinterIds, initialPlateId]);
|
||||
|
||||
// Auto-expand per-printer mapping when setting is enabled and multiple printers selected
|
||||
useEffect(() => {
|
||||
if (!settings?.per_printer_mapping_expanded) return;
|
||||
if (selectedPrinters.length <= 1) return;
|
||||
|
||||
// Check if any printer needs to be auto-configured
|
||||
const printersNeedingConfig = selectedPrinters.filter(
|
||||
printerId => !perPrinterConfigs[printerId] || perPrinterConfigs[printerId].useDefault
|
||||
);
|
||||
|
||||
if (printersNeedingConfig.length > 0) {
|
||||
// Auto-configure printers that don't have custom config
|
||||
printersNeedingConfig.forEach(printerId => {
|
||||
multiPrinterMapping.autoConfigurePrinter(printerId);
|
||||
});
|
||||
}
|
||||
}, [settings?.per_printer_mapping_expanded, selectedPrinters, perPrinterConfigs, multiPrinterMapping]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -260,6 +299,18 @@ export function PrintModal({
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Get mapping for a specific printer (per-printer override or default)
|
||||
const getMappingForPrinter = (printerId: number): number[] | undefined => {
|
||||
// For multi-printer selection, check if this printer has an override
|
||||
if (selectedPrinters.length > 1) {
|
||||
const printerConfig = perPrinterConfigs[printerId];
|
||||
if (printerConfig && !printerConfig.useDefault) {
|
||||
return multiPrinterMapping.getFinalMapping(printerId);
|
||||
}
|
||||
}
|
||||
return amsMapping;
|
||||
};
|
||||
|
||||
// Common queue data for add-to-queue and edit modes
|
||||
const getQueueData = (printerId: number): PrintQueueItemCreate => ({
|
||||
printer_id: printerId,
|
||||
@@ -269,7 +320,7 @@ export function PrintModal({
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
manual_start: scheduleOptions.scheduleType === 'manual',
|
||||
ams_mapping: amsMapping,
|
||||
ams_mapping: getMappingForPrinter(printerId),
|
||||
plate_id: selectedPlate,
|
||||
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
||||
? new Date(scheduleOptions.scheduledTime).toISOString()
|
||||
@@ -284,26 +335,28 @@ export function PrintModal({
|
||||
try {
|
||||
if (mode === 'reprint') {
|
||||
// Reprint mode - start print immediately
|
||||
const printerMapping = getMappingForPrinter(printerId);
|
||||
if (isLibraryFile) {
|
||||
await api.printLibraryFile(libraryFileId!, printerId, {
|
||||
ams_mapping: amsMapping,
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
});
|
||||
} else {
|
||||
await api.reprintArchive(archiveId!, printerId, {
|
||||
plate_id: selectedPlate ?? undefined,
|
||||
ams_mapping: amsMapping,
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
});
|
||||
}
|
||||
} else if (mode === 'edit-queue-item' && i === 0) {
|
||||
// Edit mode - update the original queue item for the first printer
|
||||
const printerMapping = getMappingForPrinter(printerId);
|
||||
const updateData: PrintQueueItemUpdate = {
|
||||
printer_id: printerId,
|
||||
require_previous_success: scheduleOptions.requirePreviousSuccess,
|
||||
auto_off_after: scheduleOptions.autoOffAfter,
|
||||
manual_start: scheduleOptions.scheduleType === 'manual',
|
||||
ams_mapping: amsMapping,
|
||||
ams_mapping: printerMapping,
|
||||
plate_id: selectedPlate,
|
||||
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
|
||||
? new Date(scheduleOptions.scheduledTime).toISOString()
|
||||
@@ -448,7 +501,7 @@ export function PrintModal({
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Printer selection */}
|
||||
{/* Printer selection with per-printer mapping */}
|
||||
<PrinterSelector
|
||||
printers={printers || []}
|
||||
selectedPrinterIds={selectedPrinters}
|
||||
@@ -456,18 +509,12 @@ export function PrintModal({
|
||||
isLoading={loadingPrinters}
|
||||
allowMultiple={true}
|
||||
showInactive={mode === 'edit-queue-item'}
|
||||
printerMappingResults={multiPrinterMapping.printerResults}
|
||||
filamentReqs={effectiveFilamentReqs}
|
||||
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
|
||||
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
|
||||
/>
|
||||
|
||||
{/* Multi-printer filament mapping note */}
|
||||
{selectedPrinters.length > 1 && (
|
||||
<div className="flex items-start gap-2 p-3 mb-2 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm">
|
||||
<AlertCircle className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-blue-400">
|
||||
Slot mapping below applies to all {selectedPrinters.length} printers. Ensure they have matching filament configurations.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plate selection */}
|
||||
<PlateSelector
|
||||
plates={plates}
|
||||
@@ -486,13 +533,14 @@ export function PrintModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filament mapping - show when printer selected and filament requirements available */}
|
||||
{showFilamentMapping && !archiveDataMissing && (
|
||||
{/* Filament mapping - only show when single printer selected */}
|
||||
{showFilamentMapping && !archiveDataMissing && selectedPrinters.length === 1 && (
|
||||
<FilamentMapping
|
||||
printerId={effectivePrinterId!}
|
||||
filamentReqs={effectiveFilamentReqs}
|
||||
manualMappings={manualMappings}
|
||||
onManualMappingChange={setManualMappings}
|
||||
defaultExpanded={settings?.per_printer_mapping_expanded ?? false}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
import type { PrinterStatus, Printer } from '../api/client';
|
||||
import {
|
||||
buildLoadedFilaments,
|
||||
computeAmsMapping,
|
||||
type LoadedFilament,
|
||||
type FilamentRequirement,
|
||||
} from './useFilamentMapping';
|
||||
import {
|
||||
normalizeColorForCompare,
|
||||
colorsAreSimilar,
|
||||
} from '../utils/amsHelpers';
|
||||
|
||||
/**
|
||||
* Match status for a single printer's filament configuration.
|
||||
*/
|
||||
export type PrinterMatchStatus = 'full' | 'partial' | 'missing';
|
||||
|
||||
/**
|
||||
* Per-printer configuration for AMS mapping.
|
||||
*/
|
||||
export interface PerPrinterConfig {
|
||||
/** Whether this printer uses the default mapping or has custom config */
|
||||
useDefault: boolean;
|
||||
/** Manual slot overrides for this printer (slot_id -> globalTrayId) */
|
||||
manualMappings: Record<number, number>;
|
||||
/** Whether this mapping was auto-configured */
|
||||
autoConfigured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of filament mapping for a single printer.
|
||||
*/
|
||||
export interface PrinterMappingResult {
|
||||
printerId: number;
|
||||
printerName: string;
|
||||
/** Printer status data */
|
||||
status: PrinterStatus | undefined;
|
||||
/** Whether status is still loading */
|
||||
isLoading: boolean;
|
||||
/** List of loaded filaments in this printer */
|
||||
loadedFilaments: LoadedFilament[];
|
||||
/** Auto-computed AMS mapping for this printer */
|
||||
autoMapping: number[] | undefined;
|
||||
/** Final AMS mapping (considering manual overrides) */
|
||||
finalMapping: number[] | undefined;
|
||||
/** Match status: full (all exact), partial (some mismatches), missing (type not found) */
|
||||
matchStatus: PrinterMatchStatus;
|
||||
/** Number of slots with exact match (type + color) */
|
||||
exactMatches: number;
|
||||
/** Number of slots with type-only match */
|
||||
typeOnlyMatches: number;
|
||||
/** Number of slots with missing type */
|
||||
missingTypes: number;
|
||||
/** Total required slots */
|
||||
totalSlots: number;
|
||||
/** Per-printer config */
|
||||
config: PerPrinterConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the useMultiPrinterFilamentMapping hook.
|
||||
*/
|
||||
export interface UseMultiPrinterFilamentMappingResult {
|
||||
/** Results for each selected printer */
|
||||
printerResults: PrinterMappingResult[];
|
||||
/** Whether any printer data is still loading */
|
||||
isLoading: boolean;
|
||||
/** Per-printer configurations */
|
||||
perPrinterConfigs: Record<number, PerPrinterConfig>;
|
||||
/** Update config for a specific printer */
|
||||
updatePrinterConfig: (printerId: number, config: Partial<PerPrinterConfig>) => void;
|
||||
/** Auto-configure all printers based on their loaded filaments */
|
||||
autoConfigureAll: () => void;
|
||||
/** Auto-configure a specific printer */
|
||||
autoConfigurePrinter: (printerId: number) => void;
|
||||
/** Get final mapping for a specific printer (for submission) */
|
||||
getFinalMapping: (printerId: number) => number[] | undefined;
|
||||
/** Check if all printers have acceptable mappings */
|
||||
allPrintersReady: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute match details for a printer given filament requirements and loaded filaments.
|
||||
*/
|
||||
function computeMatchDetails(
|
||||
filamentReqs: FilamentRequirement[] | undefined,
|
||||
loadedFilaments: LoadedFilament[],
|
||||
manualMappings: Record<number, number>
|
||||
): { exactMatches: number; typeOnlyMatches: number; missingTypes: number; totalSlots: number; status: PrinterMatchStatus } {
|
||||
if (!filamentReqs || filamentReqs.length === 0) {
|
||||
return { exactMatches: 0, typeOnlyMatches: 0, missingTypes: 0, totalSlots: 0, status: 'full' };
|
||||
}
|
||||
|
||||
let exactMatches = 0;
|
||||
let typeOnlyMatches = 0;
|
||||
let missingTypes = 0;
|
||||
const usedTrayIds = new Set<number>(Object.values(manualMappings));
|
||||
|
||||
for (const req of filamentReqs) {
|
||||
const slotId = req.slot_id || 0;
|
||||
|
||||
// Check manual override first
|
||||
if (slotId > 0 && manualMappings[slotId] !== undefined) {
|
||||
const manualTrayId = manualMappings[slotId];
|
||||
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
|
||||
|
||||
if (manualLoaded) {
|
||||
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
|
||||
const colorMatch =
|
||||
normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
|
||||
colorsAreSimilar(manualLoaded.color, req.color);
|
||||
|
||||
if (typeMatch && colorMatch) {
|
||||
exactMatches++;
|
||||
} else if (typeMatch) {
|
||||
typeOnlyMatches++;
|
||||
} else {
|
||||
missingTypes++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-match
|
||||
const exactMatch = loadedFilaments.find(
|
||||
(f) =>
|
||||
!usedTrayIds.has(f.globalTrayId) &&
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
|
||||
);
|
||||
const similarMatch = exactMatch
|
||||
? undefined
|
||||
: loadedFilaments.find(
|
||||
(f) =>
|
||||
!usedTrayIds.has(f.globalTrayId) &&
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
colorsAreSimilar(f.color, req.color)
|
||||
);
|
||||
const typeOnlyMatch =
|
||||
exactMatch || similarMatch
|
||||
? undefined
|
||||
: loadedFilaments.find(
|
||||
(f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
|
||||
);
|
||||
const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
|
||||
|
||||
if (loaded) {
|
||||
usedTrayIds.add(loaded.globalTrayId);
|
||||
}
|
||||
|
||||
if (exactMatch || similarMatch) {
|
||||
exactMatches++;
|
||||
} else if (typeOnlyMatch) {
|
||||
typeOnlyMatches++;
|
||||
} else {
|
||||
missingTypes++;
|
||||
}
|
||||
}
|
||||
|
||||
const totalSlots = filamentReqs.length;
|
||||
let status: PrinterMatchStatus = 'full';
|
||||
if (missingTypes > 0) {
|
||||
status = 'missing';
|
||||
} else if (typeOnlyMatches > 0) {
|
||||
status = 'partial';
|
||||
}
|
||||
|
||||
return { exactMatches, typeOnlyMatches, missingTypes, totalSlots, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute AMS mapping with manual overrides applied.
|
||||
*/
|
||||
function computeMappingWithOverrides(
|
||||
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
|
||||
printerStatus: PrinterStatus | undefined,
|
||||
manualMappings: Record<number, number>
|
||||
): number[] | undefined {
|
||||
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
|
||||
|
||||
const loadedFilaments = buildLoadedFilaments(printerStatus);
|
||||
if (loadedFilaments.length === 0) return undefined;
|
||||
|
||||
const usedTrayIds = new Set<number>(Object.values(manualMappings));
|
||||
const comparisons: { slot_id: number; globalTrayId: number }[] = [];
|
||||
|
||||
for (const req of filamentReqs.filaments) {
|
||||
const slotId = req.slot_id || 0;
|
||||
|
||||
// Check manual override first
|
||||
if (slotId > 0 && manualMappings[slotId] !== undefined) {
|
||||
comparisons.push({ slot_id: slotId, globalTrayId: manualMappings[slotId] });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-match
|
||||
const exactMatch = loadedFilaments.find(
|
||||
(f) =>
|
||||
!usedTrayIds.has(f.globalTrayId) &&
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
|
||||
);
|
||||
const similarMatch = exactMatch
|
||||
? undefined
|
||||
: loadedFilaments.find(
|
||||
(f) =>
|
||||
!usedTrayIds.has(f.globalTrayId) &&
|
||||
f.type?.toUpperCase() === req.type?.toUpperCase() &&
|
||||
colorsAreSimilar(f.color, req.color)
|
||||
);
|
||||
const typeOnlyMatch =
|
||||
exactMatch || similarMatch
|
||||
? undefined
|
||||
: loadedFilaments.find(
|
||||
(f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
|
||||
);
|
||||
const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
|
||||
|
||||
if (loaded) {
|
||||
usedTrayIds.add(loaded.globalTrayId);
|
||||
}
|
||||
|
||||
comparisons.push({ slot_id: slotId, globalTrayId: loaded?.globalTrayId ?? -1 });
|
||||
}
|
||||
|
||||
const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
|
||||
if (maxSlotId <= 0) return undefined;
|
||||
|
||||
const mapping = new Array(maxSlotId).fill(-1);
|
||||
comparisons.forEach((f) => {
|
||||
if (f.slot_id && f.slot_id > 0) {
|
||||
mapping[f.slot_id - 1] = f.globalTrayId;
|
||||
}
|
||||
});
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default per-printer config (use default mapping).
|
||||
*/
|
||||
const DEFAULT_PRINTER_CONFIG: PerPrinterConfig = {
|
||||
useDefault: true,
|
||||
manualMappings: {},
|
||||
autoConfigured: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage filament mapping for multiple printers.
|
||||
* Fetches printer status for all selected printers and computes per-printer mappings.
|
||||
*/
|
||||
export function useMultiPrinterFilamentMapping(
|
||||
selectedPrinterIds: number[],
|
||||
printers: Printer[] | undefined,
|
||||
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
|
||||
defaultMappings: Record<number, number>,
|
||||
perPrinterConfigs: Record<number, PerPrinterConfig>,
|
||||
setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>
|
||||
): UseMultiPrinterFilamentMappingResult {
|
||||
// Fetch printer status for all selected printers in parallel
|
||||
const statusQueries = useQueries({
|
||||
queries: selectedPrinterIds.map((printerId) => ({
|
||||
queryKey: ['printer-status', printerId],
|
||||
queryFn: () => api.getPrinterStatus(printerId),
|
||||
enabled: selectedPrinterIds.length > 0,
|
||||
staleTime: 5000, // Consider data fresh for 5 seconds
|
||||
})),
|
||||
});
|
||||
|
||||
// Build results for each printer
|
||||
const printerResults = useMemo((): PrinterMappingResult[] => {
|
||||
return selectedPrinterIds.map((printerId, index) => {
|
||||
const query = statusQueries[index];
|
||||
const printerStatus = query?.data;
|
||||
const printer = printers?.find((p) => p.id === printerId);
|
||||
const printerName = printer?.name || `Printer ${printerId}`;
|
||||
|
||||
const loadedFilaments = buildLoadedFilaments(printerStatus);
|
||||
const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG;
|
||||
|
||||
// Compute auto mapping for this printer
|
||||
const autoMapping = computeAmsMapping(filamentReqs, printerStatus);
|
||||
|
||||
// Determine which mappings to use:
|
||||
// If printer has override (useDefault=false), use its custom mappings
|
||||
// Otherwise use the default mappings
|
||||
const effectiveMappings = !config.useDefault
|
||||
? config.manualMappings
|
||||
: defaultMappings;
|
||||
|
||||
// Compute final mapping with overrides
|
||||
const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings);
|
||||
|
||||
// Compute match details
|
||||
const matchDetails = computeMatchDetails(
|
||||
filamentReqs?.filaments,
|
||||
loadedFilaments,
|
||||
effectiveMappings
|
||||
);
|
||||
|
||||
return {
|
||||
printerId,
|
||||
printerName,
|
||||
status: printerStatus,
|
||||
isLoading: query?.isLoading ?? false,
|
||||
loadedFilaments,
|
||||
autoMapping,
|
||||
finalMapping,
|
||||
matchStatus: matchDetails.status,
|
||||
exactMatches: matchDetails.exactMatches,
|
||||
typeOnlyMatches: matchDetails.typeOnlyMatches,
|
||||
missingTypes: matchDetails.missingTypes,
|
||||
totalSlots: matchDetails.totalSlots,
|
||||
config,
|
||||
};
|
||||
});
|
||||
}, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings]);
|
||||
|
||||
const isLoading = statusQueries.some((q) => q.isLoading);
|
||||
|
||||
// Update config for a specific printer
|
||||
const updatePrinterConfig = (printerId: number, updates: Partial<PerPrinterConfig>) => {
|
||||
setPerPrinterConfigs((prev) => ({
|
||||
...prev,
|
||||
[printerId]: {
|
||||
...(prev[printerId] || DEFAULT_PRINTER_CONFIG),
|
||||
...updates,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Auto-configure a specific printer based on its loaded filaments
|
||||
const autoConfigurePrinter = (printerId: number) => {
|
||||
const result = printerResults.find((r) => r.printerId === printerId);
|
||||
if (!result || !result.status || !filamentReqs?.filaments) return;
|
||||
|
||||
// Compute optimal mapping for this printer
|
||||
const autoMapping = computeAmsMapping(filamentReqs, result.status);
|
||||
if (!autoMapping) return;
|
||||
|
||||
// Convert autoMapping array to manualMappings record
|
||||
const manualMappings: Record<number, number> = {};
|
||||
autoMapping.forEach((globalTrayId, index) => {
|
||||
if (globalTrayId !== -1) {
|
||||
manualMappings[index + 1] = globalTrayId;
|
||||
}
|
||||
});
|
||||
|
||||
updatePrinterConfig(printerId, {
|
||||
useDefault: false,
|
||||
manualMappings,
|
||||
autoConfigured: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-configure all printers
|
||||
const autoConfigureAll = () => {
|
||||
for (const printerId of selectedPrinterIds) {
|
||||
autoConfigurePrinter(printerId);
|
||||
}
|
||||
};
|
||||
|
||||
// Get final mapping for a specific printer (for submission)
|
||||
const getFinalMapping = (printerId: number): number[] | undefined => {
|
||||
const result = printerResults.find((r) => r.printerId === printerId);
|
||||
return result?.finalMapping;
|
||||
};
|
||||
|
||||
// Check if all printers have acceptable mappings (no missing types)
|
||||
const allPrintersReady = printerResults.every((r) => r.matchStatus !== 'missing');
|
||||
|
||||
return {
|
||||
printerResults,
|
||||
isLoading,
|
||||
perPrinterConfigs,
|
||||
updatePrinterConfig,
|
||||
autoConfigureAll,
|
||||
autoConfigurePrinter,
|
||||
getFinalMapping,
|
||||
allPrintersReady,
|
||||
};
|
||||
}
|
||||
@@ -361,6 +361,7 @@ export function SettingsPage() {
|
||||
settings.ams_temp_good !== localSettings.ams_temp_good ||
|
||||
settings.ams_temp_fair !== localSettings.ams_temp_fair ||
|
||||
settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
|
||||
settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
|
||||
settings.date_format !== localSettings.date_format ||
|
||||
settings.time_format !== localSettings.time_format ||
|
||||
settings.default_printer_id !== localSettings.default_printer_id ||
|
||||
@@ -420,6 +421,7 @@ export function SettingsPage() {
|
||||
ams_temp_good: localSettings.ams_temp_good,
|
||||
ams_temp_fair: localSettings.ams_temp_fair,
|
||||
ams_history_retention_days: localSettings.ams_history_retention_days,
|
||||
per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
|
||||
date_format: localSettings.date_format,
|
||||
time_format: localSettings.time_format,
|
||||
default_printer_id: localSettings.default_printer_id,
|
||||
@@ -2519,6 +2521,33 @@ export function SettingsPage() {
|
||||
Older humidity and temperature data will be automatically deleted
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Per-Printer Mapping Default */}
|
||||
<div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Printer className="w-4 h-4 text-bambu-green" />
|
||||
<span className="font-medium">Print Modal</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm text-white">
|
||||
Expand custom mapping by default
|
||||
</label>
|
||||
<p className="text-xs text-bambu-gray mt-0.5">
|
||||
When printing to multiple printers, show per-printer AMS mapping expanded
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.per_printer_mapping_expanded ?? false}
|
||||
onChange={(e) => updateSetting('per_printer_mapping_expanded', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
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-DsI-2RLx.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Cw5WdZhp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C__bvgqz.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user