useStatusStore.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import { create } from 'zustand'
  2. import { apiClient } from '@/lib/apiClient'
  3. export interface StatusData {
  4. current_file: string | null
  5. is_paused: boolean
  6. manual_pause: boolean
  7. scheduled_pause: boolean
  8. is_running: boolean
  9. is_homing: boolean
  10. is_clearing: boolean
  11. sensor_homing_failed: boolean
  12. progress: {
  13. current: number
  14. total: number
  15. remaining_time: number | null
  16. elapsed_time: number
  17. percentage: number
  18. last_completed_time?: {
  19. actual_time_seconds: number
  20. actual_time_formatted: string
  21. timestamp: string
  22. }
  23. } | null
  24. playlist: {
  25. current_index: number
  26. total_files: number
  27. mode: string
  28. next_file: string | null
  29. files: string[]
  30. name: string | null
  31. } | null
  32. speed: number
  33. pause_time_remaining: number
  34. original_pause_time: number | null
  35. connection_status: boolean
  36. current_theta: number
  37. current_rho: number
  38. firmware_version: string | null
  39. table_type: string | null
  40. }
  41. interface StatusStore {
  42. isBackendConnected: boolean
  43. connectionAttempts: number
  44. status: StatusData | null
  45. }
  46. export const useStatusStore = create<StatusStore>()(() => ({
  47. isBackendConnected: false,
  48. connectionAttempts: 0,
  49. status: null,
  50. }))
  51. // --- Module-level WebSocket lifecycle (singleton, outside React) ---
  52. let ws: WebSocket | null = null
  53. let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  54. let isStopped = false
  55. function connectWebSocket() {
  56. if (isStopped) return
  57. // Don't interrupt an existing connection that's still connecting
  58. if (ws) {
  59. if (ws.readyState === WebSocket.CONNECTING) return
  60. if (ws.readyState === WebSocket.OPEN) ws.close()
  61. ws = null
  62. }
  63. const socket = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
  64. ws = socket
  65. // Every handler checks `ws === socket` so a socket orphaned by a table
  66. // switch (including one still CONNECTING when the switch happened) can
  67. // never update the store on behalf of the new connection
  68. socket.onopen = () => {
  69. if (isStopped || ws !== socket) {
  70. socket.close()
  71. return
  72. }
  73. useStatusStore.setState({ isBackendConnected: true, connectionAttempts: 0 })
  74. window.dispatchEvent(new CustomEvent('backend-connected'))
  75. }
  76. socket.onmessage = (event) => {
  77. if (isStopped || ws !== socket) return
  78. try {
  79. const data = JSON.parse(event.data)
  80. if (data.type === 'status_update' && data.data) {
  81. useStatusStore.setState({ status: data.data })
  82. }
  83. } catch {
  84. // Ignore parse errors
  85. }
  86. }
  87. socket.onclose = () => {
  88. if (isStopped || ws !== socket) return
  89. ws = null
  90. const attempts = useStatusStore.getState().connectionAttempts + 1
  91. useStatusStore.setState({
  92. isBackendConnected: false,
  93. connectionAttempts: attempts,
  94. })
  95. // Exponential backoff with jitter, capped at 30s — a flat interval makes
  96. // every open tab hammer the backend in lockstep while it's down
  97. const delay =
  98. Math.min(30_000, 3_000 * 2 ** Math.min(attempts - 1, 4)) + Math.random() * 1_000
  99. reconnectTimeout = setTimeout(connectWebSocket, delay)
  100. }
  101. socket.onerror = () => {
  102. if (isStopped || ws !== socket) return
  103. useStatusStore.setState({ isBackendConnected: false })
  104. }
  105. }
  106. // Reconnect on table switch
  107. apiClient.onBaseUrlChange(() => {
  108. useStatusStore.setState({ status: null, isBackendConnected: false })
  109. if (reconnectTimeout) {
  110. clearTimeout(reconnectTimeout)
  111. reconnectTimeout = null
  112. }
  113. // Close existing and reconnect. Null the ref first so the old socket's
  114. // handlers see ws !== socket and stand down; close() is valid (and
  115. // necessary) even on a socket still in CONNECTING state.
  116. if (ws) {
  117. const oldSocket = ws
  118. ws = null
  119. oldSocket.close()
  120. }
  121. connectWebSocket()
  122. })
  123. // Start connection immediately at module load
  124. connectWebSocket()
  125. // --- Playback transition detection ---
  126. let wasPlaying: boolean | null = null
  127. useStatusStore.subscribe((state) => {
  128. const s = state.status
  129. if (!s) return
  130. const isPlaying =
  131. Boolean(s.current_file) ||
  132. Boolean(s.is_running) ||
  133. Boolean(s.is_paused) ||
  134. (s.pause_time_remaining ?? 0) > 0
  135. // Skip first message (page refresh) - only react to transitions
  136. if (wasPlaying !== null) {
  137. if (isPlaying && !wasPlaying) {
  138. window.dispatchEvent(new CustomEvent('playback-started'))
  139. }
  140. }
  141. wasPlaying = isPlaying
  142. })
  143. // Reset wasPlaying on table switch so we don't fire false transitions
  144. apiClient.onBaseUrlChange(() => {
  145. wasPlaying = null
  146. })
  147. // For HMR / cleanup in tests
  148. export function _stopStatusWebSocket() {
  149. isStopped = true
  150. if (reconnectTimeout) clearTimeout(reconnectTimeout)
  151. // close() is safe in any readyState, including CONNECTING
  152. ws?.close()
  153. ws = null
  154. }