amsHelpers.ts 8.4 KB

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