useWebSocket.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699
  1. import { useQueryClient } from '@tanstack/react-query';
  2. import { useCallback, useEffect, useRef, useState } from 'react';
  3. import { useToast } from '../contexts/ToastContext';
  4. import { useTranslation } from 'react-i18next';
  5. import { api, ApiError } from '../api/client';
  6. import { inventoryLocationsQueryKey, inventorySuppliersQueryKey } from '../utils/inventoryQueries';
  7. // The auth-failure close code of /api/v1/ws (websocket.py
  8. // _WS_CLOSE_UNAUTHORIZED). It reaches the browser only for a socket the server
  9. // had accepted: one closed when its session ended (password change or reset,
  10. // 2FA removal, deactivation, rename), when authentication was turned on, when a
  11. // scope refresh failed, or by the server's check right after accepting it. The
  12. // hook asks AuthContext to re-check (a tab whose session ended goes to the
  13. // login page, which unmounts this provider) and reconnects with a fresh token.
  14. // Each 4401 before a socket's first message doubles the wait, up to five
  15. // minutes: no /auth/ws-token loop, and a failure that clears still recovers.
  16. //
  17. // A socket refused before it was accepted (no or invalid token, user or key
  18. // gone, a failed lookup) gets an HTTP 403 on the handshake, which the browser
  19. // reports as 1006 with no open, as for a server that is down. Those can't be
  20. // told apart, so they get no re-check, and a backoff capped at 30 s, so tabs
  21. // still come back quickly after a restart.
  22. const WS_CLOSE_UNAUTHORIZED = 4401;
  23. const UNAUTHORIZED_RETRY_FIRST_MS = 3000;
  24. const UNAUTHORIZED_RETRY_MAX_MS = 300000;
  25. const HANDSHAKE_RETRY_MAX_MS = 30000;
  26. interface WebSocketMessage {
  27. type: string;
  28. printer_id?: number;
  29. data?: Record<string, unknown>;
  30. printer_name?: string;
  31. filename?: string;
  32. missing_slots?: Array<{ slot?: string }>;
  33. // Spool-assignment read-back verification (#2582).
  34. slot?: string;
  35. verified?: boolean;
  36. kprofile_applied?: boolean;
  37. saw_tray?: boolean;
  38. // Slicer Pipeline run events (#1425 PR C). ``run`` carries the full
  39. // PipelineRunResponse payload — typed loosely here so the WebSocket hook
  40. // doesn't pull the full client.ts types in.
  41. run?: { pipeline_id?: number | null };
  42. }
  43. export function useWebSocket() {
  44. const wsRef = useRef<WebSocket | null>(null);
  45. const reconnectTimeoutRef = useRef<number | null>(null);
  46. // Set true by the effect cleanup so a close event fired *during* unmount
  47. // can't schedule a reconnect after the provider is gone (the old code cleared
  48. // reconnectTimeoutRef, then .close() ran ws.onclose which set a *fresh*
  49. // timeout — a leaked reconnect that kept minting ws-tokens post-logout).
  50. const disposedRef = useRef(false);
  51. // The wait before the next reconnect after a 4401; 0 once a socket is
  52. // admitted (its first message).
  53. const unauthorizedDelayRef = useRef(0);
  54. // The wait before the next reconnect after a handshake that failed (the
  55. // socket never opened); 0 once one opens.
  56. const handshakeDelayRef = useRef(0);
  57. const queryClient = useQueryClient();
  58. const [isConnected, setIsConnected] = useState(false);
  59. const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
  60. const { showToast } = useToast();
  61. const { t } = useTranslation();
  62. // Debounce invalidations to prevent rapid re-render cascades
  63. const pendingInvalidations = useRef<Set<string>>(new Set());
  64. const invalidationTimeoutRef = useRef<number | null>(null);
  65. // Throttle printer status updates to prevent freeze during rapid messages
  66. const pendingPrinterStatus = useRef<Map<number, Record<string, unknown>>>(new Map());
  67. const printerStatusTimeoutRef = useRef<number | null>(null);
  68. // Throttle message processing to prevent browser freeze
  69. const messageQueueRef = useRef<WebSocketMessage[]>([]);
  70. const processingRef = useRef(false);
  71. // Use ref for handleMessage to avoid stale closure in connect
  72. const handleMessageRef = useRef<(message: WebSocketMessage) => void>(() => {});
  73. // Process message queue with throttling to prevent UI freeze
  74. const processMessageQueue = useCallback(() => {
  75. if (processingRef.current || messageQueueRef.current.length === 0) {
  76. return;
  77. }
  78. processingRef.current = true;
  79. const processNext = () => {
  80. const message = messageQueueRef.current.shift();
  81. if (message) {
  82. handleMessageRef.current(message);
  83. // Small delay between messages to prevent overwhelming the browser.
  84. // This setTimeout is the yield; a requestAnimationFrame around the
  85. // handler used to sit here too, which stalled the whole queue in a
  86. // hidden tab (see the note on the rAF removal below).
  87. if (messageQueueRef.current.length > 0) {
  88. setTimeout(processNext, 16); // ~60fps
  89. } else {
  90. processingRef.current = false;
  91. }
  92. } else {
  93. processingRef.current = false;
  94. }
  95. };
  96. processNext();
  97. }, []);
  98. const connect = useCallback(async () => {
  99. if (disposedRef.current || wsRef.current?.readyState === WebSocket.OPEN) {
  100. return;
  101. }
  102. // GHSA-r2qv follow-up: when auth is enabled, /ws now requires a token
  103. // minted by POST /api/v1/auth/ws-token. We use the shared ``api.request``
  104. // helper (via ``api.getWebSocketToken``) so the JWT Authorization header
  105. // is attached — a raw ``fetch()`` with ``credentials: 'include'`` would
  106. // miss it (Bambuddy uses Bearer tokens, not cookies, for JWT auth).
  107. // Auth-disabled deployments accept connections without a token.
  108. let token: string | undefined;
  109. try {
  110. const resp = await api.getWebSocketToken();
  111. token = resp.token;
  112. } catch (err) {
  113. // A 401/403 from the token mint is an AUTH decision, not a transient
  114. // blip, so retrying is pointless and hammers /auth/ws-token every 3s:
  115. // 401 — not signed in. An expired JWT: ``request()`` already cleared
  116. // it and dispatched ``auth:expired``, so the route guard is
  117. // redirecting to /login. "Authentication required" (no token,
  118. // e.g. a tab opened while auth was off, which was turned on while
  119. // its socket was down) is not treated as a sign-out there, so
  120. // ask AuthContext to re-check, which then shows the login.
  121. // 403 — the user is validly logged in but their group lacks
  122. // WEBSOCKET_CONNECT (they stay logged in; live updates simply
  123. // degrade to the REST polling the query cache already does), or
  124. // an API key (a kiosk) whose owner was deactivated while its
  125. // socket was down. Re-checked as well: the first gets their user
  126. // back and nothing changes, the second goes to the login.
  127. // Either way: do NOT open a tokenless socket (the server just closes it
  128. // 4401) and do NOT reconnect. The old catch-all fell through to a
  129. // tokenless socket whose 4401 close rescheduled connect() forever. A
  130. // network/5xx error is not auth — fall through and let the socket + its
  131. // reconnect loop handle it (auth-disabled deployments also land here with
  132. // no token and connect fine).
  133. const status = err instanceof ApiError ? err.status : 0;
  134. if (status === 401 || status === 403) {
  135. window.dispatchEvent(new CustomEvent('auth:recheck'));
  136. return;
  137. }
  138. }
  139. if (disposedRef.current) {
  140. return;
  141. }
  142. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  143. const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
  144. const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
  145. const ws = new WebSocket(wsUrl);
  146. let pingInterval: number | null = null;
  147. let opened = false;
  148. ws.onopen = () => {
  149. opened = true;
  150. handshakeDelayRef.current = 0;
  151. if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Connected');
  152. setIsConnected(true);
  153. // Start ping interval
  154. pingInterval = window.setInterval(() => {
  155. if (ws.readyState === WebSocket.OPEN) {
  156. ws.send(JSON.stringify({ type: 'ping' }));
  157. }
  158. }, 30000);
  159. };
  160. ws.onmessage = (event) => {
  161. unauthorizedDelayRef.current = 0;
  162. try {
  163. const message: WebSocketMessage = JSON.parse(event.data);
  164. // Handle printer_status directly (already throttled) to avoid queue delays
  165. // This prevents the "timelapse" effect where status updates are applied slowly
  166. if (message.type === 'printer_status' && message.printer_id !== undefined && message.data) {
  167. handleMessageRef.current(message);
  168. } else {
  169. // Queue other messages for throttled processing
  170. messageQueueRef.current.push(message);
  171. processMessageQueue();
  172. }
  173. } catch {
  174. // Ignore parse errors
  175. }
  176. };
  177. ws.onclose = (event) => {
  178. if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Closed', event.code, event.reason);
  179. if (pingInterval) {
  180. clearInterval(pingInterval);
  181. pingInterval = null;
  182. }
  183. setIsConnected(false);
  184. wsRef.current = null;
  185. // Don't reconnect once the provider has unmounted. After an auth
  186. // rejection (4401): re-check auth, and reconnect with a growing wait (see
  187. // WS_CLOSE_UNAUTHORIZED). A socket that never opened (a refused handshake
  188. // or a server that is down) also waits longer each time, up to 30 s. One
  189. // that opened and then dropped is a network drop: the 3s reconnect.
  190. if (disposedRef.current) {
  191. return;
  192. }
  193. if (event.code === WS_CLOSE_UNAUTHORIZED) {
  194. window.dispatchEvent(new CustomEvent('auth:recheck'));
  195. const delay = unauthorizedDelayRef.current
  196. ? Math.min(unauthorizedDelayRef.current * 2, UNAUTHORIZED_RETRY_MAX_MS)
  197. : UNAUTHORIZED_RETRY_FIRST_MS;
  198. unauthorizedDelayRef.current = delay;
  199. reconnectTimeoutRef.current = window.setTimeout(() => {
  200. connect();
  201. }, delay);
  202. return;
  203. }
  204. if (!opened) {
  205. const delay = handshakeDelayRef.current
  206. ? Math.min(handshakeDelayRef.current * 2, HANDSHAKE_RETRY_MAX_MS)
  207. : UNAUTHORIZED_RETRY_FIRST_MS;
  208. handshakeDelayRef.current = delay;
  209. reconnectTimeoutRef.current = window.setTimeout(() => {
  210. connect();
  211. }, delay);
  212. return;
  213. }
  214. // Reconnect after 3 seconds
  215. reconnectTimeoutRef.current = window.setTimeout(() => {
  216. connect();
  217. }, 3000);
  218. };
  219. ws.onerror = (error) => {
  220. if (import.meta.env.MODE !== 'test') console.error('[WebSocket] Error', error);
  221. ws.close();
  222. };
  223. wsRef.current = ws;
  224. }, [processMessageQueue]);
  225. // Write every pending printer status into the query cache.
  226. //
  227. // Extracted so the hidden-tab path below can run it inline: both paths share
  228. // this one body, so the merge semantics cannot drift apart. Cancels any
  229. // scheduled coalescing timer, since everything it was going to write has
  230. // just been written and re-running it would re-apply stale data over newer.
  231. const flushPrinterStatus = useCallback(() => {
  232. if (printerStatusTimeoutRef.current) {
  233. clearTimeout(printerStatusTimeoutRef.current);
  234. printerStatusTimeoutRef.current = null;
  235. }
  236. const updates = new Map(pendingPrinterStatus.current);
  237. pendingPrinterStatus.current.clear();
  238. updates.forEach((statusData, id) => {
  239. queryClient.setQueryData(['printerStatus', id], (old: Record<string, unknown> | undefined) => {
  240. const merged = { ...old, ...statusData };
  241. if (merged.wifi_signal == null && old?.wifi_signal != null) {
  242. merged.wifi_signal = old.wifi_signal;
  243. }
  244. return merged;
  245. });
  246. });
  247. }, [queryClient]);
  248. // Printer status update — coalesced while the tab is visible, written
  249. // straight through while it is not.
  250. //
  251. // #2754 (reporter @mic4rd), in two stages. First, these writes ran inside a
  252. // requestAnimationFrame: a hidden tab gets no rendering opportunities, so
  253. // the browser *holds* queued frame callbacks rather than throttling them,
  254. // and nothing reached the cache until the tab was shown again. Removing the
  255. // frame callback fixed that total stall but not the report, because a second
  256. // timer-shaped dependency was left behind — this 100ms coalescing window.
  257. //
  258. // Browsers clamp timers in a hidden page to at best once a second, and drop
  259. // pages hidden for more than five minutes to roughly one wake-up a minute.
  260. // The reporter saw a tab title stuck at 2% beside a page at 40%.
  261. //
  262. // The coalescing exists to stop rapid messages triggering a render cascade.
  263. // A hidden tab is not painting, so there is no cascade to prevent there —
  264. // the timer is pure cost, and it is exactly the thing being throttled. So
  265. // when hidden, skip it and write immediately.
  266. //
  267. // Note "hidden", not "unfocused": on Windows a fully-occluded window reports
  268. // visibilityState 'hidden' too, which is why the reporter saw this from
  269. // merely clicking away rather than only from switching tabs.
  270. const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
  271. // Merge with any pending data for this printer
  272. const existing = pendingPrinterStatus.current.get(printerId) || {};
  273. pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
  274. if (document.hidden) {
  275. flushPrinterStatus();
  276. return;
  277. }
  278. // Schedule update if not already scheduled
  279. if (!printerStatusTimeoutRef.current) {
  280. printerStatusTimeoutRef.current = window.setTimeout(flushPrinterStatus, 100);
  281. }
  282. }, [flushPrinterStatus]);
  283. // Debounced invalidation helper - coalesces multiple rapid invalidations
  284. const debouncedInvalidate = useCallback((queryKey: string) => {
  285. pendingInvalidations.current.add(queryKey);
  286. // Clear existing timeout
  287. if (invalidationTimeoutRef.current) {
  288. clearTimeout(invalidationTimeoutRef.current);
  289. }
  290. // Schedule invalidation after a delay (3s to prevent browser freeze on print completion)
  291. invalidationTimeoutRef.current = window.setTimeout(() => {
  292. const keys = Array.from(pendingInvalidations.current);
  293. pendingInvalidations.current.clear();
  294. invalidationTimeoutRef.current = null;
  295. // Invalidate queries one at a time with delays to prevent freeze.
  296. // The 500ms stagger is the anti-cascade measure; a frame callback around
  297. // each invalidation used to sit inside it and stalled these refreshes in
  298. // a hidden tab for the same reason as the status writes above (#2754).
  299. let delay = 0;
  300. keys.forEach((key) => {
  301. setTimeout(() => {
  302. queryClient.invalidateQueries({ queryKey: [key] });
  303. }, delay);
  304. delay += 500; // 500ms between each invalidation
  305. });
  306. }, 3000);
  307. }, [queryClient]);
  308. // Slot changes skip the cascade debounce above. That debounce exists for
  309. // print completion, where one event fans out across half the app; a spool
  310. // swap touches one slot and the user is standing at the printer looking at
  311. // the card. Waiting 3s of quiet and then staggering the keys 500ms apart put
  312. // several seconds of visibly wrong data on screen for no benefit, and the
  313. // timer restarts on every further event, so a busy moment could defer it
  314. // indefinitely. React Query coalesces repeat invalidations of one key, so a
  315. // Spoolman sync reporting a dozen slots at once still costs three refetches.
  316. const invalidateSlotQueries = useCallback(() => {
  317. queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
  318. queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
  319. queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
  320. }, [queryClient]);
  321. const handleMessage = useCallback((message: WebSocketMessage) => {
  322. switch (message.type) {
  323. case 'printer_status':
  324. if (message.printer_id !== undefined && message.data) {
  325. throttledPrinterStatusUpdate(message.printer_id, message.data);
  326. }
  327. break;
  328. case 'print_start':
  329. // Refetch printer status immediately when print starts to get printable_objects_count
  330. if (message.printer_id !== undefined) {
  331. queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
  332. }
  333. break;
  334. case 'missing_spool_assignment': {
  335. if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {
  336. break;
  337. }
  338. const missingSlotLabels = message.missing_slots
  339. .map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))
  340. .filter((slot) => slot.length > 0);
  341. if (missingSlotLabels.length === 0) {
  342. lastMissingSpoolWarningRef.current.delete(message.printer_id);
  343. break;
  344. }
  345. const signature = missingSlotLabels.join('|');
  346. if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {
  347. break;
  348. }
  349. lastMissingSpoolWarningRef.current.set(message.printer_id, signature);
  350. const printerName = message.printer_name || `Printer ${message.printer_id}`;
  351. const toastMsg = t('printers.toast.missingSpoolAssignment', {
  352. printer: printerName,
  353. slots: missingSlotLabels.join(', '),
  354. });
  355. showToast(toastMsg, 'warning');
  356. break;
  357. }
  358. case 'print_complete':
  359. // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
  360. // The printer_status websocket messages will naturally update the status
  361. debouncedInvalidate('archives');
  362. debouncedInvalidate('archiveStats');
  363. break;
  364. case 'print_confirm_request': {
  365. // #1898: a completed print asked for its outcome verdict. Handed to
  366. // the globally mounted ConfirmOutcomeDialog in Layout via the same
  367. // CustomEvent relay plate_not_empty uses.
  368. const detail = (message as unknown as { data?: { archive_id?: number; print_name?: string } }).data;
  369. if (detail?.archive_id != null) {
  370. window.dispatchEvent(new CustomEvent('print-confirm-request', { detail }));
  371. }
  372. break;
  373. }
  374. case 'kill_switch_triggered': {
  375. const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
  376. const filename = message.filename || t('common.unknown');
  377. showToast(t('printers.toast.killSwitchTriggered', { printer, filename }), 'error');
  378. break;
  379. }
  380. case 'billing_charge_failed': {
  381. const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
  382. const filename = message.filename || t('common.unknown');
  383. showToast(t('printers.toast.billingChargeFailed', { printer, filename }), 'error');
  384. break;
  385. }
  386. case 'archive_created':
  387. debouncedInvalidate('archives');
  388. debouncedInvalidate('archiveStats');
  389. break;
  390. case 'archive_updated':
  391. debouncedInvalidate('archives');
  392. break;
  393. case 'pong':
  394. // Keepalive response, ignore
  395. break;
  396. case 'plate_not_empty':
  397. // Plate detection found objects - print was paused
  398. // Dispatch event for toast notification
  399. window.dispatchEvent(new CustomEvent('plate-not-empty', {
  400. detail: {
  401. printer_id: message.printer_id,
  402. printer_name: (message as unknown as { printer_name?: string }).printer_name,
  403. message: (message as unknown as { message?: string }).message,
  404. }
  405. }));
  406. break;
  407. case 'plate_check_failed':
  408. // The plate check before a print could not run; the print goes on.
  409. window.dispatchEvent(new CustomEvent('plate-check-failed', {
  410. detail: {
  411. printer_id: message.printer_id,
  412. printer_name: (message as unknown as { printer_name?: string }).printer_name,
  413. reason: (message as unknown as { reason?: string }).reason,
  414. }
  415. }));
  416. break;
  417. case 'announcements_changed':
  418. // The backend fetched a newer feed: re-read the list so the sidebar dot
  419. // and the banner appear without a reload. The event says nothing itself.
  420. debouncedInvalidate('announcements');
  421. break;
  422. case 'printer_locations_changed':
  423. // A location was created, renamed, restyled or deleted, or printers moved
  424. // between them (#2962). Every page that shows a printer's location reads
  425. // it from the printer list.
  426. debouncedInvalidate('printer-locations');
  427. debouncedInvalidate('printers');
  428. break;
  429. case 'inventory_changed':
  430. // Spool created/updated/deleted/archived/restored - refresh inventory across all tabs
  431. debouncedInvalidate('inventory-spools');
  432. debouncedInvalidate('spoolman-inventory-spools');
  433. debouncedInvalidate(inventoryLocationsQueryKey[0]);
  434. debouncedInvalidate(inventorySuppliersQueryKey[0]);
  435. // The per-material-number aggregate is derived from the same rows (#2870).
  436. debouncedInvalidate('material-number-stats');
  437. break;
  438. case 'spool_assignment_changed':
  439. // Spool assigned/unassigned - refresh assignment data across all tabs.
  440. // Both inventory modes: the Spoolman AMS sync raises this event too, and
  441. // its slot rows live under their own query keys.
  442. invalidateSlotQueries();
  443. break;
  444. case 'spool_assignment_verified': {
  445. // #2582: the backend read the AMS telemetry back after an assignment
  446. // and either confirmed the tray accepted it or timed out. Toast the
  447. // outcome so the AMS→Studio hand-off is no longer silent.
  448. // Backend always supplies printer_name (falls back to "Printer <id>"),
  449. // so the '||' here only guards a malformed payload.
  450. const printer = message.printer_name || 'Printer';
  451. const slot = message.slot || '?';
  452. if (message.verified) {
  453. if (message.kprofile_applied === false) {
  454. // Filament id landed but the K-profile (cali_idx) did not — the
  455. // exact "loaded but no flow profile" case the reporter chased.
  456. showToast(
  457. t('printers.toast.assignmentVerifiedNoKprofile', { slot, printer }),
  458. 'warning'
  459. );
  460. } else {
  461. showToast(t('printers.toast.assignmentVerified', { slot, printer }), 'success');
  462. }
  463. } else {
  464. showToast(t('printers.toast.assignmentNotConfirmed', { slot, printer }), 'warning');
  465. }
  466. break;
  467. }
  468. case 'spool_auto_assigned':
  469. // RFID tag matched - refresh inventory and assignment data.
  470. // slotPresets is not optional here: auto-assigning rewrites the slot's
  471. // slot_preset_mappings row, and the AMS slot card reads that row ahead
  472. // of the live tray_info_idx. Leave it cached and swapping a spool keeps
  473. // the *previous* spool's preset name on the card -- the rest of the
  474. // card updates off the status push, so it reads as one wrong line
  475. // rather than a stale card. Only the manual assign path invalidated it.
  476. debouncedInvalidate('inventory-spools');
  477. invalidateSlotQueries();
  478. break;
  479. case 'spool_usage_logged':
  480. // Filament consumption recorded - refresh spool data
  481. debouncedInvalidate('inventory-spools');
  482. break;
  483. case 'unknown_tag': {
  484. // Unknown RFID tag detected — dispatch event for UI. The backend
  485. // ships the slot's current tray data alongside the event so
  486. // consumers don't have to look it up from the (frequently stale)
  487. // cached printerStatus query.
  488. const m = message as unknown as {
  489. printer_id?: number;
  490. ams_id?: number;
  491. tray_id?: number;
  492. tag_uid?: string;
  493. tray_uuid?: string;
  494. tray_type?: string | null;
  495. tray_color?: string | null;
  496. tray_sub_brands?: string | null;
  497. tray_count?: number | null;
  498. };
  499. window.dispatchEvent(new CustomEvent('unknown-tag', {
  500. detail: {
  501. printer_id: m.printer_id,
  502. ams_id: m.ams_id,
  503. tray_id: m.tray_id,
  504. tag_uid: m.tag_uid,
  505. tray_uuid: m.tray_uuid,
  506. tray_type: m.tray_type,
  507. tray_color: m.tray_color,
  508. tray_sub_brands: m.tray_sub_brands,
  509. tray_count: m.tray_count,
  510. }
  511. }));
  512. break;
  513. }
  514. case 'spoolbuddy_weight':
  515. window.dispatchEvent(new CustomEvent('spoolbuddy-weight', { detail: message }));
  516. break;
  517. case 'spoolbuddy_tag_matched':
  518. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', { detail: message }));
  519. debouncedInvalidate('inventory-spools');
  520. break;
  521. case 'spoolbuddy_unknown_tag':
  522. window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', { detail: message }));
  523. break;
  524. case 'spoolbuddy_tag_removed':
  525. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));
  526. break;
  527. case 'spoolbuddy_tag_written':
  528. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-written', { detail: message }));
  529. debouncedInvalidate('inventory-spools');
  530. break;
  531. case 'spoolbuddy_tag_write_failed':
  532. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-write-failed', { detail: message }));
  533. break;
  534. // Written to the chip, but not linked to the spool.
  535. case 'spoolbuddy_tag_link_failed':
  536. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-link-failed', { detail: message }));
  537. break;
  538. // A scanned tag could not be looked up at all, as opposed to an unknown tag.
  539. case 'spoolman_unavailable':
  540. window.dispatchEvent(new CustomEvent('spoolman-unavailable', { detail: message }));
  541. break;
  542. case 'spoolbuddy_lookup_error':
  543. window.dispatchEvent(new CustomEvent('spoolbuddy-lookup-error', { detail: message }));
  544. break;
  545. case 'spoolbuddy_unregistered':
  546. debouncedInvalidate('spoolbuddy-devices');
  547. break;
  548. case 'spoolbuddy_online':
  549. window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
  550. debouncedInvalidate('spoolbuddy-devices');
  551. debouncedInvalidate('spoolbuddy-update-check');
  552. break;
  553. case 'spoolbuddy_offline':
  554. window.dispatchEvent(new CustomEvent('spoolbuddy-offline', { detail: message }));
  555. debouncedInvalidate('spoolbuddy-devices');
  556. break;
  557. case 'spoolbuddy_update':
  558. debouncedInvalidate('spoolbuddy-devices');
  559. debouncedInvalidate('spoolbuddy-update-check');
  560. break;
  561. // Dispatch toast lifecycle (#1625 follow-up — restored the upload
  562. // progress UI that the scheduler unification removed). Four backend
  563. // event types collapse to one frontend channel. No
  564. // `queue_item_queued` (the toast must wait for the upload to
  565. // actually start) and no `queue_item_dispatched` (the legacy
  566. // background-dispatch flow kept status='processing' from upload
  567. // start until printer ack — the "Awaiting printer…" subtitle is
  568. // derived from upload_progress_pct >= 99.9, not from a separate
  569. // event).
  570. case 'queue_item_uploading':
  571. case 'queue_item_upload_progress':
  572. case 'queue_item_acked':
  573. case 'queue_item_failed':
  574. window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail: message }));
  575. break;
  576. // Slicer Pipeline runs (#1425 PR C). State transitions on the run
  577. // refresh both the dashboard list AND the per-pipeline "Last run"
  578. // chip in Settings → Pipelines.
  579. case 'pipeline_run_updated':
  580. queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
  581. if (message.run?.pipeline_id) {
  582. queryClient.invalidateQueries({ queryKey: ['pipeline-runs', message.run.pipeline_id] });
  583. }
  584. break;
  585. }
  586. }, [queryClient, debouncedInvalidate, invalidateSlotQueries, throttledPrinterStatusUpdate, showToast, t]);
  587. // Keep the ref updated with latest handleMessage
  588. useEffect(() => {
  589. handleMessageRef.current = handleMessage;
  590. }, [handleMessage]);
  591. useEffect(() => {
  592. // connect() is async after the GHSA-r2qv fix (mints a ws-token first).
  593. // Fire-and-forget at mount; the inner reconnect loop also calls
  594. // connect() in the ws.onclose handler.
  595. disposedRef.current = false;
  596. void connect();
  597. return () => {
  598. // Mark disposed BEFORE closing so the ws.onclose triggered by close()
  599. // sees it and won't schedule a post-unmount reconnect.
  600. disposedRef.current = true;
  601. if (reconnectTimeoutRef.current) {
  602. clearTimeout(reconnectTimeoutRef.current);
  603. }
  604. if (invalidationTimeoutRef.current) {
  605. clearTimeout(invalidationTimeoutRef.current);
  606. }
  607. if (printerStatusTimeoutRef.current) {
  608. clearTimeout(printerStatusTimeoutRef.current);
  609. }
  610. if (wsRef.current) {
  611. wsRef.current.close();
  612. }
  613. };
  614. }, [connect]);
  615. const sendMessage = useCallback((message: Record<string, unknown>) => {
  616. if (wsRef.current?.readyState === WebSocket.OPEN) {
  617. wsRef.current.send(JSON.stringify(message));
  618. }
  619. }, []);
  620. return { isConnected, sendMessage };
  621. }