useAmsOperations.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import { useState, useRef, useCallback, useEffect } from 'react';
  2. import { useMutation } from '@tanstack/react-query';
  3. import { api } from '../../api/client';
  4. import type { AMSUnit } from '../../api/client';
  5. /**
  6. * AMS Operation State Machine
  7. *
  8. * States:
  9. * - IDLE: No operation in progress, all buttons enabled
  10. * - REFRESHING: RFID refresh in progress for a specific slot
  11. * - LOADING: Filament load in progress
  12. * - UNLOADING: Filament unload in progress
  13. *
  14. * Completion detection:
  15. * - REFRESH: AMS tray data changes (tag_uid, tray_uuid, etc.) OR timeout (15s)
  16. * - LOAD/UNLOAD: ams_status_main transitions from 1 (filament_change) to 0 (idle) OR timeout (60s)
  17. *
  18. * Rules:
  19. * - Only one operation at a time
  20. * - All operations have timeout fallback
  21. * - Operation can be cancelled/reset manually
  22. */
  23. export type OperationState = 'IDLE' | 'REFRESHING' | 'LOADING' | 'UNLOADING';
  24. export interface RefreshTarget {
  25. amsId: number;
  26. trayId: number;
  27. }
  28. export interface OperationContext {
  29. // For REFRESHING: which slot is being refreshed
  30. refreshTarget?: RefreshTarget;
  31. // For LOADING: target tray ID we're loading
  32. loadTargetTrayId?: number;
  33. // Timestamp when operation started
  34. startTime: number;
  35. }
  36. interface UseAmsOperationsProps {
  37. printerId: number;
  38. amsUnits: AMSUnit[];
  39. amsStatusMain: number;
  40. trayNow: number;
  41. lastAmsUpdate: number; // Backend timestamp for detecting AMS data updates
  42. onToast: (message: string, type: 'success' | 'error') => void;
  43. }
  44. interface UseAmsOperationsReturn {
  45. // Current state
  46. state: OperationState;
  47. context: OperationContext | null;
  48. // Operation triggers
  49. startRefresh: (amsId: number, trayId: number) => void;
  50. startLoad: (trayId: number, extruderId?: number) => void;
  51. startUnload: () => void;
  52. // Manual reset (e.g., for retry)
  53. reset: () => void;
  54. // Derived state helpers
  55. isOperationInProgress: boolean;
  56. isRefreshingSlot: (amsId: number, trayId: number) => boolean;
  57. // For FilamentChangeCard - which type of operation
  58. isLoadOperation: boolean;
  59. loadTargetTrayId: number | null;
  60. // Last initiated operation type - persists after state reset
  61. // Used to determine card type when MQTT shows change but our state is IDLE
  62. lastOperationType: 'load' | 'unload' | null;
  63. // Mutation error states (for UI feedback)
  64. loadError: Error | null;
  65. unloadError: Error | null;
  66. refreshError: Error | null;
  67. }
  68. // Timeouts for different operations
  69. const REFRESH_TIMEOUT_MS = 15000; // 15 seconds for RFID refresh
  70. const FILAMENT_CHANGE_TIMEOUT_MS = 120000; // 2 minutes for load/unload (these can take a while with heating)
  71. export function useAmsOperations({
  72. printerId,
  73. amsUnits,
  74. amsStatusMain,
  75. trayNow,
  76. lastAmsUpdate,
  77. onToast,
  78. }: UseAmsOperationsProps): UseAmsOperationsReturn {
  79. const [state, setState] = useState<OperationState>('IDLE');
  80. const [context, setContext] = useState<OperationContext | null>(null);
  81. // Track the last operation type (load vs unload) - persists after state reset
  82. // This helps show correct card type when MQTT shows filament change but our state is IDLE
  83. // Using state instead of ref so changes trigger re-renders in consuming components
  84. const [lastOperationType, setLastOperationType] = useState<'load' | 'unload' | null>(null);
  85. // Track previous values for transition detection
  86. const prevAmsStatusMainRef = useRef(amsStatusMain);
  87. // Track initial tray data signature for detecting changes during refresh
  88. const refreshInitialDataRef = useRef<string>('');
  89. // Track initial lastAmsUpdate value at start of refresh (to detect when new data arrives)
  90. const refreshInitialAmsUpdateRef = useRef<number>(0);
  91. // Timeout ref for cleanup
  92. const timeoutRef = useRef<NodeJS.Timeout | null>(null);
  93. // Clear any pending timeout
  94. const clearOperationTimeout = useCallback(() => {
  95. if (timeoutRef.current) {
  96. clearTimeout(timeoutRef.current);
  97. timeoutRef.current = null;
  98. }
  99. }, []);
  100. // Reset to IDLE state
  101. const reset = useCallback(() => {
  102. clearOperationTimeout();
  103. setState('IDLE');
  104. setContext(null);
  105. refreshInitialDataRef.current = '';
  106. refreshInitialAmsUpdateRef.current = 0;
  107. }, [clearOperationTimeout]);
  108. // === Mutations ===
  109. const refreshMutation = useMutation({
  110. mutationFn: ({ amsId, trayId }: { amsId: number; trayId: number }) =>
  111. api.refreshAmsTray(printerId, amsId, trayId),
  112. onSuccess: (data) => {
  113. if (data.success) {
  114. onToast(data.message || 'RFID refresh started', 'success');
  115. } else {
  116. onToast(data.message || 'Failed to refresh tray', 'error');
  117. reset();
  118. }
  119. },
  120. onError: (error) => {
  121. console.error('[useAmsOperations] Refresh error:', error);
  122. onToast('Failed to refresh tray', 'error');
  123. reset();
  124. },
  125. });
  126. const loadMutation = useMutation({
  127. mutationFn: ({ trayId, extruderId }: { trayId: number; extruderId?: number }) =>
  128. api.amsLoadFilament(printerId, trayId, extruderId),
  129. onSuccess: (data) => {
  130. console.log('[useAmsOperations] Load request sent:', data);
  131. // Don't reset here - wait for ams_status_main transition
  132. },
  133. onError: (error) => {
  134. console.error('[useAmsOperations] Load error:', error);
  135. reset();
  136. },
  137. });
  138. const unloadMutation = useMutation({
  139. mutationFn: () => api.amsUnloadFilament(printerId),
  140. onSuccess: (data) => {
  141. console.log('[useAmsOperations] Unload request sent:', data);
  142. // Don't reset here - wait for ams_status_main transition
  143. },
  144. onError: (error) => {
  145. console.error('[useAmsOperations] Unload error:', error);
  146. reset();
  147. },
  148. });
  149. // === Operation Triggers ===
  150. const startRefresh = useCallback((amsId: number, trayId: number) => {
  151. if (state !== 'IDLE') {
  152. console.log('[useAmsOperations] Cannot start refresh - operation in progress:', state);
  153. return;
  154. }
  155. // Capture current tray data to detect changes
  156. const unit = amsUnits.find(u => u.id === amsId);
  157. const tray = unit?.tray?.find(t => t.id === trayId);
  158. if (tray) {
  159. refreshInitialDataRef.current = JSON.stringify({
  160. tag_uid: tray.tag_uid,
  161. tray_uuid: tray.tray_uuid,
  162. tray_type: tray.tray_type,
  163. tray_color: tray.tray_color,
  164. });
  165. }
  166. // Capture initial lastAmsUpdate to detect when NEW data arrives
  167. refreshInitialAmsUpdateRef.current = lastAmsUpdate;
  168. const startTime = Date.now();
  169. console.log(`[useAmsOperations] Starting refresh: AMS ${amsId}, Tray ${trayId}, startTime=${startTime}, initialAmsUpdate=${lastAmsUpdate}`);
  170. setState('REFRESHING');
  171. setContext({ refreshTarget: { amsId, trayId }, startTime });
  172. // Set timeout
  173. timeoutRef.current = setTimeout(() => {
  174. console.log(`[useAmsOperations] Refresh timeout for AMS ${amsId} tray ${trayId}`);
  175. reset();
  176. }, REFRESH_TIMEOUT_MS);
  177. refreshMutation.mutate({ amsId, trayId });
  178. }, [state, amsUnits, lastAmsUpdate, reset, refreshMutation]);
  179. const startLoad = useCallback((trayId: number, extruderId?: number) => {
  180. if (state !== 'IDLE') {
  181. console.log('[useAmsOperations] Cannot start load - operation in progress:', state);
  182. return;
  183. }
  184. console.log(`[useAmsOperations] Starting load: tray ${trayId}, extruder ${extruderId}`);
  185. const startTime = Date.now();
  186. setState('LOADING');
  187. setContext({ loadTargetTrayId: trayId, startTime });
  188. setLastOperationType('load'); // Remember this was a load operation
  189. // Set timeout
  190. timeoutRef.current = setTimeout(() => {
  191. console.log(`[useAmsOperations] Load timeout for tray ${trayId}`);
  192. reset();
  193. }, FILAMENT_CHANGE_TIMEOUT_MS);
  194. loadMutation.mutate({ trayId, extruderId });
  195. }, [state, reset, loadMutation]);
  196. const startUnload = useCallback(() => {
  197. if (state !== 'IDLE') {
  198. console.log('[useAmsOperations] Cannot start unload - operation in progress:', state);
  199. return;
  200. }
  201. console.log('[useAmsOperations] Starting unload');
  202. const startTime = Date.now();
  203. setState('UNLOADING');
  204. setContext({ startTime });
  205. setLastOperationType('unload'); // Remember this was an unload operation
  206. // Set timeout
  207. timeoutRef.current = setTimeout(() => {
  208. console.log('[useAmsOperations] Unload timeout');
  209. reset();
  210. }, FILAMENT_CHANGE_TIMEOUT_MS);
  211. unloadMutation.mutate();
  212. }, [state, reset, unloadMutation]);
  213. // === Completion Detection ===
  214. // Detect REFRESH completion by waiting for new AMS data to arrive
  215. // RFID read takes 5-10 seconds. We detect completion when:
  216. // 1. Data changed (new/different spool detected) - complete after minimum 1s
  217. // 2. lastAmsUpdate timestamp changed from initial AND elapsed > 5 seconds
  218. // This means a new AMS data packet arrived after the RFID read should be done
  219. useEffect(() => {
  220. if (state !== 'REFRESHING' || !context?.refreshTarget) return;
  221. const { amsId, trayId } = context.refreshTarget;
  222. const elapsed = Date.now() - context.startTime;
  223. // Get current tray data
  224. const unit = amsUnits.find(u => u.id === amsId);
  225. const tray = unit?.tray?.find(t => t.id === trayId);
  226. if (!tray) return;
  227. const currentData = JSON.stringify({
  228. tag_uid: tray.tag_uid,
  229. tray_uuid: tray.tray_uuid,
  230. tray_type: tray.tray_type,
  231. tray_color: tray.tray_color,
  232. });
  233. // Check if data changed (new spool detected)
  234. const dataChanged = refreshInitialDataRef.current && currentData !== refreshInitialDataRef.current;
  235. // Check if lastAmsUpdate changed from when we started
  236. const amsUpdateChanged = lastAmsUpdate !== refreshInitialAmsUpdateRef.current;
  237. // Primary completion: data changed (new spool detected) - complete quickly
  238. if (dataChanged && elapsed > 1000) {
  239. console.log(`[useAmsOperations] Refresh complete: data changed for AMS ${amsId} tray ${trayId} (took ${elapsed}ms)`);
  240. reset();
  241. return;
  242. }
  243. // Secondary completion: new AMS update received after minimum wait time
  244. // Wait 8 seconds to ensure RFID read has time to complete before considering updates
  245. if (amsUpdateChanged && elapsed > 8000) {
  246. console.log(`[useAmsOperations] Refresh complete: new AMS update after ${elapsed}ms for AMS ${amsId} tray ${trayId}`);
  247. reset();
  248. }
  249. }, [state, context, amsUnits, lastAmsUpdate, reset]);
  250. // Detect LOAD/UNLOAD completion via ams_status_main transition 1 → 0
  251. useEffect(() => {
  252. if (state !== 'LOADING' && state !== 'UNLOADING') {
  253. prevAmsStatusMainRef.current = amsStatusMain;
  254. return;
  255. }
  256. const wasActive = prevAmsStatusMainRef.current === 1;
  257. const isNowIdle = amsStatusMain === 0;
  258. if (wasActive && isNowIdle) {
  259. console.log(`[useAmsOperations] ${state} complete: ams_status_main transitioned 1→0`);
  260. reset();
  261. }
  262. prevAmsStatusMainRef.current = amsStatusMain;
  263. }, [state, amsStatusMain, reset]);
  264. // Secondary completion detection for LOAD: tray_now matches target
  265. // Wait at least 5 seconds to ensure the filament actually reached the nozzle
  266. // (tray_now can be updated before the physical load is complete)
  267. useEffect(() => {
  268. if (state !== 'LOADING' || !context?.loadTargetTrayId) return;
  269. const elapsed = context?.startTime ? Date.now() - context.startTime : 0;
  270. if (trayNow === context.loadTargetTrayId && elapsed > 5000) {
  271. console.log(`[useAmsOperations] Load complete: tray_now=${trayNow} matches target (${elapsed}ms elapsed)`);
  272. reset();
  273. }
  274. }, [state, context, trayNow, reset]);
  275. // Secondary completion detection for UNLOAD: tray_now becomes 255
  276. useEffect(() => {
  277. if (state !== 'UNLOADING') return;
  278. // Only trigger if we're past the initial phase (give it 1s to start)
  279. const elapsed = context?.startTime ? Date.now() - context.startTime : 0;
  280. if (trayNow === 255 && elapsed > 1000) {
  281. console.log('[useAmsOperations] Unload complete: tray_now=255');
  282. reset();
  283. }
  284. }, [state, context, trayNow, reset]);
  285. // Cleanup on unmount
  286. useEffect(() => {
  287. return () => {
  288. clearOperationTimeout();
  289. };
  290. }, [clearOperationTimeout]);
  291. // === Derived State ===
  292. const isOperationInProgress = state !== 'IDLE';
  293. const isRefreshingSlot = useCallback((amsId: number, trayId: number) => {
  294. if (state !== 'REFRESHING' || !context?.refreshTarget) return false;
  295. return context.refreshTarget.amsId === amsId && context.refreshTarget.trayId === trayId;
  296. }, [state, context]);
  297. const isLoadOperation = state === 'LOADING';
  298. const loadTargetTrayId = context?.loadTargetTrayId ?? null;
  299. return {
  300. state,
  301. context,
  302. startRefresh,
  303. startLoad,
  304. startUnload,
  305. reset,
  306. isOperationInProgress,
  307. isRefreshingSlot,
  308. isLoadOperation,
  309. loadTargetTrayId,
  310. lastOperationType,
  311. loadError: loadMutation.error,
  312. unloadError: unloadMutation.error,
  313. refreshError: refreshMutation.error,
  314. };
  315. }