amsHelpers.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865
  1. /**
  2. * AMS (Automatic Material System) helper utilities for Bambu Lab printers.
  3. * These functions handle color normalization, slot labeling, and tray ID calculations
  4. * for AMS, AMS-HT, and external spool configurations.
  5. */
  6. import { parseUTCDate } from './date';
  7. import { settingIdToFilamentId } from './filamentIds';
  8. /**
  9. * Normalize color format from various sources for CSS rendering.
  10. * API returns "RRGGBBAA" (8-char), 3MF uses "#RRGGBB" (7-char with hash).
  11. * Result is "#RRGGBB" for opaque colors and "#RRGGBBAA" when alpha < FF —
  12. * CSS accepts both forms on `fill` / `backgroundColor`, and preserving alpha
  13. * lets transparent filaments render translucent instead of collapsing to
  14. * solid black (#1545). Comparison helpers use normalizeColorForCompare which
  15. * still strips alpha, so type/colour matching is unaffected.
  16. */
  17. export function normalizeColor(color: string | null | undefined): string {
  18. if (!color) return '#808080';
  19. const clean = color.replace('#', '');
  20. if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() !== 'ff') {
  21. return `#${clean.substring(0, 8)}`;
  22. }
  23. return `#${clean.substring(0, 6)}`;
  24. }
  25. /**
  26. * Normalize color for comparison (case-insensitive, strip hash and alpha).
  27. */
  28. export function normalizeColorForCompare(color: string | undefined): string {
  29. if (!color) return '';
  30. return color.replace('#', '').toLowerCase().substring(0, 6);
  31. }
  32. /**
  33. * Which side letter stands for a Filament Track Switch inlet: In-A reads as L,
  34. * In-B as R.
  35. *
  36. * This labels the inlet's position, not the nozzle it feeds — the switch can
  37. * route either inlet to either nozzle, and it never reports which pairing is
  38. * live. Anywhere this letter is shown next to a hover target, the tooltip names
  39. * the inlet outright so the two cannot be confused.
  40. */
  41. export const FTS_INLET_SIDE = { A: 'L', B: 'R' } as const;
  42. /**
  43. * Which extruder each switch inlet feeds. Out-A is the left hotend and Out-B the
  44. * right one (measured on an H2C), and the inlet pairs with its own outlet in the
  45. * switch's rest position. Mirrors `backend/app/utils/fts_routing.py`, which
  46. * carries the full reasoning and the reason `fila_switch.out` cannot be used.
  47. */
  48. const FTS_INLET_EXTRUDER: Record<string, number> = { A: 1, B: 0 };
  49. /**
  50. * The extruder an AMS slot feeds, or undefined when it genuinely cannot be told.
  51. *
  52. * Undefined is not the same as extruder 0. K-profiles are per-nozzle, so a slot
  53. * whose nozzle is unknown must not be silently treated as right-hand — that is
  54. * what bound a left-nozzle profile to a slot sitting on the right.
  55. */
  56. export function resolveSlotExtruder(
  57. amsId: number,
  58. trayId: number,
  59. amsExtruderMap: Record<string, number> | undefined,
  60. amsSwitchInlet: Record<string, string> | undefined
  61. ): number | undefined {
  62. // External holder: the tray id names the side. 254/Ext-L feeds extruder 1.
  63. if (amsId === 255) return trayId === 0 || trayId === 1 ? 1 - trayId : undefined;
  64. const mapped = amsExtruderMap?.[String(amsId)];
  65. if (mapped !== undefined) return mapped;
  66. const inlet = amsSwitchInlet?.[String(amsId)];
  67. return inlet ? FTS_INLET_EXTRUDER[inlet.toUpperCase()] : undefined;
  68. }
  69. /**
  70. * AMS unit label using the codebase convention: "AMS-A / AMS-B / ..." for
  71. * regular AMS, "HT-A / HT-B / ..." for AMS-HT (single-tray modules with
  72. * IDs 128-135). The unit id decides: a regular AMS can report one tray.
  73. */
  74. export function getAmsLabel(amsId: number | string): string {
  75. const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId;
  76. const safeId = isNaN(id) ? 0 : id;
  77. if (safeId === 255) return 'External';
  78. // A2L "AMS Lite": the backend normalises its physical unit id 16 to 6 at
  79. // ingest (see a2l-am-unit-16). No regular AMS uses id 6, so this is a safe,
  80. // self-scoping label for the Lite's 4-slot unit.
  81. if (safeId === 6) return 'AMS Lite';
  82. const isHt = isAmsHtUnit(safeId);
  83. const normalizedId = safeId >= 128 ? safeId - 128 : safeId;
  84. const letter = String.fromCharCode(65 + normalizedId);
  85. return isHt ? `HT-${letter}` : `AMS-${letter}`;
  86. }
  87. /**
  88. * Filament type equivalence groups.
  89. * Types within the same group are interchangeable on the printer side
  90. * (e.g., Bambu Lab firmware treats PA-CF and PA12-CF as compatible).
  91. */
  92. const FILAMENT_TYPE_GROUPS: string[][] = [
  93. ['PA-CF', 'PA12-CF', 'PAHT-CF'],
  94. ];
  95. const _equivalenceMap: Record<string, string> = {};
  96. for (const group of FILAMENT_TYPE_GROUPS) {
  97. const canonical = group[0];
  98. for (const t of group) {
  99. _equivalenceMap[t.toUpperCase()] = canonical.toUpperCase();
  100. }
  101. }
  102. /**
  103. * Get the canonical filament type for equivalence matching.
  104. * Types in the same group (e.g., PA-CF / PA12-CF / PAHT-CF) return the same canonical type.
  105. */
  106. export function canonicalFilamentType(type: string | undefined): string {
  107. if (!type) return '';
  108. const upper = type.toUpperCase();
  109. return _equivalenceMap[upper] ?? upper;
  110. }
  111. /**
  112. * Check if two filament types are compatible (same type or same equivalence group).
  113. */
  114. export function filamentTypesCompatible(a: string | undefined, b: string | undefined): boolean {
  115. return canonicalFilamentType(a) === canonicalFilamentType(b);
  116. }
  117. /**
  118. * Check if two colors are visually similar within a threshold.
  119. * Uses RGB component comparison with configurable tolerance.
  120. * @param color1 - First hex color
  121. * @param color2 - Second hex color
  122. * @param threshold - Maximum difference per RGB component (default: 40)
  123. */
  124. export function colorsAreSimilar(
  125. color1: string | undefined,
  126. color2: string | undefined,
  127. threshold = 40
  128. ): boolean {
  129. const hex1 = normalizeColorForCompare(color1);
  130. const hex2 = normalizeColorForCompare(color2);
  131. if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
  132. const r1 = parseInt(hex1.substring(0, 2), 16);
  133. const g1 = parseInt(hex1.substring(2, 4), 16);
  134. const b1 = parseInt(hex1.substring(4, 6), 16);
  135. const r2 = parseInt(hex2.substring(0, 2), 16);
  136. const g2 = parseInt(hex2.substring(2, 4), 16);
  137. const b2 = parseInt(hex2.substring(4, 6), 16);
  138. return (
  139. Math.abs(r1 - r2) <= threshold &&
  140. Math.abs(g1 - g2) <= threshold &&
  141. Math.abs(b1 - b2) <= threshold
  142. );
  143. }
  144. const D65_WHITE: readonly [number, number, number] = [0.95047, 1.0, 1.08883];
  145. const LAB_DELTA = 6 / 29;
  146. /**
  147. * Convert a hex colour to CIE L*a*b* under D65, or null if it is unusable.
  148. *
  149. * Alpha is dropped by `normalizeColorForCompare`, deliberately: the alpha a
  150. * slicer writes for a transparent filament is not a colour the user chose, and
  151. * counting it would stop a transparent filament matching itself.
  152. */
  153. function hexToLab(color: string | undefined): [number, number, number] | null {
  154. const hex = normalizeColorForCompare(color);
  155. if (!hex || hex.length < 6) return null;
  156. const channels = [0, 2, 4].map((i) => parseInt(hex.substring(i, i + 2), 16) / 255);
  157. if (channels.some(Number.isNaN)) return null;
  158. // sRGB gamma -> linear light.
  159. const [r, g, b] = channels.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
  160. const xyz: [number, number, number] = [
  161. 0.4124564 * r + 0.3575761 * g + 0.1804375 * b,
  162. 0.2126729 * r + 0.7151522 * g + 0.072175 * b,
  163. 0.0193339 * r + 0.119192 * g + 0.9503041 * b,
  164. ];
  165. const f = (t: number) =>
  166. t > LAB_DELTA ** 3 ? Math.cbrt(t) : t / (3 * LAB_DELTA * LAB_DELTA) + 4 / 29;
  167. const [fx, fy, fz] = xyz.map((v, i) => f(v / D65_WHITE[i]));
  168. return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
  169. }
  170. /**
  171. * CIEDE2000 colour difference between two L*a*b* triples.
  172. *
  173. * Straight transcription of the CIE formulation with kL = kC = kH = 1, kept
  174. * structurally identical to `perceptual_color_distance` in
  175. * `backend/app/utils/color_utils.py` so the two can be read side by side. They
  176. * must agree: the dialog must not promise a spool the scheduler would not pick.
  177. */
  178. function ciede2000(lab1: [number, number, number], lab2: [number, number, number]): number {
  179. const [l1, a1, b1] = lab1;
  180. const [l2, a2, b2] = lab2;
  181. const rad = (deg: number) => (deg * Math.PI) / 180;
  182. const c1 = Math.hypot(a1, b1);
  183. const c2 = Math.hypot(a2, b2);
  184. const cBar7 = ((c1 + c2) / 2) ** 7;
  185. const g = 0.5 * (1 - Math.sqrt(cBar7 / (cBar7 + 25 ** 7)));
  186. const a1p = (1 + g) * a1;
  187. const a2p = (1 + g) * a2;
  188. const c1p = Math.hypot(a1p, b1);
  189. const c2p = Math.hypot(a2p, b2);
  190. const hue = (ap: number, bp: number) => {
  191. if (ap === 0 && bp === 0) return 0;
  192. const deg = (Math.atan2(bp, ap) * 180) / Math.PI;
  193. return deg < 0 ? deg + 360 : deg;
  194. };
  195. const h1p = hue(a1p, b1);
  196. const h2p = hue(a2p, b2);
  197. const dlp = l2 - l1;
  198. const dcp = c2p - c1p;
  199. const chromaProduct = c1p * c2p;
  200. let dhp = 0;
  201. if (chromaProduct !== 0) {
  202. dhp = h2p - h1p;
  203. if (dhp > 180) dhp -= 360;
  204. else if (dhp < -180) dhp += 360;
  205. }
  206. const dhpBig = 2 * Math.sqrt(chromaProduct) * Math.sin(rad(dhp) / 2);
  207. const lBar = (l1 + l2) / 2;
  208. const cBar = (c1p + c2p) / 2;
  209. let hBar: number;
  210. if (chromaProduct === 0) hBar = h1p + h2p;
  211. else if (Math.abs(h1p - h2p) <= 180) hBar = (h1p + h2p) / 2;
  212. else if (h1p + h2p < 360) hBar = (h1p + h2p + 360) / 2;
  213. else hBar = (h1p + h2p - 360) / 2;
  214. const t =
  215. 1 -
  216. 0.17 * Math.cos(rad(hBar - 30)) +
  217. 0.24 * Math.cos(rad(2 * hBar)) +
  218. 0.32 * Math.cos(rad(3 * hBar + 6)) -
  219. 0.2 * Math.cos(rad(4 * hBar - 63));
  220. const cBarP7 = cBar ** 7;
  221. const rc = 2 * Math.sqrt(cBarP7 / (cBarP7 + 25 ** 7));
  222. const sl = 1 + (0.015 * (lBar - 50) ** 2) / Math.sqrt(20 + (lBar - 50) ** 2);
  223. const sc = 1 + 0.045 * cBar;
  224. const sh = 1 + 0.015 * cBar * t;
  225. const rt = -Math.sin(rad(2 * (30 * Math.exp(-(((hBar - 275) / 25) ** 2))))) * rc;
  226. const dL = dlp / sl;
  227. const dC = dcp / sc;
  228. const dH = dhpBig / sh;
  229. return Math.sqrt(dL * dL + dC * dC + dH * dH + rt * dC * dH);
  230. }
  231. /**
  232. * Perceptual distance between two hex colours, or null if either is unusable.
  233. *
  234. * Used to rank the candidates `colorsAreSimilar` admits. Eligibility stays the
  235. * per-channel box that shipped; this only decides which of several eligible
  236. * spools is closest, so no spool becomes usable or unusable because of it.
  237. *
  238. * It ranks by how far apart the colours *look*, not how far apart their numbers
  239. * are. RGB distance overweights blue badly enough to invert the answer: against
  240. * a required `#1E4821` green, a purple `#38202F` is the nearer of two eligible
  241. * spools by RGB and four times the further once measured perceptually.
  242. *
  243. * The scale is CIEDE2000 delta-E, where ~1 is a just-noticeable difference —
  244. * far smaller numbers than the RGB distances this replaced, and not comparable
  245. * against an RGB threshold.
  246. */
  247. export function colorDistance(
  248. color1: string | undefined,
  249. color2: string | undefined,
  250. ): number | null {
  251. const lab1 = hexToLab(color1);
  252. const lab2 = hexToLab(color2);
  253. if (!lab1 || !lab2) return null;
  254. return ciede2000(lab1, lab2);
  255. }
  256. /**
  257. * The closest colour match among `candidates`, or undefined if none is similar
  258. * enough to qualify.
  259. *
  260. * Callers pass candidates in the order they already established — slot order,
  261. * or the "prefer lowest remaining" sort. Ties keep the earliest of them, so
  262. * that order survives as the tie-break and Prefer Lowest still decides between
  263. * two equally close spools, which is the case it was actually for.
  264. *
  265. * This exists so the four matchers that pick a spool (`autoMatchFilament`,
  266. * `computeAmsMapping`, `computeMappingWithOverrides`, `computeMatchDetails`)
  267. * share one ranking rule instead of four copies of "first one within
  268. * tolerance", which made the winner depend on AMS slot order.
  269. */
  270. export function findNearestSimilar<T>(
  271. candidates: T[],
  272. requiredColor: string | undefined,
  273. getColor: (candidate: T) => string | undefined,
  274. ): T | undefined {
  275. let best: T | undefined;
  276. let bestDistance = Infinity;
  277. for (const candidate of candidates) {
  278. const color = getColor(candidate);
  279. if (!colorsAreSimilar(color, requiredColor)) continue;
  280. const distance = colorDistance(color, requiredColor);
  281. if (distance === null) continue;
  282. // Strict <: an equally close candidate never displaces an earlier one.
  283. if (distance < bestDistance) {
  284. best = candidate;
  285. bestDistance = distance;
  286. }
  287. }
  288. return best;
  289. }
  290. /** Classify an empty AMS slot for UI rendering (#1322 follow-up).
  291. *
  292. * "physical" — firmware positively confirmed no spool (state 9 or 10). The
  293. * bambu_mqtt handler now promotes tray_exist_bits=0 slots to state=9, so
  294. * every empty-by-bitmask slot lands here regardless of firmware payload
  295. * shape.
  296. *
  297. * "reset" — tray_type is missing/empty but firmware hasn't confirmed
  298. * emptiness (state is null, 3, or any non-9/10 value). Typically a slot
  299. * the user cleared with "Reset Slot" where a physical spool may still be
  300. * loaded but unassigned.
  301. *
  302. * Returns null when the slot is loaded (tray_type is present).
  303. */
  304. export function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | null; exists?: boolean | null } | null | undefined): 'physical' | 'reset' | null {
  305. if (tray?.tray_type) return null;
  306. // tray_exist_bits is firmware's authoritative presence signal: a non-RFID
  307. // spool the firmware can't identify is physically present (exists === true)
  308. // but carries no tray_type, so it must read as "?" (loaded, unconfigured),
  309. // never "Empty" (#2527). BambuStudio draws it the same way. Only fall back to
  310. // the state=9/10 heuristic when the bitmask was unavailable (exists == null).
  311. if (tray?.exists === true) return 'reset';
  312. if (tray?.exists === false) return 'physical';
  313. return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset';
  314. }
  315. /**
  316. * Format slot label for display in the UI.
  317. * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
  318. * @param trayId - Tray/slot ID within the AMS unit (0-3)
  319. * @param isHt - Whether this is an AMS-HT unit (single tray)
  320. * @param isExternal - Whether this is the external spool holder
  321. */
  322. export function formatSlotLabel(
  323. amsId: number,
  324. trayId: number,
  325. isHt: boolean,
  326. isExternal: boolean
  327. ): string {
  328. if (isExternal) return 'Ext';
  329. // Convert AMS ID to letter (A, B, C, D)
  330. // AMS-HT uses IDs starting at 128
  331. const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
  332. if (isHt) return `HT-${letter}`;
  333. return `${letter}${trayId + 1}`;
  334. }
  335. /**
  336. * Calculate global tray ID for MQTT command.
  337. * Used in the ams_mapping array sent to the printer.
  338. * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
  339. * @param trayId - Tray/slot ID within the AMS unit
  340. * @param isExternal - Whether this is the external spool holder
  341. * @returns Global tray ID (0-15 for AMS, 128+ for AMS-HT, 254 for external)
  342. */
  343. export function getGlobalTrayId(
  344. amsId: number,
  345. trayId: number,
  346. isExternal: boolean
  347. ): number {
  348. if (isExternal) return 254 + trayId;
  349. // AMS-HT units have IDs starting at 128 with a single tray — use ID directly
  350. if (amsId >= 128) return amsId;
  351. return amsId * 4 + trayId;
  352. }
  353. /**
  354. * AMS-HT units are ids 128-135, one tray each. The id decides, not the tray
  355. * count: a regular AMS can report fewer than four trays.
  356. */
  357. export function isAmsHtUnit(amsId: number): boolean {
  358. return amsId >= 128 && amsId <= 135;
  359. }
  360. /**
  361. * The tray reported for a slot of a regular AMS, by its tray id. An AMS can
  362. * report only some of its trays, and then a position in the list is not the
  363. * slot. Only when the ids do not tell the trays apart (a firmware that sends
  364. * none: they all read 0) does the position decide.
  365. */
  366. export function trayForSlot<T extends { id: number }>(trays: T[], slotIdx: number): T | undefined {
  367. const idsUsable = new Set(trays.map(tray => tray.id)).size === trays.length;
  368. return idsUsable ? trays.find(tray => tray.id === slotIdx) : trays[slotIdx];
  369. }
  370. /**
  371. * Whether some spool of this AMS is threaded into the feed tube. The AMS turns
  372. * its spools together, so one loaded slot keeps it from rotating them while
  373. * drying (the firmware refuses with dry_sf_reason 3, consumable at AMS outlet).
  374. *
  375. * Tray state 11 means loaded, but not on every firmware, so the slot the
  376. * printer reports as loaded (tray_now) counts too, and on a two-nozzle printer
  377. * the slot each hotend is fed from (extruder_slots), since tray_now names only
  378. * the active nozzle's.
  379. */
  380. export function amsHasLoadedTray(
  381. ams: { id: number; tray: { id: number; state?: number | null }[] } | undefined,
  382. trayNow: number | null | undefined,
  383. extruderSlots: Record<string, { ams_id: number | null; has_filament: boolean }> | null | undefined,
  384. ): boolean {
  385. if (!ams) return false;
  386. if (ams.tray.some(tray => tray.state === 11)) return true;
  387. if (trayNow != null && ams.tray.some(tray => getGlobalTrayId(ams.id, tray.id, false) === trayNow)) return true;
  388. return Object.values(extruderSlots ?? {}).some(slot => slot.ams_id === ams.id && slot.has_filament);
  389. }
  390. /**
  391. * Get fill bar color based on spool fill level.
  392. * Matches PrintersPage thresholds and Bambu Lab brand green.
  393. */
  394. export function getFillBarColor(fillLevel: number): string {
  395. if (fillLevel > 50) return '#00ae42'; // Green - good
  396. if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
  397. return '#ef4444'; // Red - critical (< 15%)
  398. }
  399. /**
  400. * Calculate fill level from Spoolman weight data.
  401. * Used as the first source in the Spoolman → Inventory → AMS fill chain.
  402. */
  403. export function getSpoolmanFillLevel(
  404. linkedSpool: { remaining_weight: number | null; filament_weight: number | null } | undefined
  405. ): number | null {
  406. if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
  407. || linkedSpool.filament_weight <= 0) return null;
  408. return Math.min(100, Math.round(
  409. (linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
  410. ));
  411. }
  412. function toFixedHex(value: number, width: number): string {
  413. const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
  414. return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);
  415. }
  416. // 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)
  417. function hashSerialToHex32(serial: string): string {
  418. const input = (serial || '').trim().toUpperCase();
  419. let hash = 0x811c9dc5;
  420. for (let i = 0; i < input.length; i++) {
  421. hash ^= input.charCodeAt(i);
  422. hash = Math.imul(hash, 0x01000193);
  423. }
  424. return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
  425. }
  426. /**
  427. * Generate a stable fallback spool tag for slots without RFID identifiers.
  428. * Returns a 16-char hex string derived from the printer serial + slot position.
  429. */
  430. export function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {
  431. return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;
  432. }
  433. /**
  434. * Check if a scheduled time is a placeholder far-future date.
  435. * Placeholder dates (more than 6 months out) are treated as ASAP.
  436. */
  437. export function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {
  438. if (!scheduledTime) return false;
  439. const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
  440. return (parseUTCDate(scheduledTime)?.getTime() ?? 0) > sixMonthsFromNow;
  441. }
  442. /**
  443. * Banding tie-break for `preferLowestSortKey`, mirroring backend
  444. * `PrintScheduler._slot_priority` so regular AMS < AMS-HT < external on ties
  445. * regardless of the raw `ams_id`. In particular, `ams_id = -1` (VT / external
  446. * in `buildLoadedFilaments`) MUST NOT sort to a negative number or it would
  447. * beat AMS slot 0 — backend clamps to 10_000.
  448. */
  449. function slotPriority(amsId: number | undefined, trayId: number | undefined): number {
  450. if (amsId == null || amsId < 0) return 10_000;
  451. if (amsId >= 128) return 1_000 + (amsId - 128) * 4 + (trayId ?? 0);
  452. return amsId * 4 + (trayId ?? 0);
  453. }
  454. /**
  455. * Two-tier sort key for the "Prefer Lowest Remaining Filament" preference (#1766).
  456. *
  457. * Mirrors backend `_prefer_lowest_sort_key` in `print_scheduler.py:1161` so the
  458. * client-side sort that PrintModal pre-computes lines up with the dispatch-time
  459. * sort. Inventory-bound spools sort before MQTT-only ones (tier 0 vs tier 1) so
  460. * the user's tracked grams beat the printer's per-cent estimate; within each
  461. * tier the lowest value wins, with the slot-position tie-break above so the
  462. * order is deterministic across identical spools.
  463. *
  464. * `inventoryByTrayId` is the `globalTrayId -> grams_remaining` map derived from
  465. * the user's spool assignments. Pass `undefined` to fall back to remain%-only
  466. * sorting (preserves pre-#1766 behaviour for callers that don't yet wire it in).
  467. */
  468. export function preferLowestSortKey(
  469. f: { globalTrayId: number; amsId?: number; trayId?: number; remain?: number },
  470. inventoryByTrayId: Map<number, number> | undefined,
  471. ): [number, number, number] {
  472. const slot = slotPriority(f.amsId, f.trayId);
  473. if (inventoryByTrayId && inventoryByTrayId.has(f.globalTrayId)) {
  474. return [0, inventoryByTrayId.get(f.globalTrayId) ?? 0, slot];
  475. }
  476. const remain = f.remain ?? -1;
  477. return [1, remain >= 0 ? remain : 101, slot];
  478. }
  479. /** Tuple compare for `preferLowestSortKey` outputs. */
  480. export function compareSortKeys(
  481. a: [number, number, number],
  482. b: [number, number, number],
  483. ): number {
  484. return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
  485. }
  486. /**
  487. * Effective "Prefer lowest remaining filament" preference for a given printer,
  488. * gated on its AMS Filament Backup state (#1766).
  489. *
  490. * Without backup, the printer can't switch to a second spool when the picked
  491. * one runs out — so even with the user setting on, sorting toward the lowest
  492. * leaves the print at risk. Mirrors the backend gate in
  493. * `print_scheduler.py::_compute_ams_mapping_for_printer`. `null`/`undefined`
  494. * (unknown state, e.g. A1 family) preserves today's behaviour intentionally.
  495. */
  496. export function effectivePreferLowest(
  497. setting: boolean | undefined,
  498. amsFilamentBackup: boolean | null | undefined,
  499. ): boolean {
  500. if (!setting) return false;
  501. return amsFilamentBackup !== false;
  502. }
  503. /**
  504. * Auto-match a filament requirement to a loaded filament, respecting nozzle constraints.
  505. * Used by both single-printer (FilamentMapping) and multi-printer (InlineMappingEditor) paths.
  506. */
  507. export function autoMatchFilament(
  508. req: { type?: string; color?: string; nozzle_id?: number | null },
  509. loadedFilaments: { globalTrayId: number; amsId?: number; trayId?: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
  510. usedTrayIds: Set<number>,
  511. preferLowest?: boolean,
  512. inventoryByTrayId?: Map<number, number>,
  513. ): typeof loadedFilaments[number] | undefined {
  514. let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
  515. if (preferLowest) {
  516. nozzleFilaments = [...nozzleFilaments].sort((a, b) =>
  517. compareSortKeys(
  518. preferLowestSortKey(a, inventoryByTrayId),
  519. preferLowestSortKey(b, inventoryByTrayId),
  520. ),
  521. );
  522. }
  523. const exactMatch = nozzleFilaments.find(
  524. (f) =>
  525. !usedTrayIds.has(f.globalTrayId) &&
  526. filamentTypesCompatible(f.type, req.type) &&
  527. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  528. );
  529. const similarMatch = exactMatch
  530. ? undefined
  531. : findNearestSimilar(
  532. nozzleFilaments.filter(
  533. (f) => !usedTrayIds.has(f.globalTrayId) && filamentTypesCompatible(f.type, req.type),
  534. ),
  535. req.color,
  536. (f) => f.color,
  537. );
  538. const typeOnlyMatch =
  539. exactMatch || similarMatch
  540. ? undefined
  541. : nozzleFilaments.find(
  542. (f) => !usedTrayIds.has(f.globalTrayId) && filamentTypesCompatible(f.type, req.type)
  543. );
  544. return exactMatch ?? similarMatch ?? typeOnlyMatch;
  545. }
  546. /**
  547. * Filter loaded filaments to those valid for a given nozzle requirement.
  548. * For single-nozzle printers (nozzle_id is null/undefined), returns all filaments.
  549. */
  550. export function filterFilamentsByNozzle<T extends { extruderId?: number }>(
  551. loadedFilaments: T[],
  552. nozzleId: number | undefined | null,
  553. ): T[] {
  554. return loadedFilaments.filter(
  555. (f) => nozzleId == null || f.extruderId === nozzleId
  556. );
  557. }
  558. /**
  559. * List the distinct nozzle diameters the printer actually reports (#2618).
  560. * Mirrors the backend `_installed_nozzle_diameters`: reads each
  561. * `status.nozzles[].nozzle_diameter`, skips the empty-string / non-positive
  562. * defaults that populate a NozzleInfo before MQTT fills it in, and dedupes.
  563. *
  564. * Returns e.g. `['0.4']` (single-nozzle) or `['0.4', '0.6']` (dual-nozzle). An
  565. * empty array means "the printer hasn't told us its nozzle hardware" — callers
  566. * that need to fetch per-nozzle should fall back to their own default rather
  567. * than treating it as "no nozzles". Preserves the bare decimal string form the
  568. * status carries so it can be passed straight to `getKProfiles`.
  569. */
  570. export function installedNozzleDiameters(
  571. status: { nozzles?: { nozzle_diameter?: string }[] } | null | undefined,
  572. ): string[] {
  573. const seen = new Set<string>();
  574. const result: string[] = [];
  575. for (const nozzle of status?.nozzles ?? []) {
  576. const raw = (nozzle?.nozzle_diameter ?? '').trim();
  577. if (!raw || !(parseFloat(raw) > 0) || seen.has(raw)) continue;
  578. seen.add(raw);
  579. result.push(raw);
  580. }
  581. return result;
  582. }
  583. /**
  584. * Resolve the installed nozzle diameter feeding a given AMS unit, so the
  585. * Configure-AMS-Slot picker filters filament presets by the nozzle actually on
  586. * the machine instead of assuming 0.4mm (#1899).
  587. *
  588. * On dual-nozzle printers (H2D) each AMS is bound to one extruder via
  589. * `ams_extruder_map` (amsId → extruder index), so we read that nozzle's
  590. * diameter. `status.nozzles` is indexed by extruder id -- [0] is the RIGHT
  591. * hotend and [1] the left, measured on an H2D fitted with 0.4 left / 0.6 right
  592. * -- so indexing it by the extruder is correct. (This comment used to say
  593. * "0=left/primary, 1=right", which was backwards; the code was always right.)
  594. * Single-nozzle printers have no map entry and fall back to index 0. Returns
  595. * undefined when the printer hasn't reported nozzle hardware yet, letting the
  596. * caller keep its own default.
  597. * Diameter is the bare decimal string the status carries, e.g. "0.4" / "0.6".
  598. */
  599. export function resolveSlotNozzleDiameter(
  600. status: {
  601. nozzles?: { nozzle_diameter?: string }[];
  602. ams_extruder_map?: Record<string, number>;
  603. } | null | undefined,
  604. amsId: number,
  605. ): string | undefined {
  606. const nozzles = status?.nozzles;
  607. if (!nozzles || nozzles.length === 0) return undefined;
  608. const extruderIdx = status?.ams_extruder_map?.[String(amsId)] ?? 0;
  609. const diameter = nozzles[extruderIdx]?.nozzle_diameter || nozzles[0]?.nozzle_diameter;
  610. return diameter || undefined;
  611. }
  612. /**
  613. * Detect Bambu Lab RFID-tagged spool by tray_uuid (32 hex) or tag_uid (16 hex).
  614. *
  615. * Permissive zero-string check: any non-zero non-empty value returns true. The
  616. * function exists to suppress assign/unassign actions on RFID-managed slots
  617. * whose state is owned by the printer firmware — manual changes there would be
  618. * overwritten on the next RFID re-read (eye → pen icon in BambuStudio).
  619. */
  620. export function isBambuLabSpool(tray: {
  621. tray_uuid?: string | null;
  622. tag_uid?: string | null;
  623. } | null | undefined): boolean {
  624. if (!tray) return false;
  625. if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') return true;
  626. if (tray.tag_uid && tray.tag_uid !== '0000000000000000') return true;
  627. return false;
  628. }
  629. /**
  630. * Does a stored slot preset still describe what is in the slot?
  631. *
  632. * `slot_preset_mappings` remembers the preset a slot was last configured with,
  633. * and the AMS slot card shows that name ahead of anything the printer reports —
  634. * which is what lets a slot keep a hand-picked name like "# Bambu PLA Matte
  635. * @BBL H2C 0.4 nozzle (Custom)" instead of the plain catalog one. The cost is
  636. * that a swapped spool leaves the previous spool's name on the card until the
  637. * row is refetched, and until then a cached row outranks live telemetry.
  638. *
  639. * The printer's own `tray_info_idx` settles it, but only for official Bambu
  640. * presets, where the two id forms differ by one letter (setting_id `GFSA01` ↔
  641. * filament_id `GFA01`). A user preset genuinely carries two unrelated ids — a
  642. * slot configured with `PFUSa3b8b0c664c142` reports `tray_info_idx=P8a85d5a` —
  643. * and a local preset (`local_68`) has no printer-side id at all, so neither can
  644. * be checked here and both keep the stored name. Same for a slot reporting no
  645. * id (generic filament with no tag), which is the case the row exists for.
  646. *
  647. * Rows written since #3216 also record the filament id the slot was given
  648. * with the preset (`presetTrayInfoIdx`). That settles every kind of preset:
  649. * once the printer reports a different id, something else — the slicer's
  650. * Device tab, the printer's screen — re-configured the slot, and the row no
  651. * longer describes it.
  652. */
  653. export function slotPresetDescribesTray(
  654. presetId: string | null | undefined,
  655. trayInfoIdx: string | null | undefined,
  656. presetTrayInfoIdx?: string | null,
  657. ): boolean {
  658. const preset = (presetId || '').split('_')[0].toUpperCase();
  659. const tray = (trayInfoIdx || '').split('_')[0].toUpperCase();
  660. const recorded = (presetTrayInfoIdx || '').split('_')[0].toUpperCase();
  661. if (recorded && tray) return recorded === tray;
  662. if (!preset.startsWith('GFS') || !tray.startsWith('GF')) return true;
  663. // The full id, suffix included, decides some presets' filament_id (#3387).
  664. return settingIdToFilamentId((presetId || '').toUpperCase()) === tray;
  665. }
  666. export interface AmsTrayLike {
  667. id: number;
  668. tray_type: string | null | undefined;
  669. tray_sub_brands: string | null | undefined;
  670. tray_color: string | null | undefined;
  671. tray_info_idx: string | null | undefined;
  672. }
  673. export interface AmsUnitLike {
  674. id: number;
  675. tray: AmsTrayLike[];
  676. }
  677. /**
  678. * One row in the AMS Backup modal: a group of slots that back each other up
  679. * (length >= 2), or a single non-empty slot with no peer (length === 1).
  680. */
  681. export interface BackupGroup {
  682. /** Stable key — same across renders for the same material+extruder. */
  683. key: string;
  684. /** Bambu preset ID (tray_info_idx) when matched on preset; null otherwise. */
  685. presetId: string | null;
  686. /** 0 = right / single, 1 = left. Scoping field for dual-nozzle. */
  687. extruder: number;
  688. /** Display name from the first slot's tray_sub_brands (or tray_type). */
  689. displayName: string;
  690. /** Tray colour from the first slot, for the swatch in the modal. */
  691. trayColor: string | null;
  692. /** Member slots, in (ams_id, slot_idx) order. */
  693. members: Array<{ amsId: number; slotIdx: number; globalTrayId: number }>;
  694. }
  695. /**
  696. * Canonicalise a hex colour for identity comparison. Mirrors the backend
  697. * `_normalize_color_for_id`. Strips the leading `#`, uppercases, and drops
  698. * the alpha channel when 8 chars long so `1A1A1AFF` matches `1A1A1A`.
  699. */
  700. function normalizeColorForId(raw: string | null | undefined): string {
  701. let s = (raw || '').trim().replace(/^#/, '').toUpperCase();
  702. if (s.length === 8) s = s.slice(0, 6);
  703. return s;
  704. }
  705. /**
  706. * Compute backup pairs for the AMS Backup modal (#1762).
  707. *
  708. * Strict identity rule (mirrors backend `_material_identity_internal` /
  709. * `_material_identity_spoolman`): slots pair ONLY when they share the same
  710. * Bambu preset ID (`tray_info_idx`, e.g. "GFA00") AND the same colour. The
  711. * preset identifies the filament profile (PETG HF, PLA Basic, etc.); the
  712. * colour pins the variant — three PETG HF spools in different colours
  713. * absolutely don't back each other up. User-tagged spools without a preset
  714. * never pair — Bambu's firmware backup logic relies on the preset, and
  715. * pairing on cosmetic name/colour match alone would let two visually-
  716. * identical but materially-different spools be treated as backups.
  717. *
  718. * Empty slots are skipped entirely. Every non-empty slot is returned — slots
  719. * without a peer come back as 1-member entries so the modal can list them as
  720. * "Slots without a backup peer".
  721. *
  722. * On dual-extruder printers (H2D / H2C / X2D), pairs are scoped per extruder
  723. * side — the firmware can't cross extruders even with the global backup bit
  724. * set.
  725. */
  726. export function computeBackupGroups(
  727. amsUnits: AmsUnitLike[] | undefined,
  728. amsExtruderMap: Record<string, number> | undefined,
  729. isDualNozzle: boolean,
  730. ): BackupGroup[] {
  731. if (!amsUnits || amsUnits.length === 0) return [];
  732. // Defensive dedup: ``status.ams`` is expected to be unique by `ams.id`, but
  733. // observed in the wild to occasionally contain duplicate entries (e.g. on
  734. // VP-aggregated switch printers or during MQTT partial-update merges). A
  735. // duplicate would surface as "AMS-A slot 1" rendered twice with different
  736. // materials, which is impossible physically and visually broken. First
  737. // occurrence per `ams.id` wins.
  738. const seenIds = new Set<number>();
  739. const uniqueAms: AmsUnitLike[] = [];
  740. for (const ams of amsUnits) {
  741. if (seenIds.has(ams.id)) continue;
  742. seenIds.add(ams.id);
  743. uniqueAms.push(ams);
  744. }
  745. const byKey = new Map<string, BackupGroup>();
  746. for (const ams of uniqueAms) {
  747. const extruder = isDualNozzle ? Number(amsExtruderMap?.[String(ams.id)] ?? 0) : 0;
  748. ams.tray.forEach((tray, slotIdx) => {
  749. if (!tray?.tray_type) return; // empty slot
  750. const preset = (tray.tray_info_idx || '').trim();
  751. const globalTrayId = getGlobalTrayId(ams.id, slotIdx, false);
  752. const member = { amsId: ams.id, slotIdx, globalTrayId };
  753. let key: string;
  754. let presetId: string | null;
  755. if (preset) {
  756. // Same Bambu profile is necessary but NOT sufficient — different colours
  757. // of the same PETG HF profile can't back each other up. Bake the colour
  758. // into the identity key, normalised to strip alpha and case.
  759. const color = normalizeColorForId(tray.tray_color);
  760. key = `preset:${preset}|color:${color}#${extruder}`;
  761. presetId = preset;
  762. } else {
  763. // No preset → never group with anything else. Unique-per-slot key.
  764. key = `unmatched:${ams.id}:${slotIdx}#${extruder}`;
  765. presetId = null;
  766. }
  767. const existing = byKey.get(key);
  768. if (existing) {
  769. existing.members.push(member);
  770. } else {
  771. byKey.set(key, {
  772. key,
  773. presetId,
  774. extruder,
  775. displayName: tray.tray_sub_brands || tray.tray_type || '',
  776. trayColor: tray.tray_color ?? null,
  777. members: [member],
  778. });
  779. }
  780. });
  781. }
  782. // Stable sort: extruder first (so the modal can section per side on
  783. // dual-nozzle), then pairs before lone slots, then by name, then by first
  784. // member's global tray id for deterministic rendering.
  785. return Array.from(byKey.values()).sort((a, b) => {
  786. if (a.extruder !== b.extruder) return a.extruder - b.extruder;
  787. const aLone = a.members.length === 1 ? 1 : 0;
  788. const bLone = b.members.length === 1 ? 1 : 0;
  789. if (aLone !== bLone) return aLone - bLone;
  790. if (a.displayName !== b.displayName) return a.displayName.localeCompare(b.displayName);
  791. return a.members[0].globalTrayId - b.members[0].globalTrayId;
  792. });
  793. }