mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 23:45:40 +02:00
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
392 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|