useWebSocket.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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. interface WebSocketMessage {
  6. type: string;
  7. printer_id?: number;
  8. data?: Record<string, unknown>;
  9. printer_name?: string;
  10. missing_slots?: Array<{ slot?: string }>;
  11. }
  12. export function useWebSocket() {
  13. const wsRef = useRef<WebSocket | null>(null);
  14. const reconnectTimeoutRef = useRef<number | null>(null);
  15. const queryClient = useQueryClient();
  16. const [isConnected, setIsConnected] = useState(false);
  17. const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
  18. const { showToast } = useToast();
  19. const { t } = useTranslation();
  20. // Debounce invalidations to prevent rapid re-render cascades
  21. const pendingInvalidations = useRef<Set<string>>(new Set());
  22. const invalidationTimeoutRef = useRef<number | null>(null);
  23. // Throttle printer status updates to prevent freeze during rapid messages
  24. const pendingPrinterStatus = useRef<Map<number, Record<string, unknown>>>(new Map());
  25. const printerStatusTimeoutRef = useRef<number | null>(null);
  26. // Throttle message processing to prevent browser freeze
  27. const messageQueueRef = useRef<WebSocketMessage[]>([]);
  28. const processingRef = useRef(false);
  29. // Use ref for handleMessage to avoid stale closure in connect
  30. const handleMessageRef = useRef<(message: WebSocketMessage) => void>(() => {});
  31. // Process message queue with throttling to prevent UI freeze
  32. const processMessageQueue = useCallback(() => {
  33. if (processingRef.current || messageQueueRef.current.length === 0) {
  34. return;
  35. }
  36. processingRef.current = true;
  37. const processNext = () => {
  38. const message = messageQueueRef.current.shift();
  39. if (message) {
  40. // Use requestAnimationFrame to yield to the browser
  41. requestAnimationFrame(() => {
  42. handleMessageRef.current(message);
  43. // Small delay between messages to prevent overwhelming the browser
  44. if (messageQueueRef.current.length > 0) {
  45. setTimeout(processNext, 16); // ~60fps
  46. } else {
  47. processingRef.current = false;
  48. }
  49. });
  50. } else {
  51. processingRef.current = false;
  52. }
  53. };
  54. processNext();
  55. }, []);
  56. const connect = useCallback(() => {
  57. if (wsRef.current?.readyState === WebSocket.OPEN) {
  58. return;
  59. }
  60. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  61. const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
  62. const ws = new WebSocket(wsUrl);
  63. let pingInterval: number | null = null;
  64. ws.onopen = () => {
  65. if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Connected');
  66. setIsConnected(true);
  67. // Start ping interval
  68. pingInterval = window.setInterval(() => {
  69. if (ws.readyState === WebSocket.OPEN) {
  70. ws.send(JSON.stringify({ type: 'ping' }));
  71. }
  72. }, 30000);
  73. };
  74. ws.onmessage = (event) => {
  75. try {
  76. const message: WebSocketMessage = JSON.parse(event.data);
  77. // Handle printer_status directly (already throttled) to avoid queue delays
  78. // This prevents the "timelapse" effect where status updates are applied slowly
  79. if (message.type === 'printer_status' && message.printer_id !== undefined && message.data) {
  80. handleMessageRef.current(message);
  81. } else {
  82. // Queue other messages for throttled processing
  83. messageQueueRef.current.push(message);
  84. processMessageQueue();
  85. }
  86. } catch {
  87. // Ignore parse errors
  88. }
  89. };
  90. ws.onclose = (event) => {
  91. if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Closed', event.code, event.reason);
  92. if (pingInterval) {
  93. clearInterval(pingInterval);
  94. pingInterval = null;
  95. }
  96. setIsConnected(false);
  97. wsRef.current = null;
  98. // Reconnect after 3 seconds
  99. reconnectTimeoutRef.current = window.setTimeout(() => {
  100. connect();
  101. }, 3000);
  102. };
  103. ws.onerror = (error) => {
  104. if (import.meta.env.MODE !== 'test') console.error('[WebSocket] Error', error);
  105. ws.close();
  106. };
  107. wsRef.current = ws;
  108. }, [processMessageQueue]);
  109. // Throttled printer status update - coalesces rapid updates per printer
  110. const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
  111. // Merge with any pending data for this printer
  112. const existing = pendingPrinterStatus.current.get(printerId) || {};
  113. pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
  114. // Schedule update if not already scheduled
  115. if (!printerStatusTimeoutRef.current) {
  116. printerStatusTimeoutRef.current = window.setTimeout(() => {
  117. const updates = new Map(pendingPrinterStatus.current);
  118. pendingPrinterStatus.current.clear();
  119. printerStatusTimeoutRef.current = null;
  120. // Apply all pending updates
  121. requestAnimationFrame(() => {
  122. updates.forEach((statusData, id) => {
  123. queryClient.setQueryData(
  124. ['printerStatus', id],
  125. (old: Record<string, unknown> | undefined) => {
  126. const merged = { ...old, ...statusData };
  127. if (merged.wifi_signal == null && old?.wifi_signal != null) {
  128. merged.wifi_signal = old.wifi_signal;
  129. }
  130. return merged;
  131. }
  132. );
  133. });
  134. });
  135. }, 100); // Update at most every 100ms
  136. }
  137. }, [queryClient]);
  138. // Debounced invalidation helper - coalesces multiple rapid invalidations
  139. const debouncedInvalidate = useCallback((queryKey: string) => {
  140. pendingInvalidations.current.add(queryKey);
  141. // Clear existing timeout
  142. if (invalidationTimeoutRef.current) {
  143. clearTimeout(invalidationTimeoutRef.current);
  144. }
  145. // Schedule invalidation after a delay (3s to prevent browser freeze on print completion)
  146. invalidationTimeoutRef.current = window.setTimeout(() => {
  147. const keys = Array.from(pendingInvalidations.current);
  148. pendingInvalidations.current.clear();
  149. invalidationTimeoutRef.current = null;
  150. // Invalidate queries one at a time with delays to prevent freeze
  151. let delay = 0;
  152. keys.forEach((key) => {
  153. setTimeout(() => {
  154. requestAnimationFrame(() => {
  155. queryClient.invalidateQueries({ queryKey: [key] });
  156. });
  157. }, delay);
  158. delay += 500; // 500ms between each invalidation
  159. });
  160. }, 3000);
  161. }, [queryClient]);
  162. const handleMessage = useCallback((message: WebSocketMessage) => {
  163. switch (message.type) {
  164. case 'printer_status':
  165. if (message.printer_id !== undefined && message.data) {
  166. throttledPrinterStatusUpdate(message.printer_id, message.data);
  167. }
  168. break;
  169. case 'print_start':
  170. // Refetch printer status immediately when print starts to get printable_objects_count
  171. if (message.printer_id !== undefined) {
  172. queryClient.invalidateQueries({ queryKey: ['printerStatus', message.printer_id] });
  173. }
  174. break;
  175. case 'missing_spool_assignment': {
  176. if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {
  177. break;
  178. }
  179. const missingSlotLabels = message.missing_slots
  180. .map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))
  181. .filter((slot) => slot.length > 0);
  182. if (missingSlotLabels.length === 0) {
  183. lastMissingSpoolWarningRef.current.delete(message.printer_id);
  184. break;
  185. }
  186. const signature = missingSlotLabels.join('|');
  187. if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {
  188. break;
  189. }
  190. lastMissingSpoolWarningRef.current.set(message.printer_id, signature);
  191. const printerName = message.printer_name || `Printer ${message.printer_id}`;
  192. const toastMsg = t('printers.toast.missingSpoolAssignment', {
  193. printer: printerName,
  194. slots: missingSlotLabels.join(', '),
  195. });
  196. showToast(toastMsg, 'warning');
  197. break;
  198. }
  199. case 'print_complete':
  200. // Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
  201. // The printer_status websocket messages will naturally update the status
  202. debouncedInvalidate('archives');
  203. debouncedInvalidate('archiveStats');
  204. break;
  205. case 'archive_created':
  206. debouncedInvalidate('archives');
  207. debouncedInvalidate('archiveStats');
  208. break;
  209. case 'archive_updated':
  210. debouncedInvalidate('archives');
  211. break;
  212. case 'pong':
  213. // Keepalive response, ignore
  214. break;
  215. case 'plate_not_empty':
  216. // Plate detection found objects - print was paused
  217. // Dispatch event for toast notification
  218. window.dispatchEvent(new CustomEvent('plate-not-empty', {
  219. detail: {
  220. printer_id: message.printer_id,
  221. printer_name: (message as unknown as { printer_name?: string }).printer_name,
  222. message: (message as unknown as { message?: string }).message,
  223. }
  224. }));
  225. break;
  226. case 'spool_assignment_changed':
  227. // Spool assigned/unassigned - refresh assignment data across all tabs
  228. debouncedInvalidate('spool-assignments');
  229. debouncedInvalidate('slotPresets');
  230. break;
  231. case 'spool_auto_assigned':
  232. // RFID tag matched - refresh inventory and assignment data
  233. debouncedInvalidate('inventory-spools');
  234. debouncedInvalidate('spool-assignments');
  235. break;
  236. case 'spool_usage_logged':
  237. // Filament consumption recorded - refresh spool data
  238. debouncedInvalidate('inventory-spools');
  239. break;
  240. case 'unknown_tag':
  241. // Unknown RFID tag detected - dispatch event for UI
  242. window.dispatchEvent(new CustomEvent('unknown-tag', {
  243. detail: {
  244. printer_id: (message as unknown as { printer_id?: number }).printer_id,
  245. ams_id: (message as unknown as { ams_id?: number }).ams_id,
  246. tray_id: (message as unknown as { tray_id?: number }).tray_id,
  247. tag_uid: (message as unknown as { tag_uid?: string }).tag_uid,
  248. tray_uuid: (message as unknown as { tray_uuid?: string }).tray_uuid,
  249. }
  250. }));
  251. break;
  252. case 'background_dispatch':
  253. window.dispatchEvent(
  254. new CustomEvent('background-dispatch', {
  255. detail: (message as unknown as { data?: Record<string, unknown> }).data || {},
  256. })
  257. );
  258. break;
  259. case 'spoolbuddy_weight':
  260. window.dispatchEvent(new CustomEvent('spoolbuddy-weight', { detail: message }));
  261. break;
  262. case 'spoolbuddy_tag_matched':
  263. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', { detail: message }));
  264. debouncedInvalidate('inventory-spools');
  265. break;
  266. case 'spoolbuddy_unknown_tag':
  267. window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', { detail: message }));
  268. break;
  269. case 'spoolbuddy_tag_removed':
  270. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', { detail: message }));
  271. break;
  272. case 'spoolbuddy_tag_written':
  273. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-written', { detail: message }));
  274. debouncedInvalidate('inventory-spools');
  275. break;
  276. case 'spoolbuddy_tag_write_failed':
  277. window.dispatchEvent(new CustomEvent('spoolbuddy-tag-write-failed', { detail: message }));
  278. break;
  279. case 'spoolbuddy_online':
  280. window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
  281. debouncedInvalidate('spoolbuddy-devices');
  282. debouncedInvalidate('spoolbuddy-update-check');
  283. break;
  284. case 'spoolbuddy_offline':
  285. window.dispatchEvent(new CustomEvent('spoolbuddy-offline', { detail: message }));
  286. debouncedInvalidate('spoolbuddy-devices');
  287. break;
  288. case 'spoolbuddy_update':
  289. debouncedInvalidate('spoolbuddy-devices');
  290. debouncedInvalidate('spoolbuddy-update-check');
  291. break;
  292. }
  293. }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
  294. // Keep the ref updated with latest handleMessage
  295. useEffect(() => {
  296. handleMessageRef.current = handleMessage;
  297. }, [handleMessage]);
  298. useEffect(() => {
  299. connect();
  300. return () => {
  301. if (reconnectTimeoutRef.current) {
  302. clearTimeout(reconnectTimeoutRef.current);
  303. }
  304. if (invalidationTimeoutRef.current) {
  305. clearTimeout(invalidationTimeoutRef.current);
  306. }
  307. if (printerStatusTimeoutRef.current) {
  308. clearTimeout(printerStatusTimeoutRef.current);
  309. }
  310. if (wsRef.current) {
  311. wsRef.current.close();
  312. }
  313. };
  314. }, [connect]);
  315. const sendMessage = useCallback((message: Record<string, unknown>) => {
  316. if (wsRef.current?.readyState === WebSocket.OPEN) {
  317. wsRef.current.send(JSON.stringify(message));
  318. }
  319. }, []);
  320. return { isConnected, sendMessage };
  321. }