Files
bambuddy/frontend/src/utils/amsHelpers.ts
T
maziggy 9de0c0e7da refactor(printer-card): in-file cleanup ahead of customization work (PR 1a of 3)
Three independent cleanups inside PrintersPage.tsx noticed during the
  architectural audit for the upcoming printer-card customization feature
  (modular widgets + tile-layout dashboard). All three are pure refactor -
  no behavior change, no UI change.

  1. getStatusDisplay i18n fix
     The function returned hardcoded English regardless of locale -
     "Printing" / "Paused" / "Finished" / "Failed" / "Idle". A latent bug
     that surfaced as German/French/etc. printer cards rendering English
     status text. Now takes `t` as its first arg and returns
     t('printers.status.X') keys. printers.status.{idle,printing,paused,
     finished} already existed in all 8 locales; only `failed` was missing
     - added natively across en/de/fr/it/ja/pt-BR/zh-CN/zh-TW.

  2. resolveSlotFill utility extraction
     The ~80-line "Spoolman tag -> slot assignment -> inventory -> AMS
     remain" fill-level chain was triply duplicated - once for regular AMS
     slots, once for HT AMS slots, once for external spool, with `*` /
     `ht*` / `ext*` variable prefixes. Pulled into
     utils/amsHelpers.ts::resolveSlotFill() as a single typed helper. Call
     sites now destructure { effectiveFill, fillSource, slotSpoolForFill,
     linkedSpool, slotAssignmentForFill } - last two are needed for
     downstream FilamentHoverCard link/unlink/assign wiring. Saves 43
     lines net (7,441 -> 7,398).

  3. Delete-confirm aligned with the rest
     The inline delete confirmation used <div className="fixed inset-0
     ..."> directly inside <CardContent> - the only confirmation in the
     file that wasn't using the shared ConfirmModal. Migrated to
     ConfirmModal with the "also delete archives" checkbox passed via a
     new optional `children` prop slot (rendered between message and
     buttons). The new prop generalizes ConfirmModal for any future
     confirm that needs a checkbox or extra inline form.

  Verification: npm run build clean; PrintersPageFillLevel.test.ts
  (10 tests) and PrinterQueueWidget.test.tsx (7 tests) pass.

  PR 1b (sub-component file moves + 2 modal extractions) and PR 1c (the
  4,055-line PrinterCard extraction itself) are deferred to dedicated
  sessions because moving that much inline JSX is too risky to bundle
  with anything else.#	modified:   frontend/src/utils/amsHelpers.ts
2026-05-09 13:46:10 +02:00

392 lines
13 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 type { InventorySpool, LinkedSpoolInfo, SpoolAssignment } from '../api/client';
import { parseUTCDate } from './date';
/**
* Normalize color format from various sources.
* API returns "RRGGBBAA" (8-char), 3MF uses "#RRGGBB" (7-char with hash).
* This normalizes to "#RRGGBB" format.
*/
export function normalizeColor(color: string | null | undefined): string {
if (!color) return '#808080';
// Remove alpha channel if present (8-char hex to 6-char)
const hex = color.replace('#', '').substring(0, 6);
return `#${hex}`;
}
/**
* 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);
}
/**
* 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
);
}
/**
* 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;
}
/**
* 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; type?: string; color?: string; extruderId?: number; remain?: number }[],
usedTrayIds: Set<number>,
preferLowest?: boolean,
): typeof loadedFilaments[number] | undefined {
let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
if (preferLowest) {
nozzleFilaments = [...nozzleFilaments].sort((a, b) => {
const ra = (a.remain ?? -1) >= 0 ? (a.remain ?? -1) : 101;
const rb = (b.remain ?? -1) >= 0 ? (b.remain ?? -1) : 101;
return ra - rb;
});
}
const exactMatch = nozzleFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
filamentTypesCompatible(f.type, req.type) &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch = exactMatch
? undefined
: nozzleFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
filamentTypesCompatible(f.type, req.type) &&
colorsAreSimilar(f.color, req.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
);
}
/**
* 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;
}
/**
* Resolve a slot's effective fill level by walking the
* Spoolman → Inventory → AMS-remain fallback chain.
*
* Priority order:
* 1. Spoolman: spool linked via NFC tag (`linkedSpools[trayTag]`)
* 2. Spoolman: spool slot-assigned without an NFC tag
* (`spoolmanSlotAssignments` → `spoolmanSpools`)
* 3. Bambuddy inventory: spool slot-assigned via the inventory API
* 4. AMS firmware-reported `tray.remain`
*
* Issue #676: if inventory says 0% but AMS reports positive remain, prefer
* AMS — the inventory `weight_used` may be stale or over-counted.
*
* Used identically by regular AMS slots, AMS-HT slots, and external-spool
* slots; before extraction this logic was duplicated three times in
* PrintersPage.tsx with `*`, `ht*`, and `ext*` variable prefixes.
*/
export interface SlotFillContext {
tray: { tray_uuid?: string | null; tag_uid?: string | null; remain?: number } | null | undefined;
printerSerial: string;
printerId: number;
amsId: number;
slotIdx: number;
hasFillLevel: boolean;
linkedSpools: Record<string, LinkedSpoolInfo> | undefined;
spoolmanEnabled: boolean;
spoolmanLoading: boolean;
spoolmanSlotAssignments:
| { printer_id: number; ams_id: number; tray_id: number; spoolman_spool_id: number }[]
| undefined;
spoolmanSpools: InventorySpool[] | undefined;
inventoryAssignment: SpoolAssignment | null | undefined;
}
export interface SlotFillResult {
effectiveFill: number | null;
fillSource: 'spoolman' | 'inventory' | 'ams' | undefined;
slotSpoolForFill: InventorySpool | undefined;
// Also returned for downstream use (FilamentHoverCard link/unlink wiring):
linkedSpool: LinkedSpoolInfo | undefined;
slotAssignmentForFill:
| { printer_id: number; ams_id: number; tray_id: number; spoolman_spool_id: number }
| undefined;
}
export function resolveSlotFill(ctx: SlotFillContext): SlotFillResult {
const trayTag = (
ctx.tray?.tray_uuid ||
ctx.tray?.tag_uid ||
getFallbackSpoolTag(ctx.printerSerial, ctx.amsId, ctx.slotIdx)
)?.toUpperCase();
const linkedSpool = trayTag ? ctx.linkedSpools?.[trayTag] : undefined;
const spoolmanFill = getSpoolmanFillLevel(linkedSpool);
const slotAssignmentForFill =
ctx.spoolmanEnabled && !ctx.spoolmanLoading
? ctx.spoolmanSlotAssignments?.find(
(a) =>
a.printer_id === ctx.printerId &&
a.ams_id === ctx.amsId &&
a.tray_id === ctx.slotIdx,
)
: undefined;
const slotSpoolForFill = slotAssignmentForFill
? ctx.spoolmanSpools?.find((s) => s.id === slotAssignmentForFill.spoolman_spool_id)
: undefined;
const slotSpoolFill =
slotSpoolForFill && (slotSpoolForFill.label_weight ?? 0) > 0
? Math.round(
(Math.max(
0,
(slotSpoolForFill.label_weight ?? 0) - slotSpoolForFill.weight_used,
) /
(slotSpoolForFill.label_weight ?? 1)) *
100,
)
: null;
const inventoryFill = (() => {
const sp = ctx.inventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used != null) {
return Math.round(
(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight) * 100,
);
}
return null;
})();
const trayRemain = ctx.tray?.remain ?? -1;
// #676: inventory 0% + AMS reports positive remain → prefer AMS.
const resolvedInventoryFill =
inventoryFill === 0 && ctx.hasFillLevel && trayRemain > 0 ? null : inventoryFill;
const effectiveFill =
spoolmanFill ??
slotSpoolFill ??
resolvedInventoryFill ??
(ctx.hasFillLevel ? trayRemain : null);
const fillSource =
spoolmanFill !== null || slotSpoolFill !== null
? ('spoolman' as const)
: resolvedInventoryFill !== null
? ('inventory' as const)
: ctx.hasFillLevel
? ('ams' as const)
: undefined;
return {
effectiveFill,
fillSource,
slotSpoolForFill,
linkedSpool,
slotAssignmentForFill,
};
}