Files
bambuddy/frontend/src/hooks/useFilamentMapping.ts
T
maziggy b1cb26f6ee feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
  status/control surface, and the #1766 fix that depends on it.

  Added -- AMS Filament Backup status + control
  - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
    on every push_status. Verified against OrcaSlicer source
    (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
    (None = A1 family / pre-cfg push) preserves today's behaviour.
  - Hold-timer guard (3 s) prevents stale frames from flickering the badge
    back to the printer's old cfg after a user-initiated toggle.
  - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
    method calling _set_print_option("auto_switch_filament", enabled).
  - GET /printers/{id}/inventory-remain endpoint exposes the same map the
    dispatcher uses (internal and Spoolman modes both work uniformly).
  - Small icon badge in the printer card's "Filaments" section header
    (placement reads as printer-wide because the cfg bit is printer-wide,
    not per-AMS). Click to toggle, success toast.
  - 5 i18n keys x 11 locales for the badge UI.

  Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
  - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
    to False when status.ams_filament_backup is False; log the skip.
  - New effectivePreferLowest(setting, backup) helper applied at every
    frontend sort entry point: single-printer PrintModal, multi-printer
    hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
    standalone editor (the last had NO preferLowest awareness at all
    before this change).
  - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
    key exactly, including the banding tie-break (regular AMS < AMS-HT <
    external) so the client-side pre-compute matches the dispatch-time pick.
    An earlier draft used a flat `amsId * 4 + trayId` priority which gave
    external slots (ams_id = -1) a NEGATIVE priority -- caught in code
    review before commit.
  - Settings -> Filament -> "Prefer lowest remaining filament" gets an
    explanatory note about the printer-side AMS Backup dependency, with
    i18n key in all 11 locales.
2026-06-20 12:07:20 +02:00

540 lines
19 KiB
TypeScript

import { useMemo } from 'react';
import { getColorName } from '../utils/colors';
import {
normalizeColor,
normalizeColorForCompare,
colorsAreSimilar,
formatSlotLabel,
getGlobalTrayId,
preferLowestSortKey,
compareSortKeys,
} from '../utils/amsHelpers';
import type { PrinterStatus } from '../api/client';
/**
* Build loaded filaments list from printer status (non-hook version).
* Extracts filaments from all AMS units (regular and HT) and external spool.
*/
export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] {
const filaments: LoadedFilament[] = [];
const amsExtruderMap = printerStatus?.ams_extruder_map;
// Dual-nozzle detection. The backend always emits a 2-entry nozzles array
// (default-stub second entry for single-nozzle printers), so length is not
// a reliable signal. Real second-nozzle hardware sets `nozzle_diameter` from
// the MQTT `right_nozzle_diameter` field (bambu_mqtt.py:2619-2621); without
// that field, nozzles[1] stays at its empty default. Belt-and-braces: a
// populated ams_extruder_map (dual-nozzle with AMS) and >1 vt_tray (only
// dual-nozzle hardware exposes multiple external feeds) each independently
// imply dual-nozzle — keep them as fallbacks for any firmware rev that
// surfaces one signal but not the other. (#1257)
const hasDualNozzle =
Boolean(printerStatus?.nozzles?.[1]?.nozzle_diameter)
|| (amsExtruderMap && Object.keys(amsExtruderMap).length > 0)
|| (printerStatus?.vt_tray?.length ?? 0) > 1;
// Add filaments from all AMS units (regular and HT)
printerStatus?.ams?.forEach((amsUnit) => {
const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
amsUnit.tray.forEach((tray) => {
if (tray.tray_type) {
const color = normalizeColor(tray.tray_color);
filaments.push({
type: tray.tray_type,
color,
colorName: getColorName(color),
amsId: amsUnit.id,
trayId: tray.id,
isHt,
isExternal: false,
label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
trayInfoIdx: tray.tray_info_idx || '',
traySubBrands: tray.tray_sub_brands || '',
extruderId: amsExtruderMap?.[String(amsUnit.id)],
remain: tray.remain ?? -1,
});
}
});
});
// Add external spool(s) if loaded
for (const extTray of printerStatus?.vt_tray ?? []) {
if (extTray.tray_type) {
const color = normalizeColor(extTray.tray_color);
const trayId = extTray.id ?? 254;
const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1;
filaments.push({
type: extTray.tray_type,
color,
colorName: getColorName(color),
amsId: -1,
trayId: trayId - 254,
isHt: false,
isExternal: true,
label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
globalTrayId: trayId,
trayInfoIdx: extTray.tray_info_idx || '',
traySubBrands: extTray.tray_sub_brands || '',
extruderId: hasDualNozzle ? (255 - trayId) : undefined,
remain: extTray.remain ?? -1,
});
}
}
return filaments;
}
/**
* Compute AMS mapping for a printer given filament requirements and printer status.
* This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
*
* Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
*
* The tray_info_idx is a filament type identifier stored in the 3MF file when the user
* slices (e.g., "GFA00" for generic PLA, "P4d64437" for custom presets). If the same
* tray_info_idx appears in only ONE available tray, we use that tray. If multiple trays
* have the same tray_info_idx (e.g., two spools of generic PLA), we fall back to color
* matching among those trays.
*
* @param filamentReqs - Required filaments from the 3MF file
* @param printerStatus - Current printer status with AMS information
* @returns AMS mapping array or undefined if no mapping needed
*/
export function computeAmsMapping(
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
printerStatus: PrinterStatus | undefined,
preferLowest?: boolean,
inventoryByTrayId?: Map<number, number>,
): number[] | undefined {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
const loadedFilaments = buildLoadedFilaments(printerStatus);
if (loadedFilaments.length === 0) return undefined;
// FTS routes any AMS slot to any extruder, so per-nozzle slot restriction
// doesn't apply when it's installed (#1162).
const ftsActive = printerStatus?.fila_switch?.installed === true;
// Track which trays have been assigned to avoid duplicates
const usedTrayIds = new Set<number>();
const comparisons = filamentReqs.filaments.map((req) => {
const reqTrayInfoIdx = req.tray_info_idx || '';
// Get available trays (not already used)
let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
// Nozzle-aware filtering: restrict to trays on the correct nozzle.
// This is a hard filter — cross-nozzle assignment causes print failures
// ("position of left hotend is abnormal"), so we never fall back to wrong-nozzle trays.
// Skip when an FTS is installed: it can route any slot to either extruder.
if (req.nozzle_id != null && !ftsActive) {
available = available.filter((f) => f.extruderId === req.nozzle_id);
}
// Sort lowest-first when the preference is on. Inventory-tracked spools
// sort before MQTT-only ones; see preferLowestSortKey for the rationale.
if (preferLowest) {
available = [...available].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
let idxMatch: LoadedFilament | undefined;
let exactMatch: LoadedFilament | undefined;
let similarMatch: LoadedFilament | undefined;
let typeOnlyMatch: LoadedFilament | undefined;
// Check if tray_info_idx is unique among available trays
if (reqTrayInfoIdx) {
const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
if (idxMatches.length === 1) {
// Unique tray_info_idx - use it as definitive match
idxMatch = idxMatches[0];
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
if (preferLowest) {
idxMatches.sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
exactMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
if (!exactMatch) {
similarMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = idxMatches.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
}
// If no idx match, do standard type/color matching on all available trays
if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
exactMatch = available.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
if (!exactMatch) {
similarMatch = available.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = available.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
return {
slot_id: req.slot_id,
globalTrayId: loaded?.globalTrayId ?? -1,
};
});
// Find the max slot_id to determine array size
const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
if (maxSlotId <= 0) return undefined;
// Create array with -1 for all positions
const mapping = new Array(maxSlotId).fill(-1);
// Fill in tray IDs at correct positions (slot_id - 1)
comparisons.forEach((f) => {
if (f.slot_id && f.slot_id > 0) {
mapping[f.slot_id - 1] = f.globalTrayId;
}
});
return mapping;
}
/**
* Represents a loaded filament in the printer's AMS/HT/External spool holder.
*/
export interface LoadedFilament {
type: string;
color: string;
colorName: string;
amsId: number;
trayId: number;
isHt: boolean;
isExternal: boolean;
label: string;
globalTrayId: number;
/** Unique spool identifier (e.g., "GFA00", "P4d64437") */
trayInfoIdx?: string;
/** Filament subtype name (e.g., "PLA Basic", "PLA Matte", "PETG HF") */
traySubBrands?: string;
/** Extruder ID for dual-nozzle printers (0=right, 1=left) */
extruderId?: number;
/** Remaining filament percentage (0-100), -1 = unknown */
remain: number;
}
/**
* Represents a required filament from the 3MF file.
*/
export interface FilamentRequirement {
slot_id: number;
type: string;
color: string;
used_grams: number;
/** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
tray_info_idx?: string;
/** Target nozzle for dual-nozzle printers (0=right, 1=left) */
nozzle_id?: number;
}
/**
* Status of filament comparison between required and loaded.
*/
export type FilamentStatus = 'match' | 'type_only' | 'mismatch' | 'empty';
/**
* Result of comparing a required filament with loaded filaments.
*/
export interface FilamentComparison extends FilamentRequirement {
loaded: LoadedFilament | undefined;
hasFilament: boolean;
typeMatch: boolean;
colorMatch: boolean;
status: FilamentStatus;
isManual: boolean;
}
interface FilamentRequirementsResponse {
filaments: FilamentRequirement[];
}
interface UseFilamentMappingResult {
/** List of all filaments loaded in the printer */
loadedFilaments: LoadedFilament[];
/** Comparison results for each required filament */
filamentComparison: FilamentComparison[];
/** AMS mapping array for the print command */
amsMapping: number[] | undefined;
/** Whether any required filament type is not loaded */
hasTypeMismatch: boolean;
/** Whether any required filament has a color mismatch */
hasColorMismatch: boolean;
}
/**
* Hook to build loaded filaments list from printer status.
* Extracts filaments from all AMS units (regular and HT) and external spool.
*/
export function useLoadedFilaments(
printerStatus: PrinterStatus | undefined
): LoadedFilament[] {
return useMemo(() => {
return buildLoadedFilaments(printerStatus);
}, [printerStatus]);
}
/**
* Hook to compare required filaments with loaded filaments and build AMS mapping.
* Handles both auto-matching and manual overrides.
*
* @param filamentReqs - Required filaments from the 3MF file
* @param printerStatus - Current printer status with AMS information
* @param manualMappings - Manual slot overrides (slot_id -> globalTrayId)
*/
export function useFilamentMapping(
filamentReqs: FilamentRequirementsResponse | undefined,
printerStatus: PrinterStatus | undefined,
manualMappings: Record<number, number>,
preferLowest?: boolean,
inventoryByTrayId?: Map<number, number>,
): UseFilamentMappingResult {
const loadedFilaments = useLoadedFilaments(printerStatus);
// FTS routes any AMS slot to any extruder, so per-nozzle slot restriction
// doesn't apply when it's installed (#1162).
const ftsActive = printerStatus?.fila_switch?.installed === true;
const filamentComparison = useMemo(() => {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return [];
// Track which trays have been assigned to avoid duplicates
// First, mark all manually assigned trays as used
const usedTrayIds = new Set<number>(Object.values(manualMappings));
return filamentReqs.filaments.map((req) => {
const slotId = req.slot_id || 0;
// Check if there's a manual override for this slot
if (slotId > 0 && manualMappings[slotId] !== undefined) {
const manualTrayId = manualMappings[slotId];
const manualLoaded = loadedFilaments.find((f) => f.globalTrayId === manualTrayId);
if (manualLoaded) {
const typeMatch = manualLoaded.type?.toUpperCase() === req.type?.toUpperCase();
const colorMatch =
normalizeColorForCompare(manualLoaded.color) === normalizeColorForCompare(req.color) ||
colorsAreSimilar(manualLoaded.color, req.color);
let status: FilamentStatus;
if (typeMatch && colorMatch) {
status = 'match';
} else if (typeMatch) {
status = 'type_only';
} else {
status = 'mismatch';
}
return {
...req,
loaded: manualLoaded,
hasFilament: true,
typeMatch,
colorMatch,
status,
isManual: true,
};
}
}
// Auto-match: Find a loaded filament
// Priority: unique tray_info_idx match > exact color match > similar color match > type-only match
// IMPORTANT: Exclude trays that are already assigned (manually or auto)
const reqTrayInfoIdx = req.tray_info_idx || '';
// Get available trays (not already used)
let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
// Nozzle-aware filtering: restrict to trays on the correct nozzle.
// This is a hard filter — cross-nozzle assignment causes print failures.
// Skip when an FTS is installed: it can route any slot to either extruder.
if (req.nozzle_id != null && !ftsActive) {
available = available.filter((f) => f.extruderId === req.nozzle_id);
}
// Sort lowest-first when the preference is on. Inventory-tracked spools
// sort before MQTT-only ones; see preferLowestSortKey for the rationale.
if (preferLowest) {
available = [...available].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
let idxMatch: LoadedFilament | undefined;
let exactMatch: LoadedFilament | undefined;
let similarMatch: LoadedFilament | undefined;
let typeOnlyMatch: LoadedFilament | undefined;
// Check if tray_info_idx is unique among available trays
if (reqTrayInfoIdx) {
const idxMatches = available.filter((f) => f.trayInfoIdx === reqTrayInfoIdx);
if (idxMatches.length === 1) {
// Unique tray_info_idx - use it as definitive match
idxMatch = idxMatches[0];
} else if (idxMatches.length > 1) {
// Multiple trays with same tray_info_idx - use color matching among them
if (preferLowest) {
idxMatches.sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
exactMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
if (!exactMatch) {
similarMatch = idxMatches.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = idxMatches.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
}
// If no idx match, do standard type/color matching on all available trays
if (!idxMatch && !exactMatch && !similarMatch && !typeOnlyMatch) {
exactMatch = available.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
if (!exactMatch) {
similarMatch = available.find(
(f) =>
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
}
if (!exactMatch && !similarMatch) {
typeOnlyMatch = available.find(
(f) => f.type?.toUpperCase() === req.type?.toUpperCase()
);
}
}
const loaded = idxMatch || exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
const hasFilament = !!loaded;
const typeMatch = hasFilament;
// idxMatch is always considered a color match (same spool = same color)
const colorMatch = !!idxMatch || !!exactMatch || !!similarMatch;
// Status: match (tray_info_idx, type+color, or similar color), type_only (type ok, color very different), mismatch (type not found)
let status: FilamentStatus;
if (idxMatch || exactMatch || similarMatch) {
status = 'match';
} else if (typeOnlyMatch) {
status = 'type_only';
} else {
status = 'mismatch';
}
return {
...req,
loaded,
hasFilament,
typeMatch,
colorMatch,
status,
isManual: false,
};
});
}, [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive, inventoryByTrayId]);
// Build AMS mapping from matched filaments
// Format: array matching 3MF filament slot structure
// Position = slot_id - 1 (0-indexed), value = global tray ID or -1 for unused
const amsMapping = useMemo(() => {
if (filamentComparison.length === 0) return undefined;
// Find the max slot_id to determine array size
const maxSlotId = Math.max(...filamentComparison.map((f) => f.slot_id || 0));
if (maxSlotId <= 0) return undefined;
// Create array with -1 for all positions
const mapping = new Array(maxSlotId).fill(-1);
// Fill in tray IDs at correct positions (slot_id - 1)
filamentComparison.forEach((f) => {
if (f.slot_id && f.slot_id > 0) {
mapping[f.slot_id - 1] = f.loaded?.globalTrayId ?? -1;
}
});
return mapping;
}, [filamentComparison]);
const hasTypeMismatch = filamentComparison.some((f) => f.status === 'mismatch');
const hasColorMismatch = filamentComparison.some((f) => f.status === 'type_only');
return {
loadedFilaments,
filamentComparison,
amsMapping,
hasTypeMismatch,
hasColorMismatch,
};
}