amsHelpers.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  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. * AMS unit label using the codebase convention: "AMS-A / AMS-B / ..." for
  33. * regular AMS, "HT-A / HT-B / ..." for AMS-HT (single-tray modules with
  34. * IDs starting at 128). `trayCount` is required because the type can't be
  35. * inferred from the id alone — regular AMS IDs 0-3 can collide with the
  36. * normalized HT range otherwise.
  37. */
  38. export function getAmsLabel(amsId: number | string, trayCount: number): string {
  39. const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId;
  40. const safeId = isNaN(id) ? 0 : id;
  41. if (safeId === 255) return 'External';
  42. const isHt = trayCount === 1;
  43. const normalizedId = safeId >= 128 ? safeId - 128 : safeId;
  44. const letter = String.fromCharCode(65 + normalizedId);
  45. return isHt ? `HT-${letter}` : `AMS-${letter}`;
  46. }
  47. /**
  48. * Filament type equivalence groups.
  49. * Types within the same group are interchangeable on the printer side
  50. * (e.g., Bambu Lab firmware treats PA-CF and PA12-CF as compatible).
  51. */
  52. const FILAMENT_TYPE_GROUPS: string[][] = [
  53. ['PA-CF', 'PA12-CF', 'PAHT-CF'],
  54. ];
  55. const _equivalenceMap: Record<string, string> = {};
  56. for (const group of FILAMENT_TYPE_GROUPS) {
  57. const canonical = group[0];
  58. for (const t of group) {
  59. _equivalenceMap[t.toUpperCase()] = canonical.toUpperCase();
  60. }
  61. }
  62. /**
  63. * Get the canonical filament type for equivalence matching.
  64. * Types in the same group (e.g., PA-CF / PA12-CF / PAHT-CF) return the same canonical type.
  65. */
  66. export function canonicalFilamentType(type: string | undefined): string {
  67. if (!type) return '';
  68. const upper = type.toUpperCase();
  69. return _equivalenceMap[upper] ?? upper;
  70. }
  71. /**
  72. * Check if two filament types are compatible (same type or same equivalence group).
  73. */
  74. export function filamentTypesCompatible(a: string | undefined, b: string | undefined): boolean {
  75. return canonicalFilamentType(a) === canonicalFilamentType(b);
  76. }
  77. /**
  78. * Check if two colors are visually similar within a threshold.
  79. * Uses RGB component comparison with configurable tolerance.
  80. * @param color1 - First hex color
  81. * @param color2 - Second hex color
  82. * @param threshold - Maximum difference per RGB component (default: 40)
  83. */
  84. export function colorsAreSimilar(
  85. color1: string | undefined,
  86. color2: string | undefined,
  87. threshold = 40
  88. ): boolean {
  89. const hex1 = normalizeColorForCompare(color1);
  90. const hex2 = normalizeColorForCompare(color2);
  91. if (!hex1 || !hex2 || hex1.length < 6 || hex2.length < 6) return false;
  92. const r1 = parseInt(hex1.substring(0, 2), 16);
  93. const g1 = parseInt(hex1.substring(2, 4), 16);
  94. const b1 = parseInt(hex1.substring(4, 6), 16);
  95. const r2 = parseInt(hex2.substring(0, 2), 16);
  96. const g2 = parseInt(hex2.substring(2, 4), 16);
  97. const b2 = parseInt(hex2.substring(4, 6), 16);
  98. return (
  99. Math.abs(r1 - r2) <= threshold &&
  100. Math.abs(g1 - g2) <= threshold &&
  101. Math.abs(b1 - b2) <= threshold
  102. );
  103. }
  104. /**
  105. * Format slot label for display in the UI.
  106. * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
  107. * @param trayId - Tray/slot ID within the AMS unit (0-3)
  108. * @param isHt - Whether this is an AMS-HT unit (single tray)
  109. * @param isExternal - Whether this is the external spool holder
  110. */
  111. export function formatSlotLabel(
  112. amsId: number,
  113. trayId: number,
  114. isHt: boolean,
  115. isExternal: boolean
  116. ): string {
  117. if (isExternal) return 'Ext';
  118. // Convert AMS ID to letter (A, B, C, D)
  119. // AMS-HT uses IDs starting at 128
  120. const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
  121. if (isHt) return `HT-${letter}`;
  122. return `${letter}${trayId + 1}`;
  123. }
  124. /**
  125. * Calculate global tray ID for MQTT command.
  126. * Used in the ams_mapping array sent to the printer.
  127. * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
  128. * @param trayId - Tray/slot ID within the AMS unit
  129. * @param isExternal - Whether this is the external spool holder
  130. * @returns Global tray ID (0-15 for AMS, 128+ for AMS-HT, 254 for external)
  131. */
  132. export function getGlobalTrayId(
  133. amsId: number,
  134. trayId: number,
  135. isExternal: boolean
  136. ): number {
  137. if (isExternal) return 254 + trayId;
  138. // AMS-HT units have IDs starting at 128 with a single tray — use ID directly
  139. if (amsId >= 128) return amsId;
  140. return amsId * 4 + trayId;
  141. }
  142. /**
  143. * Get fill bar color based on spool fill level.
  144. * Matches PrintersPage thresholds and Bambu Lab brand green.
  145. */
  146. export function getFillBarColor(fillLevel: number): string {
  147. if (fillLevel > 50) return '#00ae42'; // Green - good
  148. if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
  149. return '#ef4444'; // Red - critical (< 15%)
  150. }
  151. /**
  152. * Calculate fill level from Spoolman weight data.
  153. * Used as the first source in the Spoolman → Inventory → AMS fill chain.
  154. */
  155. export function getSpoolmanFillLevel(
  156. linkedSpool: { remaining_weight: number | null; filament_weight: number | null } | undefined
  157. ): number | null {
  158. if (!linkedSpool?.remaining_weight || !linkedSpool?.filament_weight
  159. || linkedSpool.filament_weight <= 0) return null;
  160. return Math.min(100, Math.round(
  161. (linkedSpool.remaining_weight / linkedSpool.filament_weight) * 100
  162. ));
  163. }
  164. function toFixedHex(value: number, width: number): string {
  165. const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
  166. return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width);
  167. }
  168. // 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials)
  169. function hashSerialToHex32(serial: string): string {
  170. const input = (serial || '').trim().toUpperCase();
  171. let hash = 0x811c9dc5;
  172. for (let i = 0; i < input.length; i++) {
  173. hash ^= input.charCodeAt(i);
  174. hash = Math.imul(hash, 0x01000193);
  175. }
  176. return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0');
  177. }
  178. /**
  179. * Generate a stable fallback spool tag for slots without RFID identifiers.
  180. * Returns a 16-char hex string derived from the printer serial + slot position.
  181. */
  182. export function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string {
  183. return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`;
  184. }
  185. /**
  186. * Get minimum datetime for scheduling (now + 1 minute).
  187. * Returns ISO string format for datetime-local input.
  188. */
  189. export function getMinDateTime(): string {
  190. const now = new Date();
  191. now.setMinutes(now.getMinutes() + 1);
  192. return now.toISOString().slice(0, 16);
  193. }
  194. /**
  195. * Check if a scheduled time is a placeholder far-future date.
  196. * Placeholder dates (more than 6 months out) are treated as ASAP.
  197. */
  198. export function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {
  199. if (!scheduledTime) return false;
  200. const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
  201. return (parseUTCDate(scheduledTime)?.getTime() ?? 0) > sixMonthsFromNow;
  202. }
  203. /**
  204. * Banding tie-break for `preferLowestSortKey`, mirroring backend
  205. * `PrintScheduler._slot_priority` so regular AMS < AMS-HT < external on ties
  206. * regardless of the raw `ams_id`. In particular, `ams_id = -1` (VT / external
  207. * in `buildLoadedFilaments`) MUST NOT sort to a negative number or it would
  208. * beat AMS slot 0 — backend clamps to 10_000.
  209. */
  210. function slotPriority(amsId: number | undefined, trayId: number | undefined): number {
  211. if (amsId == null || amsId < 0) return 10_000;
  212. if (amsId >= 128) return 1_000 + (amsId - 128) * 4 + (trayId ?? 0);
  213. return amsId * 4 + (trayId ?? 0);
  214. }
  215. /**
  216. * Two-tier sort key for the "Prefer Lowest Remaining Filament" preference (#1766).
  217. *
  218. * Mirrors backend `_prefer_lowest_sort_key` in `print_scheduler.py:1161` so the
  219. * client-side sort that PrintModal pre-computes lines up with the dispatch-time
  220. * sort. Inventory-bound spools sort before MQTT-only ones (tier 0 vs tier 1) so
  221. * the user's tracked grams beat the printer's per-cent estimate; within each
  222. * tier the lowest value wins, with the slot-position tie-break above so the
  223. * order is deterministic across identical spools.
  224. *
  225. * `inventoryByTrayId` is the `globalTrayId -> grams_remaining` map derived from
  226. * the user's spool assignments. Pass `undefined` to fall back to remain%-only
  227. * sorting (preserves pre-#1766 behaviour for callers that don't yet wire it in).
  228. */
  229. export function preferLowestSortKey(
  230. f: { globalTrayId: number; amsId?: number; trayId?: number; remain?: number },
  231. inventoryByTrayId: Map<number, number> | undefined,
  232. ): [number, number, number] {
  233. const slot = slotPriority(f.amsId, f.trayId);
  234. if (inventoryByTrayId && inventoryByTrayId.has(f.globalTrayId)) {
  235. return [0, inventoryByTrayId.get(f.globalTrayId) ?? 0, slot];
  236. }
  237. const remain = f.remain ?? -1;
  238. return [1, remain >= 0 ? remain : 101, slot];
  239. }
  240. /** Tuple compare for `preferLowestSortKey` outputs. */
  241. export function compareSortKeys(
  242. a: [number, number, number],
  243. b: [number, number, number],
  244. ): number {
  245. return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
  246. }
  247. /**
  248. * Effective "Prefer lowest remaining filament" preference for a given printer,
  249. * gated on its AMS Filament Backup state (#1766).
  250. *
  251. * Without backup, the printer can't switch to a second spool when the picked
  252. * one runs out — so even with the user setting on, sorting toward the lowest
  253. * leaves the print at risk. Mirrors the backend gate in
  254. * `print_scheduler.py::_compute_ams_mapping_for_printer`. `null`/`undefined`
  255. * (unknown state, e.g. A1 family) preserves today's behaviour intentionally.
  256. */
  257. export function effectivePreferLowest(
  258. setting: boolean | undefined,
  259. amsFilamentBackup: boolean | null | undefined,
  260. ): boolean {
  261. if (!setting) return false;
  262. return amsFilamentBackup !== false;
  263. }
  264. /**
  265. * Auto-match a filament requirement to a loaded filament, respecting nozzle constraints.
  266. * Used by both single-printer (FilamentMapping) and multi-printer (InlineMappingEditor) paths.
  267. */
  268. export function autoMatchFilament(
  269. req: { type?: string; color?: string; nozzle_id?: number | null },
  270. loadedFilaments: { globalTrayId: number; amsId?: number; trayId?: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
  271. usedTrayIds: Set<number>,
  272. preferLowest?: boolean,
  273. inventoryByTrayId?: Map<number, number>,
  274. ): typeof loadedFilaments[number] | undefined {
  275. let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
  276. if (preferLowest) {
  277. nozzleFilaments = [...nozzleFilaments].sort((a, b) =>
  278. compareSortKeys(
  279. preferLowestSortKey(a, inventoryByTrayId),
  280. preferLowestSortKey(b, inventoryByTrayId),
  281. ),
  282. );
  283. }
  284. const exactMatch = nozzleFilaments.find(
  285. (f) =>
  286. !usedTrayIds.has(f.globalTrayId) &&
  287. filamentTypesCompatible(f.type, req.type) &&
  288. normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
  289. );
  290. const similarMatch = exactMatch
  291. ? undefined
  292. : nozzleFilaments.find(
  293. (f) =>
  294. !usedTrayIds.has(f.globalTrayId) &&
  295. filamentTypesCompatible(f.type, req.type) &&
  296. colorsAreSimilar(f.color, req.color)
  297. );
  298. const typeOnlyMatch =
  299. exactMatch || similarMatch
  300. ? undefined
  301. : nozzleFilaments.find(
  302. (f) => !usedTrayIds.has(f.globalTrayId) && filamentTypesCompatible(f.type, req.type)
  303. );
  304. return exactMatch ?? similarMatch ?? typeOnlyMatch;
  305. }
  306. /**
  307. * Filter loaded filaments to those valid for a given nozzle requirement.
  308. * For single-nozzle printers (nozzle_id is null/undefined), returns all filaments.
  309. */
  310. export function filterFilamentsByNozzle<T extends { extruderId?: number }>(
  311. loadedFilaments: T[],
  312. nozzleId: number | undefined | null,
  313. ): T[] {
  314. return loadedFilaments.filter(
  315. (f) => nozzleId == null || f.extruderId === nozzleId
  316. );
  317. }
  318. /**
  319. * Resolve the installed nozzle diameter feeding a given AMS unit, so the
  320. * Configure-AMS-Slot picker filters filament presets by the nozzle actually on
  321. * the machine instead of assuming 0.4mm (#1899).
  322. *
  323. * On dual-nozzle printers (H2D) each AMS is bound to one extruder via
  324. * `ams_extruder_map` (amsId → extruder index, 0=left/primary, 1=right), so we
  325. * read that nozzle's diameter. Single-nozzle printers have no map entry and
  326. * fall back to the primary nozzle (index 0). Returns undefined when the printer
  327. * hasn't reported nozzle hardware yet, letting the caller keep its own default.
  328. * Diameter is the bare decimal string the status carries, e.g. "0.4" / "0.6".
  329. */
  330. export function resolveSlotNozzleDiameter(
  331. status: {
  332. nozzles?: { nozzle_diameter?: string }[];
  333. ams_extruder_map?: Record<string, number>;
  334. } | null | undefined,
  335. amsId: number,
  336. ): string | undefined {
  337. const nozzles = status?.nozzles;
  338. if (!nozzles || nozzles.length === 0) return undefined;
  339. const extruderIdx = status?.ams_extruder_map?.[String(amsId)] ?? 0;
  340. const diameter = nozzles[extruderIdx]?.nozzle_diameter || nozzles[0]?.nozzle_diameter;
  341. return diameter || undefined;
  342. }
  343. /**
  344. * Detect Bambu Lab RFID-tagged spool by tray_uuid (32 hex) or tag_uid (16 hex).
  345. *
  346. * Permissive zero-string check: any non-zero non-empty value returns true. The
  347. * function exists to suppress assign/unassign actions on RFID-managed slots
  348. * whose state is owned by the printer firmware — manual changes there would be
  349. * overwritten on the next RFID re-read (eye → pen icon in BambuStudio).
  350. */
  351. export function isBambuLabSpool(tray: {
  352. tray_uuid?: string | null;
  353. tag_uid?: string | null;
  354. } | null | undefined): boolean {
  355. if (!tray) return false;
  356. if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') return true;
  357. if (tray.tag_uid && tray.tag_uid !== '0000000000000000') return true;
  358. return false;
  359. }
  360. export interface AmsTrayLike {
  361. id: number;
  362. tray_type: string | null | undefined;
  363. tray_sub_brands: string | null | undefined;
  364. tray_color: string | null | undefined;
  365. tray_info_idx: string | null | undefined;
  366. }
  367. export interface AmsUnitLike {
  368. id: number;
  369. tray: AmsTrayLike[];
  370. }
  371. /**
  372. * One row in the AMS Backup modal: a group of slots that back each other up
  373. * (length >= 2), or a single non-empty slot with no peer (length === 1).
  374. */
  375. export interface BackupGroup {
  376. /** Stable key — same across renders for the same material+extruder. */
  377. key: string;
  378. /** Bambu preset ID (tray_info_idx) when matched on preset; null otherwise. */
  379. presetId: string | null;
  380. /** 0 = right / single, 1 = left. Scoping field for dual-nozzle. */
  381. extruder: number;
  382. /** Display name from the first slot's tray_sub_brands (or tray_type). */
  383. displayName: string;
  384. /** Tray colour from the first slot, for the swatch in the modal. */
  385. trayColor: string | null;
  386. /** Member slots, in (ams_id, slot_idx) order. */
  387. members: Array<{ amsId: number; slotIdx: number; globalTrayId: number }>;
  388. }
  389. /**
  390. * Canonicalise a hex colour for identity comparison. Mirrors the backend
  391. * `_normalize_color_for_id`. Strips the leading `#`, uppercases, and drops
  392. * the alpha channel when 8 chars long so `1A1A1AFF` matches `1A1A1A`.
  393. */
  394. function normalizeColorForId(raw: string | null | undefined): string {
  395. let s = (raw || '').trim().replace(/^#/, '').toUpperCase();
  396. if (s.length === 8) s = s.slice(0, 6);
  397. return s;
  398. }
  399. /**
  400. * Compute backup pairs for the AMS Backup modal (#1762).
  401. *
  402. * Strict identity rule (mirrors backend `_material_identity_internal` /
  403. * `_material_identity_spoolman`): slots pair ONLY when they share the same
  404. * Bambu preset ID (`tray_info_idx`, e.g. "GFA00") AND the same colour. The
  405. * preset identifies the filament profile (PETG HF, PLA Basic, etc.); the
  406. * colour pins the variant — three PETG HF spools in different colours
  407. * absolutely don't back each other up. User-tagged spools without a preset
  408. * never pair — Bambu's firmware backup logic relies on the preset, and
  409. * pairing on cosmetic name/colour match alone would let two visually-
  410. * identical but materially-different spools be treated as backups.
  411. *
  412. * Empty slots are skipped entirely. Every non-empty slot is returned — slots
  413. * without a peer come back as 1-member entries so the modal can list them as
  414. * "Slots without a backup peer".
  415. *
  416. * On dual-extruder printers (H2D / H2C / X2D), pairs are scoped per extruder
  417. * side — the firmware can't cross extruders even with the global backup bit
  418. * set.
  419. */
  420. export function computeBackupGroups(
  421. amsUnits: AmsUnitLike[] | undefined,
  422. amsExtruderMap: Record<string, number> | undefined,
  423. isDualNozzle: boolean,
  424. ): BackupGroup[] {
  425. if (!amsUnits || amsUnits.length === 0) return [];
  426. // Defensive dedup: ``status.ams`` is expected to be unique by `ams.id`, but
  427. // observed in the wild to occasionally contain duplicate entries (e.g. on
  428. // VP-aggregated switch printers or during MQTT partial-update merges). A
  429. // duplicate would surface as "AMS-A slot 1" rendered twice with different
  430. // materials, which is impossible physically and visually broken. First
  431. // occurrence per `ams.id` wins.
  432. const seenIds = new Set<number>();
  433. const uniqueAms: AmsUnitLike[] = [];
  434. for (const ams of amsUnits) {
  435. if (seenIds.has(ams.id)) continue;
  436. seenIds.add(ams.id);
  437. uniqueAms.push(ams);
  438. }
  439. const byKey = new Map<string, BackupGroup>();
  440. for (const ams of uniqueAms) {
  441. const extruder = isDualNozzle ? Number(amsExtruderMap?.[String(ams.id)] ?? 0) : 0;
  442. ams.tray.forEach((tray, slotIdx) => {
  443. if (!tray?.tray_type) return; // empty slot
  444. const preset = (tray.tray_info_idx || '').trim();
  445. const globalTrayId = getGlobalTrayId(ams.id, slotIdx, false);
  446. const member = { amsId: ams.id, slotIdx, globalTrayId };
  447. let key: string;
  448. let presetId: string | null;
  449. if (preset) {
  450. // Same Bambu profile is necessary but NOT sufficient — different colours
  451. // of the same PETG HF profile can't back each other up. Bake the colour
  452. // into the identity key, normalised to strip alpha and case.
  453. const color = normalizeColorForId(tray.tray_color);
  454. key = `preset:${preset}|color:${color}#${extruder}`;
  455. presetId = preset;
  456. } else {
  457. // No preset → never group with anything else. Unique-per-slot key.
  458. key = `unmatched:${ams.id}:${slotIdx}#${extruder}`;
  459. presetId = null;
  460. }
  461. const existing = byKey.get(key);
  462. if (existing) {
  463. existing.members.push(member);
  464. } else {
  465. byKey.set(key, {
  466. key,
  467. presetId,
  468. extruder,
  469. displayName: tray.tray_sub_brands || tray.tray_type || '',
  470. trayColor: tray.tray_color ?? null,
  471. members: [member],
  472. });
  473. }
  474. });
  475. }
  476. // Stable sort: extruder first (so the modal can section per side on
  477. // dual-nozzle), then pairs before lone slots, then by name, then by first
  478. // member's global tray id for deterministic rendering.
  479. return Array.from(byKey.values()).sort((a, b) => {
  480. if (a.extruder !== b.extruder) return a.extruder - b.extruder;
  481. const aLone = a.members.length === 1 ? 1 : 0;
  482. const bLone = b.members.length === 1 ? 1 : 0;
  483. if (aLone !== bLone) return aLone - bLone;
  484. if (a.displayName !== b.displayName) return a.displayName.localeCompare(b.displayName);
  485. return a.members[0].globalTrayId - b.members[0].globalTrayId;
  486. });
  487. }