Files
bambuddy/frontend/src/utils/amsHelpers.ts
T
maziggy c5e0055864 Track which nozzle each AMS feeds through the Filament Track Switch
With a switch fitted, an AMS is not wired to a nozzle any more. It is
plumbed into one of the switch's two inlets and reaches both nozzles
through it, so every unit reports its extruder as "not fixed" (0xE) and
ams_extruder_map comes back empty. Bambuddy had nothing to fall back on
but the AMS unit number, so AMS-A was badged R and AMS-B was badged L
purely because their unit ids are 0 and 1, a third unit got no badge at
all, and every one of those labels was wrong.

The binding needed no new telemetry. BambuStudio reads it out of bits
24-27 of the same AMS info string we already parse for the type and the
extruder id -- 0 is In-B, 1 is In-A -- and it only means anything when a
switch is installed, because without one 0xE really does mean an
uninitialised unit. That gates the read, which forced the switch block to
be parsed before the AMS block: _handle_ams_data runs early in
_process_message and _update_state only much later, so the binding was
lost on every frame that carried both.

The badge letters stay L and R, In-A reading as L, with the inlet named
in full in the tooltip -- the letter is the inlet's position and not a
claim about which nozzle that AMS feeds, since the switch can route
either inlet to either outlet. Both views update live now. The switch
fields were missing from the WebSocket payload, and the frontend
shallow-merges each push over its cached status, so an absent field kept
whatever the last full fetch left behind. The broadcast dedup key had no
term for them either, so "Join IN-B" on the printer screen moved nothing:
the binding is not in the tray component of that key, and not in the AMS
change-hash, which covers tray fields only and must stay that way because
it drives Spoolman sync.

The rest of this is the calibration half, which is where it actually
bites. K-profiles are calibrated per nozzle and the printer numbers its
calibration table per nozzle too, so entry 16 exists on both hotends and
means a different profile on each. A tray holds exactly one index. Move
an AMS to the other inlet and every configured slot in it silently keeps
pointing at the old hotend's table -- measured on the maintainer's H2C, a
black PLA calibrated 0.018 left and 0.020 right stayed on the left
profile after the move, and a manual RFID re-read only re-asserted the
same wrong one. Bambu has not solved this either; their AMS dialog
carries "TODO: fila_switcher broken the connection of ams->extruder"
above the line that decides which nozzle's profiles to offer.

Three copies of "which extruder is this slot on" each ended in else 0,
which on a switch machine filed every profile under the right-hand
nozzle. They now share one resolver that returns None for "unknown",
because unknown and extruder 0 are very different answers on a
dual-nozzle machine and conflating them is what bound a left-nozzle
profile to a slot sitting on the right. The per-slot K value on the
printer card had the same confusion from the other direction: its lookup
was keyed on cali_idx alone, so one nozzle's K silently overwrote the
other's.

Moving an AMS now re-selects each configured slot's counterpart profile
for the nozzle it has arrived on. Only the calibration binding moves, and
only for slots whose spool already has a profile for that nozzle:
configuring a slot is a deliberate preparation step, so a slot we know
nothing about, or a spool calibrated on one hotend only, is left exactly
as the operator set it. Nothing fires on the first sighting of a binding
either, since every reconnect learns them afresh and re-applying there
would overwrite a choice made by hand.

Configure Slot resolves against the slot's own nozzle throughout. Option
identity carries the extruder, so a filament calibrated on both hotends
gives two distinguishable entries instead of two that collapse into
whichever the printer listed first; options name the hotend; matches are
scoped to the nozzle the slot feeds, with the other hotend's profiles
still reachable under Other K profiles; and the slot's active index is
resolved as a pair rather than followed into the wrong table.

Inlet to nozzle is one table, In-A to the left hotend and In-B to the
right, measured rather than assumed -- fila_switch.out cannot be used for
it, reporting [1, 1] unchanged across a 90-second capture, both outlets
claiming the same extruder. The print dialog picks up the same inlet
labelling in its slot dropdown, replacing a left/right hint that never
rendered because it matched snow-encoded values against global tray ids;
decoding it correctly would not have saved it, since the firmware never
reports which inlet is currently paired with which outlet. The dialog
also notes when every filament a print needs sits behind one inlet, which
is legal but slow -- a change between two filaments on the same inlet
retracts the outgoing spool all the way back to its AMS, where a change
across the two only retracts as far as the switch.

Assigning an AMS to an inlet remains printer-side. BambuStudio can read
that binding and has no command to write it, so there is no wire format
to copy.
2026-08-16 15:59:59 +02:00

769 lines
29 KiB
TypeScript

/**
* AMS (Automatic Material System) helper utilities for Bambu Lab printers.
* These functions handle color normalization, slot labeling, and tray ID calculations
* for AMS, AMS-HT, and external spool configurations.
*/
import { parseUTCDate } from './date';
/**
* Normalize color format from various sources for CSS rendering.
* API returns "RRGGBBAA" (8-char), 3MF uses "#RRGGBB" (7-char with hash).
* Result is "#RRGGBB" for opaque colors and "#RRGGBBAA" when alpha < FF —
* CSS accepts both forms on `fill` / `backgroundColor`, and preserving alpha
* lets transparent filaments render translucent instead of collapsing to
* solid black (#1545). Comparison helpers use normalizeColorForCompare which
* still strips alpha, so type/colour matching is unaffected.
*/
export function normalizeColor(color: string | null | undefined): string {
if (!color) return '#808080';
const clean = color.replace('#', '');
if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() !== 'ff') {
return `#${clean.substring(0, 8)}`;
}
return `#${clean.substring(0, 6)}`;
}
/**
* Normalize color for comparison (case-insensitive, strip hash and alpha).
*/
export function normalizeColorForCompare(color: string | undefined): string {
if (!color) return '';
return color.replace('#', '').toLowerCase().substring(0, 6);
}
/**
* Which side letter stands for a Filament Track Switch inlet: In-A reads as L,
* In-B as R.
*
* This labels the inlet's position, not the nozzle it feeds — the switch can
* route either inlet to either nozzle, and it never reports which pairing is
* live. Anywhere this letter is shown next to a hover target, the tooltip names
* the inlet outright so the two cannot be confused.
*/
export const FTS_INLET_SIDE = { A: 'L', B: 'R' } as const;
/**
* Which extruder each switch inlet feeds. Out-A is the left hotend and Out-B the
* right one (measured on an H2C), and the inlet pairs with its own outlet in the
* switch's rest position. Mirrors `backend/app/utils/fts_routing.py`, which
* carries the full reasoning and the reason `fila_switch.out` cannot be used.
*/
const FTS_INLET_EXTRUDER: Record<string, number> = { A: 1, B: 0 };
/**
* The extruder an AMS slot feeds, or undefined when it genuinely cannot be told.
*
* Undefined is not the same as extruder 0. K-profiles are per-nozzle, so a slot
* whose nozzle is unknown must not be silently treated as right-hand — that is
* what bound a left-nozzle profile to a slot sitting on the right.
*/
export function resolveSlotExtruder(
amsId: number,
trayId: number,
amsExtruderMap: Record<string, number> | undefined,
amsSwitchInlet: Record<string, string> | undefined
): number | undefined {
// External holder: the tray id names the side. 254/Ext-L feeds extruder 1.
if (amsId === 255) return trayId === 0 || trayId === 1 ? 1 - trayId : undefined;
const mapped = amsExtruderMap?.[String(amsId)];
if (mapped !== undefined) return mapped;
const inlet = amsSwitchInlet?.[String(amsId)];
return inlet ? FTS_INLET_EXTRUDER[inlet.toUpperCase()] : undefined;
}
/**
* AMS unit label using the codebase convention: "AMS-A / AMS-B / ..." for
* regular AMS, "HT-A / HT-B / ..." for AMS-HT (single-tray modules with
* IDs starting at 128). `trayCount` is required because the type can't be
* inferred from the id alone — regular AMS IDs 0-3 can collide with the
* normalized HT range otherwise.
*/
export function getAmsLabel(amsId: number | string, trayCount: number): string {
const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId;
const safeId = isNaN(id) ? 0 : id;
if (safeId === 255) return 'External';
// A2L "AMS Lite": the backend normalises its physical unit id 16 to 6 at
// ingest (see a2l-am-unit-16). No regular AMS uses id 6, so this is a safe,
// self-scoping label for the Lite's 4-slot unit.
if (safeId === 6) return 'AMS Lite';
const isHt = trayCount === 1;
const normalizedId = safeId >= 128 ? safeId - 128 : safeId;
const letter = String.fromCharCode(65 + normalizedId);
return isHt ? `HT-${letter}` : `AMS-${letter}`;
}
/**
* Filament type equivalence groups.
* Types within the same group are interchangeable on the printer side
* (e.g., Bambu Lab firmware treats PA-CF and PA12-CF as compatible).
*/
const FILAMENT_TYPE_GROUPS: string[][] = [
['PA-CF', 'PA12-CF', 'PAHT-CF'],
];
const _equivalenceMap: Record<string, string> = {};
for (const group of FILAMENT_TYPE_GROUPS) {
const canonical = group[0];
for (const t of group) {
_equivalenceMap[t.toUpperCase()] = canonical.toUpperCase();
}
}
/**
* Get the canonical filament type for equivalence matching.
* Types in the same group (e.g., PA-CF / PA12-CF / PAHT-CF) return the same canonical type.
*/
export function canonicalFilamentType(type: string | undefined): string {
if (!type) return '';
const upper = type.toUpperCase();
return _equivalenceMap[upper] ?? upper;
}
/**
* Check if two filament types are compatible (same type or same equivalence group).
*/
export function filamentTypesCompatible(a: string | undefined, b: string | undefined): boolean {
return canonicalFilamentType(a) === canonicalFilamentType(b);
}
/**
* Check if two colors are visually similar within a threshold.
* Uses RGB component comparison with configurable tolerance.
* @param color1 - First hex color
* @param color2 - Second hex color
* @param threshold - Maximum difference per RGB component (default: 40)
*/
export function colorsAreSimilar(
color1: string | undefined,
color2: string | undefined,
threshold = 40
): boolean {
const hex1 = normalizeColorForCompare(color1);
const hex2 = normalizeColorForCompare(color2);
if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
const r1 = parseInt(hex1.substring(0, 2), 16);
const g1 = parseInt(hex1.substring(2, 4), 16);
const b1 = parseInt(hex1.substring(4, 6), 16);
const r2 = parseInt(hex2.substring(0, 2), 16);
const g2 = parseInt(hex2.substring(2, 4), 16);
const b2 = parseInt(hex2.substring(4, 6), 16);
return (
Math.abs(r1 - r2) <= threshold &&
Math.abs(g1 - g2) <= threshold &&
Math.abs(b1 - b2) <= threshold
);
}
const D65_WHITE: readonly [number, number, number] = [0.95047, 1.0, 1.08883];
const LAB_DELTA = 6 / 29;
/**
* Convert a hex colour to CIE L*a*b* under D65, or null if it is unusable.
*
* Alpha is dropped by `normalizeColorForCompare`, deliberately: the alpha a
* slicer writes for a transparent filament is not a colour the user chose, and
* counting it would stop a transparent filament matching itself.
*/
function hexToLab(color: string | undefined): [number, number, number] | null {
const hex = normalizeColorForCompare(color);
if (!hex || hex.length < 6) return null;
const channels = [0, 2, 4].map((i) => parseInt(hex.substring(i, i + 2), 16) / 255);
if (channels.some(Number.isNaN)) return null;
// sRGB gamma -> linear light.
const [r, g, b] = channels.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
const xyz: [number, number, number] = [
0.4124564 * r + 0.3575761 * g + 0.1804375 * b,
0.2126729 * r + 0.7151522 * g + 0.072175 * b,
0.0193339 * r + 0.119192 * g + 0.9503041 * b,
];
const f = (t: number) =>
t > LAB_DELTA ** 3 ? Math.cbrt(t) : t / (3 * LAB_DELTA * LAB_DELTA) + 4 / 29;
const [fx, fy, fz] = xyz.map((v, i) => f(v / D65_WHITE[i]));
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
}
/**
* CIEDE2000 colour difference between two L*a*b* triples.
*
* Straight transcription of the CIE formulation with kL = kC = kH = 1, kept
* structurally identical to `perceptual_color_distance` in
* `backend/app/utils/color_utils.py` so the two can be read side by side. They
* must agree: the dialog must not promise a spool the scheduler would not pick.
*/
function ciede2000(lab1: [number, number, number], lab2: [number, number, number]): number {
const [l1, a1, b1] = lab1;
const [l2, a2, b2] = lab2;
const rad = (deg: number) => (deg * Math.PI) / 180;
const c1 = Math.hypot(a1, b1);
const c2 = Math.hypot(a2, b2);
const cBar7 = ((c1 + c2) / 2) ** 7;
const g = 0.5 * (1 - Math.sqrt(cBar7 / (cBar7 + 25 ** 7)));
const a1p = (1 + g) * a1;
const a2p = (1 + g) * a2;
const c1p = Math.hypot(a1p, b1);
const c2p = Math.hypot(a2p, b2);
const hue = (ap: number, bp: number) => {
if (ap === 0 && bp === 0) return 0;
const deg = (Math.atan2(bp, ap) * 180) / Math.PI;
return deg < 0 ? deg + 360 : deg;
};
const h1p = hue(a1p, b1);
const h2p = hue(a2p, b2);
const dlp = l2 - l1;
const dcp = c2p - c1p;
const chromaProduct = c1p * c2p;
let dhp = 0;
if (chromaProduct !== 0) {
dhp = h2p - h1p;
if (dhp > 180) dhp -= 360;
else if (dhp < -180) dhp += 360;
}
const dhpBig = 2 * Math.sqrt(chromaProduct) * Math.sin(rad(dhp) / 2);
const lBar = (l1 + l2) / 2;
const cBar = (c1p + c2p) / 2;
let hBar: number;
if (chromaProduct === 0) hBar = h1p + h2p;
else if (Math.abs(h1p - h2p) <= 180) hBar = (h1p + h2p) / 2;
else if (h1p + h2p < 360) hBar = (h1p + h2p + 360) / 2;
else hBar = (h1p + h2p - 360) / 2;
const t =
1 -
0.17 * Math.cos(rad(hBar - 30)) +
0.24 * Math.cos(rad(2 * hBar)) +
0.32 * Math.cos(rad(3 * hBar + 6)) -
0.2 * Math.cos(rad(4 * hBar - 63));
const cBarP7 = cBar ** 7;
const rc = 2 * Math.sqrt(cBarP7 / (cBarP7 + 25 ** 7));
const sl = 1 + (0.015 * (lBar - 50) ** 2) / Math.sqrt(20 + (lBar - 50) ** 2);
const sc = 1 + 0.045 * cBar;
const sh = 1 + 0.015 * cBar * t;
const rt = -Math.sin(rad(2 * (30 * Math.exp(-(((hBar - 275) / 25) ** 2))))) * rc;
const dL = dlp / sl;
const dC = dcp / sc;
const dH = dhpBig / sh;
return Math.sqrt(dL * dL + dC * dC + dH * dH + rt * dC * dH);
}
/**
* Perceptual distance between two hex colours, or null if either is unusable.
*
* Used to rank the candidates `colorsAreSimilar` admits. Eligibility stays the
* per-channel box that shipped; this only decides which of several eligible
* spools is closest, so no spool becomes usable or unusable because of it.
*
* It ranks by how far apart the colours *look*, not how far apart their numbers
* are. RGB distance overweights blue badly enough to invert the answer: against
* a required `#1E4821` green, a purple `#38202F` is the nearer of two eligible
* spools by RGB and four times the further once measured perceptually.
*
* The scale is CIEDE2000 delta-E, where ~1 is a just-noticeable difference —
* far smaller numbers than the RGB distances this replaced, and not comparable
* against an RGB threshold.
*/
export function colorDistance(
color1: string | undefined,
color2: string | undefined,
): number | null {
const lab1 = hexToLab(color1);
const lab2 = hexToLab(color2);
if (!lab1 || !lab2) return null;
return ciede2000(lab1, lab2);
}
/**
* The closest colour match among `candidates`, or undefined if none is similar
* enough to qualify.
*
* Callers pass candidates in the order they already established — slot order,
* or the "prefer lowest remaining" sort. Ties keep the earliest of them, so
* that order survives as the tie-break and Prefer Lowest still decides between
* two equally close spools, which is the case it was actually for.
*
* This exists so the four matchers that pick a spool (`autoMatchFilament`,
* `computeAmsMapping`, `computeMappingWithOverrides`, `computeMatchDetails`)
* share one ranking rule instead of four copies of "first one within
* tolerance", which made the winner depend on AMS slot order.
*/
export function findNearestSimilar<T>(
candidates: T[],
requiredColor: string | undefined,
getColor: (candidate: T) => string | undefined,
): T | undefined {
let best: T | undefined;
let bestDistance = Infinity;
for (const candidate of candidates) {
const color = getColor(candidate);
if (!colorsAreSimilar(color, requiredColor)) continue;
const distance = colorDistance(color, requiredColor);
if (distance === null) continue;
// Strict <: an equally close candidate never displaces an earlier one.
if (distance < bestDistance) {
best = candidate;
bestDistance = distance;
}
}
return best;
}
/**
* Format slot label for display in the UI.
* @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
* @param trayId - Tray/slot ID within the AMS unit (0-3)
* @param isHt - Whether this is an AMS-HT unit (single tray)
* @param isExternal - Whether this is the external spool holder
*/
export function formatSlotLabel(
amsId: number,
trayId: number,
isHt: boolean,
isExternal: boolean
): string {
if (isExternal) return 'Ext';
// Convert AMS ID to letter (A, B, C, D)
// AMS-HT uses IDs starting at 128
const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
if (isHt) return `HT-${letter}`;
return `${letter}${trayId + 1}`;
}
/**
* Calculate global tray ID for MQTT command.
* Used in the ams_mapping array sent to the printer.
* @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
* @param trayId - Tray/slot ID within the AMS unit
* @param isExternal - Whether this is the external spool holder
* @returns Global tray ID (0-15 for AMS, 128+ for AMS-HT, 254 for external)
*/
export function getGlobalTrayId(
amsId: number,
trayId: number,
isExternal: boolean
): number {
if (isExternal) return 254 + trayId;
// AMS-HT units have IDs starting at 128 with a single tray — use ID directly
if (amsId >= 128) return amsId;
return amsId * 4 + trayId;
}
/**
* Get fill bar color based on spool fill level.
* Matches PrintersPage thresholds and Bambu Lab brand green.
*/
export function getFillBarColor(fillLevel: number): string {
if (fillLevel > 50) return '#00ae42'; // Green - good
if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
return '#ef4444'; // Red - critical (< 15%)
}
/**
* Calculate fill level from Spoolman weight data.
* Used as the first source in the Spoolman → Inventory → AMS fill chain.
*/
export function getSpoolmanFillLevel(
linkedSpool: { remaining_weight: number | null; filament_weight: number | null } | undefined
): number | null {
if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
|| linkedSpool.filament_weight <= 0) return null;
return Math.min(100, Math.round(
(linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
));
}
function toFixedHex(value: number, width: number): string {
const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);
}
// 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)
function hashSerialToHex32(serial: string): string {
const input = (serial || '').trim().toUpperCase();
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
}
/**
* Generate a stable fallback spool tag for slots without RFID identifiers.
* Returns a 16-char hex string derived from the printer serial + slot position.
*/
export function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {
return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;
}
/**
* Get minimum datetime for scheduling (now + 1 minute).
* Returns ISO string format for datetime-local input.
*/
export function getMinDateTime(): string {
const now = new Date();
now.setMinutes(now.getMinutes() + 1);
return now.toISOString().slice(0, 16);
}
/**
* Check if a scheduled time is a placeholder far-future date.
* Placeholder dates (more than 6 months out) are treated as ASAP.
*/
export function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {
if (!scheduledTime) return false;
const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
return (parseUTCDate(scheduledTime)?.getTime() ?? 0) > sixMonthsFromNow;
}
/**
* Banding tie-break for `preferLowestSortKey`, mirroring backend
* `PrintScheduler._slot_priority` so regular AMS < AMS-HT < external on ties
* regardless of the raw `ams_id`. In particular, `ams_id = -1` (VT / external
* in `buildLoadedFilaments`) MUST NOT sort to a negative number or it would
* beat AMS slot 0 — backend clamps to 10_000.
*/
function slotPriority(amsId: number | undefined, trayId: number | undefined): number {
if (amsId == null || amsId < 0) return 10_000;
if (amsId >= 128) return 1_000 + (amsId - 128) * 4 + (trayId ?? 0);
return amsId * 4 + (trayId ?? 0);
}
/**
* Two-tier sort key for the "Prefer Lowest Remaining Filament" preference (#1766).
*
* Mirrors backend `_prefer_lowest_sort_key` in `print_scheduler.py:1161` so the
* client-side sort that PrintModal pre-computes lines up with the dispatch-time
* sort. Inventory-bound spools sort before MQTT-only ones (tier 0 vs tier 1) so
* the user's tracked grams beat the printer's per-cent estimate; within each
* tier the lowest value wins, with the slot-position tie-break above so the
* order is deterministic across identical spools.
*
* `inventoryByTrayId` is the `globalTrayId -> grams_remaining` map derived from
* the user's spool assignments. Pass `undefined` to fall back to remain%-only
* sorting (preserves pre-#1766 behaviour for callers that don't yet wire it in).
*/
export function preferLowestSortKey(
f: { globalTrayId: number; amsId?: number; trayId?: number; remain?: number },
inventoryByTrayId: Map<number, number> | undefined,
): [number, number, number] {
const slot = slotPriority(f.amsId, f.trayId);
if (inventoryByTrayId && inventoryByTrayId.has(f.globalTrayId)) {
return [0, inventoryByTrayId.get(f.globalTrayId) ?? 0, slot];
}
const remain = f.remain ?? -1;
return [1, remain >= 0 ? remain : 101, slot];
}
/** Tuple compare for `preferLowestSortKey` outputs. */
export function compareSortKeys(
a: [number, number, number],
b: [number, number, number],
): number {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
}
/**
* Effective "Prefer lowest remaining filament" preference for a given printer,
* gated on its AMS Filament Backup state (#1766).
*
* Without backup, the printer can't switch to a second spool when the picked
* one runs out — so even with the user setting on, sorting toward the lowest
* leaves the print at risk. Mirrors the backend gate in
* `print_scheduler.py::_compute_ams_mapping_for_printer`. `null`/`undefined`
* (unknown state, e.g. A1 family) preserves today's behaviour intentionally.
*/
export function effectivePreferLowest(
setting: boolean | undefined,
amsFilamentBackup: boolean | null | undefined,
): boolean {
if (!setting) return false;
return amsFilamentBackup !== false;
}
/**
* Auto-match a filament requirement to a loaded filament, respecting nozzle constraints.
* Used by both single-printer (FilamentMapping) and multi-printer (InlineMappingEditor) paths.
*/
export function autoMatchFilament(
req: { type?: string; color?: string; nozzle_id?: number | null },
loadedFilaments: { globalTrayId: number; amsId?: number; trayId?: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
usedTrayIds: Set<number>,
preferLowest?: boolean,
inventoryByTrayId?: Map<number, number>,
): typeof loadedFilaments[number] | undefined {
let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
if (preferLowest) {
nozzleFilaments = [...nozzleFilaments].sort((a, b) =>
compareSortKeys(
preferLowestSortKey(a, inventoryByTrayId),
preferLowestSortKey(b, inventoryByTrayId),
),
);
}
const exactMatch = nozzleFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
filamentTypesCompatible(f.type, req.type) &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = exactMatch
? undefined
: findNearestSimilar(
nozzleFilaments.filter(
(f) => !usedTrayIds.has(f.globalTrayId) && filamentTypesCompatible(f.type, req.type),
),
req.color,
(f) => f.color,
);
const typeOnlyMatch =
exactMatch || similarMatch
? undefined
: nozzleFilaments.find(
(f) => !usedTrayIds.has(f.globalTrayId) && filamentTypesCompatible(f.type, req.type)
);
return exactMatch ?? similarMatch ?? typeOnlyMatch;
}
/**
* Filter loaded filaments to those valid for a given nozzle requirement.
* For single-nozzle printers (nozzle_id is null/undefined), returns all filaments.
*/
export function filterFilamentsByNozzle<T extends { extruderId?: number }>(
loadedFilaments: T[],
nozzleId: number | undefined | null,
): T[] {
return loadedFilaments.filter(
(f) => nozzleId == null || f.extruderId === nozzleId
);
}
/**
* List the distinct nozzle diameters the printer actually reports (#2618).
* Mirrors the backend `_installed_nozzle_diameters`: reads each
* `status.nozzles[].nozzle_diameter`, skips the empty-string / non-positive
* defaults that populate a NozzleInfo before MQTT fills it in, and dedupes.
*
* Returns e.g. `['0.4']` (single-nozzle) or `['0.4', '0.6']` (dual-nozzle). An
* empty array means "the printer hasn't told us its nozzle hardware" — callers
* that need to fetch per-nozzle should fall back to their own default rather
* than treating it as "no nozzles". Preserves the bare decimal string form the
* status carries so it can be passed straight to `getKProfiles`.
*/
export function installedNozzleDiameters(
status: { nozzles?: { nozzle_diameter?: string }[] } | null | undefined,
): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const nozzle of status?.nozzles ?? []) {
const raw = (nozzle?.nozzle_diameter ?? '').trim();
if (!raw || !(parseFloat(raw) > 0) || seen.has(raw)) continue;
seen.add(raw);
result.push(raw);
}
return result;
}
/**
* Resolve the installed nozzle diameter feeding a given AMS unit, so the
* Configure-AMS-Slot picker filters filament presets by the nozzle actually on
* the machine instead of assuming 0.4mm (#1899).
*
* On dual-nozzle printers (H2D) each AMS is bound to one extruder via
* `ams_extruder_map` (amsId → extruder index, 0=left/primary, 1=right), so we
* read that nozzle's diameter. Single-nozzle printers have no map entry and
* fall back to the primary nozzle (index 0). Returns undefined when the printer
* hasn't reported nozzle hardware yet, letting the caller keep its own default.
* Diameter is the bare decimal string the status carries, e.g. "0.4" / "0.6".
*/
export function resolveSlotNozzleDiameter(
status: {
nozzles?: { nozzle_diameter?: string }[];
ams_extruder_map?: Record<string, number>;
} | null | undefined,
amsId: number,
): string | undefined {
const nozzles = status?.nozzles;
if (!nozzles || nozzles.length === 0) return undefined;
const extruderIdx = status?.ams_extruder_map?.[String(amsId)] ?? 0;
const diameter = nozzles[extruderIdx]?.nozzle_diameter || nozzles[0]?.nozzle_diameter;
return diameter || undefined;
}
/**
* Detect Bambu Lab RFID-tagged spool by tray_uuid (32 hex) or tag_uid (16 hex).
*
* Permissive zero-string check: any non-zero non-empty value returns true. The
* function exists to suppress assign/unassign actions on RFID-managed slots
* whose state is owned by the printer firmware — manual changes there would be
* overwritten on the next RFID re-read (eye → pen icon in BambuStudio).
*/
export function isBambuLabSpool(tray: {
tray_uuid?: string | null;
tag_uid?: string | null;
} | null | undefined): boolean {
if (!tray) return false;
if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') return true;
if (tray.tag_uid && tray.tag_uid !== '0000000000000000') return true;
return false;
}
export interface AmsTrayLike {
id: number;
tray_type: string | null | undefined;
tray_sub_brands: string | null | undefined;
tray_color: string | null | undefined;
tray_info_idx: string | null | undefined;
}
export interface AmsUnitLike {
id: number;
tray: AmsTrayLike[];
}
/**
* One row in the AMS Backup modal: a group of slots that back each other up
* (length >= 2), or a single non-empty slot with no peer (length === 1).
*/
export interface BackupGroup {
/** Stable key — same across renders for the same material+extruder. */
key: string;
/** Bambu preset ID (tray_info_idx) when matched on preset; null otherwise. */
presetId: string | null;
/** 0 = right / single, 1 = left. Scoping field for dual-nozzle. */
extruder: number;
/** Display name from the first slot's tray_sub_brands (or tray_type). */
displayName: string;
/** Tray colour from the first slot, for the swatch in the modal. */
trayColor: string | null;
/** Member slots, in (ams_id, slot_idx) order. */
members: Array<{ amsId: number; slotIdx: number; globalTrayId: number }>;
}
/**
* Canonicalise a hex colour for identity comparison. Mirrors the backend
* `_normalize_color_for_id`. Strips the leading `#`, uppercases, and drops
* the alpha channel when 8 chars long so `1A1A1AFF` matches `1A1A1A`.
*/
function normalizeColorForId(raw: string | null | undefined): string {
let s = (raw || '').trim().replace(/^#/, '').toUpperCase();
if (s.length === 8) s = s.slice(0, 6);
return s;
}
/**
* Compute backup pairs for the AMS Backup modal (#1762).
*
* Strict identity rule (mirrors backend `_material_identity_internal` /
* `_material_identity_spoolman`): slots pair ONLY when they share the same
* Bambu preset ID (`tray_info_idx`, e.g. "GFA00") AND the same colour. The
* preset identifies the filament profile (PETG HF, PLA Basic, etc.); the
* colour pins the variant — three PETG HF spools in different colours
* absolutely don't back each other up. User-tagged spools without a preset
* never pair — Bambu's firmware backup logic relies on the preset, and
* pairing on cosmetic name/colour match alone would let two visually-
* identical but materially-different spools be treated as backups.
*
* Empty slots are skipped entirely. Every non-empty slot is returned — slots
* without a peer come back as 1-member entries so the modal can list them as
* "Slots without a backup peer".
*
* On dual-extruder printers (H2D / H2C / X2D), pairs are scoped per extruder
* side — the firmware can't cross extruders even with the global backup bit
* set.
*/
export function computeBackupGroups(
amsUnits: AmsUnitLike[] | undefined,
amsExtruderMap: Record<string, number> | undefined,
isDualNozzle: boolean,
): BackupGroup[] {
if (!amsUnits || amsUnits.length === 0) return [];
// Defensive dedup: ``status.ams`` is expected to be unique by `ams.id`, but
// observed in the wild to occasionally contain duplicate entries (e.g. on
// VP-aggregated switch printers or during MQTT partial-update merges). A
// duplicate would surface as "AMS-A slot 1" rendered twice with different
// materials, which is impossible physically and visually broken. First
// occurrence per `ams.id` wins.
const seenIds = new Set<number>();
const uniqueAms: AmsUnitLike[] = [];
for (const ams of amsUnits) {
if (seenIds.has(ams.id)) continue;
seenIds.add(ams.id);
uniqueAms.push(ams);
}
const byKey = new Map<string, BackupGroup>();
for (const ams of uniqueAms) {
const extruder = isDualNozzle ? Number(amsExtruderMap?.[String(ams.id)] ?? 0) : 0;
ams.tray.forEach((tray, slotIdx) => {
if (!tray?.tray_type) return; // empty slot
const preset = (tray.tray_info_idx || '').trim();
const globalTrayId = getGlobalTrayId(ams.id, slotIdx, false);
const member = { amsId: ams.id, slotIdx, globalTrayId };
let key: string;
let presetId: string | null;
if (preset) {
// Same Bambu profile is necessary but NOT sufficient — different colours
// of the same PETG HF profile can't back each other up. Bake the colour
// into the identity key, normalised to strip alpha and case.
const color = normalizeColorForId(tray.tray_color);
key = `preset:${preset}|color:${color}#${extruder}`;
presetId = preset;
} else {
// No preset → never group with anything else. Unique-per-slot key.
key = `unmatched:${ams.id}:${slotIdx}#${extruder}`;
presetId = null;
}
const existing = byKey.get(key);
if (existing) {
existing.members.push(member);
} else {
byKey.set(key, {
key,
presetId,
extruder,
displayName: tray.tray_sub_brands || tray.tray_type || '',
trayColor: tray.tray_color ?? null,
members: [member],
});
}
});
}
// Stable sort: extruder first (so the modal can section per side on
// dual-nozzle), then pairs before lone slots, then by name, then by first
// member's global tray id for deterministic rendering.
return Array.from(byKey.values()).sort((a, b) => {
if (a.extruder !== b.extruder) return a.extruder - b.extruder;
const aLone = a.members.length === 1 ? 1 : 0;
const bLone = b.members.length === 1 ? 1 : 0;
if (aLone !== bLone) return aLone - bLone;
if (a.displayName !== b.displayName) return a.displayName.localeCompare(b.displayName);
return a.members[0].globalTrayId - b.members[0].globalTrayId;
});
}