mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 21:21:54 +02:00
Reporter wanted to select a transparent filament colour in the spool
editor; CMW-ISS confirmed on v0.2.5b1 that AMS-detected transparent
spools were silently labelled "Black" in the filament-mapping dropdown
because the colour name resolver dropped the alpha byte and the underlying
RGB 000000 HSL-bucketed to "Black". Spoolman already supported 8-digit
hex; the built-in inventory didn't.
Eight collapsing sites fixed together so transparent reaches the user
intact:
- frontend/src/utils/colors.ts: hexToColorName / getColorName /
resolveSpoolColorName / isLightColor short-circuit to "Clear" for
alpha=00 before HSL bucketing or catalog lookup
- frontend/src/utils/amsHelpers.ts::normalizeColor preserves the alpha
byte when alpha < FF (normalizeColorForCompare unchanged so type/colour
matching is unaffected)
- frontend/src/components/spool-form/constants.ts: new
{ name: 'Clear', hex: '00000000' } preset in QUICK_COLORS
- frontend/src/components/spool-form/ColorSection.tsx: hex draft accepts
0-8 chars, commits at 6 (+FF) or 8 verbatim; blur pads 7-char to 8;
selectColor passes 6-char as +FF / 8-char verbatim; isSelected matches
on full rgba; swatch buttons paint a checkerboard for alpha=00
- backend/app/api/routes/printers.py::get_available_filaments preserves
the full rgba on both AMS and vt_tray branches (6-char dedup key
unchanged)
- backend/app/services/spoolman.py::parse_ams_tray drops the silent
00000000 -> F5E6D3FF cream rewrite — the swatch renderer paints a
checkerboard underlay for alpha < FF already (added in #1154), so the
rewrite was hidden technical debt that made every AMS-detected
transparent spool land in inventory as cream
- backend/app/services/spool_tag_matcher.py::create_spool_from_tray
short-circuits the colour-catalog lookup for alpha=00 and stores
color_name="Clear" directly — otherwise an RFID-tagged transparent
Bambu spool would resolve against the #000000 catalog row (or "Black"
via the HSL fallback) before the frontend's resolver ever saw it
- Two shared helpers in utils/colors.ts — getSwatchStyle(rgba) (style
object: checkerboard for alpha=00) and spoolColorString(rgba)
(8-char hex string for SVG fill) — applied to every simple-swatch
site that would otherwise have rendered Clear spools as solid black:
LabelTemplatePickerModal, SpoolBuddyInventoryPage (SpoolCircle + dot),
SpoolBuddyAmsPage (both branches), SpoolBuddyWriteTagPage (4 sites),
ForecastPanel, AssignToAmsModal, AssignSpoolModal (both branches),
InventorySpoolInfoCard, TagDetectedModal, SpoolInfoCard, LinkSpoolModal,
and the FilamentSwatch tooltip title fallback
Intentionally NOT changed: native <input type="color"> keeps 6-char hex
(can't pick alpha; onChange still emits +FF, correct); Spoolman's
_find_or_create_filament strips alpha (Spoolman catalog is 6-char only);
print_scheduler colour matching strips alpha (auto-mapping treats Clear
as Black for slot compatibility); label_renderer prints "#RRGGBB" on the
physical label (printers can't print transparency, swatch fill via
_color_from_hex still honours alpha).
279 lines
9.8 KiB
TypeScript
279 lines
9.8 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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|