amsHelpers.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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. /**
  8. * Normalize color format from various sources for CSS rendering.
  9. * API returns "RRGGBBAA" (8-char), 3MF uses "#RRGGBB" (7-char with hash).
  10. * Result is "#RRGGBB" for opaque colors and "#RRGGBBAA" when alpha < FF —
  11. * CSS accepts both forms on `fill` / `backgroundColor`, and preserving alpha
  12. * lets transparent filaments render translucent instead of collapsing to
  13. * solid black (#1545). Comparison helpers use normalizeColorForCompare which
  14. * still strips alpha, so type/colour matching is unaffected.
  15. */
  16. export function normalizeColor(color: string | null | undefined): string {
  17. if (!color) return '#808080';
  18. const clean = color.replace('#', '');
  19. if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() !== 'ff') {
  20. return `#${clean.substring(0, 8)}`;
  21. }
  22. return `#${clean.substring(0, 6)}`;
  23. }
  24. /**
  25. * Normalize color for comparison (case-insensitive, strip hash and alpha).
  26. */
  27. export function normalizeColorForCompare(color: string | undefined): string {
  28. if (!color) return '';
  29. return color.replace('#', '').toLowerCase().substring(0, 6);
  30. }
  31. /**
  32. * Filament type equivalence groups.
  33. * Types within the same group are interchangeable on the printer side
  34. * (e.g., Bambu Lab firmware treats PA-CF and PA12-CF as compatible).
  35. */
  36. const FILAMENT_TYPE_GROUPS: string[][] = [
  37. ['PA-CF', 'PA12-CF', 'PAHT-CF'],
  38. ];
  39. const _equivalenceMap: Record<string, string> = {};
  40. for (const group of FILAMENT_TYPE_GROUPS) {
  41. const canonical = group[0];
  42. for (const t of group) {
  43. _equivalenceMap[t.toUpperCase()] = canonical.toUpperCase();
  44. }
  45. }
  46. /**
  47. * Get the canonical filament type for equivalence matching.
  48. * Types in the same group (e.g., PA-CF / PA12-CF / PAHT-CF) return the same canonical type.
  49. */
  50. export function canonicalFilamentType(type: string | undefined): string {
  51. if (!type) return '';
  52. const upper = type.toUpperCase();
  53. return _equivalenceMap[upper] ?? upper;
  54. }
  55. /**
  56. * Check if two filament types are compatible (same type or same equivalence group).
  57. */
  58. export function filamentTypesCompatible(a: string | undefined, b: string | undefined): boolean {
  59. return canonicalFilamentType(a) === canonicalFilamentType(b);
  60. }
  61. /**
  62. * Check if two colors are visually similar within a threshold.
  63. * Uses RGB component comparison with configurable tolerance.
  64. * @param color1 - First hex color
  65. * @param color2 - Second hex color
  66. * @param threshold - Maximum difference per RGB component (default: 40)
  67. */
  68. export function colorsAreSimilar(
  69. color1: string | undefined,
  70. color2: string | undefined,
  71. threshold = 40
  72. ): boolean {
  73. const hex1 = normalizeColorForCompare(color1);
  74. const hex2 = normalizeColorForCompare(color2);
  75. if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
  76. const r1 = parseInt(hex1.substring(0, 2), 16);
  77. const g1 = parseInt(hex1.substring(2, 4), 16);
  78. const b1 = parseInt(hex1.substring(4, 6), 16);
  79. const r2 = parseInt(hex2.substring(0, 2), 16);
  80. const g2 = parseInt(hex2.substring(2, 4), 16);
  81. const b2 = parseInt(hex2.substring(4, 6), 16);
  82. return (
  83. Math.abs(r1 - r2) <= threshold &&
  84. Math.abs(g1 - g2) <= threshold &&
  85. Math.abs(b1 - b2) <= threshold
  86. );
  87. }
  88. /**
  89. * Format slot label for display in the UI.
  90. * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
  91. * @param trayId - Tray/slot ID within the AMS unit (0-3)
  92. * @param isHt - Whether this is an AMS-HT unit (single tray)
  93. * @param isExternal - Whether this is the external spool holder
  94. */
  95. export function formatSlotLabel(
  96. amsId: number,
  97. trayId: number,
  98. isHt: boolean,
  99. isExternal: boolean
  100. ): string {
  101. if (isExternal) return 'Ext';
  102. // Convert AMS ID to letter (A, B, C, D)
  103. // AMS-HT uses IDs starting at 128
  104. const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
  105. if (isHt) return `HT-${letter}`;
  106. return `${letter}${trayId + 1}`;
  107. }
  108. /**
  109. * Calculate global tray ID for MQTT command.
  110. * Used in the ams_mapping array sent to the printer.
  111. * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
  112. * @param trayId - Tray/slot ID within the AMS unit
  113. * @param isExternal - Whether this is the external spool holder
  114. * @returns Global tray ID (0-15 for AMS, 128+ for AMS-HT, 254 for external)
  115. */
  116. export function getGlobalTrayId(
  117. amsId: number,
  118. trayId: number,
  119. isExternal: boolean
  120. ): number {
  121. if (isExternal) return 254 + trayId;
  122. // AMS-HT units have IDs starting at 128 with a single tray — use ID directly
  123. if (amsId >= 128) return amsId;
  124. return amsId * 4 + trayId;
  125. }
  126. /**
  127. * Get fill bar color based on spool fill level.
  128. * Matches PrintersPage thresholds and Bambu Lab brand green.
  129. */
  130. export function getFillBarColor(fillLevel: number): string {
  131. if (fillLevel > 50) return '#00ae42'; // Green - good
  132. if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
  133. return '#ef4444'; // Red - critical (< 15%)
  134. }
  135. /**
  136. * Calculate fill level from Spoolman weight data.
  137. * Used as the first source in the Spoolman → Inventory → AMS fill chain.
  138. */
  139. export function getSpoolmanFillLevel(
  140. linkedSpool: { remaining_weight: number | null; filament_weight: number | null } | undefined
  141. ): number | null {
  142. if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
  143. || linkedSpool.filament_weight <= 0) return null;
  144. return Math.min(100, Math.round(
  145. (linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
  146. ));
  147. }
  148. function toFixedHex(value: number, width: number): string {
  149. const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
  150. return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);
  151. }
  152. // 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)
  153. function hashSerialToHex32(serial: string): string {
  154. const input = (serial || '').trim().toUpperCase();
  155. let hash = 0x811c9dc5;
  156. for (let i = 0; i < input.length; i++) {
  157. hash ^= input.charCodeAt(i);
  158. hash = Math.imul(hash, 0x01000193);
  159. }
  160. return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
  161. }
  162. /**
  163. * Generate a stable fallback spool tag for slots without RFID identifiers.
  164. * Returns a 16-char hex string derived from the printer serial + slot position.
  165. */
  166. export function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {
  167. return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;
  168. }
  169. /**
  170. * Get minimum datetime for scheduling (now + 1 minute).
  171. * Returns ISO string format for datetime-local input.
  172. */
  173. export function getMinDateTime(): string {
  174. const now = new Date();
  175. now.setMinutes(now.getMinutes() + 1);
  176. return now.toISOString().slice(0, 16);
  177. }
  178. /**
  179. * Check if a scheduled time is a placeholder far-future date.
  180. * Placeholder dates (more than 6 months out) are treated as ASAP.
  181. */
  182. export function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {
  183. if (!scheduledTime) return false;
  184. const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
  185. return (parseUTCDate(scheduledTime)?.getTime() ?? 0) > sixMonthsFromNow;
  186. }
  187. /**
  188. * Auto-match a filament requirement to a loaded filament, respecting nozzle constraints.
  189. * Used by both single-printer (FilamentMapping) and multi-printer (InlineMappingEditor) paths.
  190. */
  191. export function autoMatchFilament(
  192. req: { type?: string; color?: string; nozzle_id?: number | null },
  193. loadedFilaments: { globalTrayId: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
  194. usedTrayIds: Set<number>,
  195. preferLowest?: boolean,
  196. ): typeof loadedFilaments[number] | undefined {
  197. let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
  198. if (preferLowest) {
  199. nozzleFilaments = [...nozzleFilaments].sort((a, b) => {
  200. const ra = (a.remain ?? -1) >= 0 ? (a.remain ?? -1) : 101;
  201. const rb = (b.remain ?? -1) >= 0 ? (b.remain ?? -1) : 101;
  202. return ra - rb;
  203. });
  204. }
  205. const exactMatch = nozzleFilaments.find(
  206. (f) =>
  207. !usedTrayIds.has(f.globalTrayId) &&
  208. filamentTypesCompatible(f.type, req.type) &&
  209. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  210. );
  211. const similarMatch = exactMatch
  212. ? undefined
  213. : nozzleFilaments.find(
  214. (f) =>
  215. !usedTrayIds.has(f.globalTrayId) &&
  216. filamentTypesCompatible(f.type, req.type) &&
  217. colorsAreSimilar(f.color, req.color)
  218. );
  219. const typeOnlyMatch =
  220. exactMatch || similarMatch
  221. ? undefined
  222. : nozzleFilaments.find(
  223. (f) => !usedTrayIds.has(f.globalTrayId) && filamentTypesCompatible(f.type, req.type)
  224. );
  225. return exactMatch ?? similarMatch ?? typeOnlyMatch;
  226. }
  227. /**
  228. * Filter loaded filaments to those valid for a given nozzle requirement.
  229. * For single-nozzle printers (nozzle_id is null/undefined), returns all filaments.
  230. */
  231. export function filterFilamentsByNozzle<T extends { extruderId?: number }>(
  232. loadedFilaments: T[],
  233. nozzleId: number | undefined | null,
  234. ): T[] {
  235. return loadedFilaments.filter(
  236. (f) => nozzleId == null || f.extruderId === nozzleId
  237. );
  238. }
  239. /**
  240. * Detect Bambu Lab RFID-tagged spool by tray_uuid (32 hex) or tag_uid (16 hex).
  241. *
  242. * Permissive zero-string check: any non-zero non-empty value returns true. The
  243. * function exists to suppress assign/unassign actions on RFID-managed slots
  244. * whose state is owned by the printer firmware — manual changes there would be
  245. * overwritten on the next RFID re-read (eye → pen icon in BambuStudio).
  246. */
  247. export function isBambuLabSpool(tray: {
  248. tray_uuid?: string | null;
  249. tag_uid?: string | null;
  250. } | null | undefined): boolean {
  251. if (!tray) return false;
  252. if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') return true;
  253. if (tray.tag_uid && tray.tag_uid !== '0000000000000000') return true;
  254. return false;
  255. }