diff --git a/ui/src/components/WaterPumpStatusProvider.js b/ui/src/components/WaterPumpStatusProvider.js deleted file mode 100644 index 5727fde..0000000 --- a/ui/src/components/WaterPumpStatusProvider.js +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import { updateSystemStatus } from '../store/slices/SystemStatus'; -import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; - -const FETCH_INTERVAL = 5000; -const CHECK_INTERVAL = Math.round(FETCH_INTERVAL / 10); - -function WaterPumpStatusProviderComoponent({ children, updateStatus, systemStatus }) { - const { API } = useWaterPumpAPI(); - const nextFetchTime = React.useRef(0); - - // Function to fetch water pump status - const fetchStatus = React.useCallback(async () => { - const now = Date.now(); - if(now < nextFetchTime.current) return; - if(null == API) return; - - nextFetchTime.current = Number.MAX_SAFE_INTEGER; // prevent concurrent fetches - await updateStatus({ api: API }); - nextFetchTime.current = Date.now() + FETCH_INTERVAL; - }, - [API, updateStatus, nextFetchTime] - ); - - // Effect to start fetching periodically and when API changes - React.useEffect(() => { - const timer = setInterval(fetchStatus, CHECK_INTERVAL); - return () => { clearInterval(timer); }; - }, [fetchStatus]); - - // Effect to reset timer when system status changes - React.useEffect(() => { - // reset timer if not fetching - const now = Date.now(); - if(now < nextFetchTime.current) { - nextFetchTime.current = 0; - } - }, [API, systemStatus, nextFetchTime]); - - return ( - - {children} - - ); -} - -export default connect( - (state) => ({ - systemStatus: state.systemStatus - }), { - updateStatus: updateSystemStatus - } -)(WaterPumpStatusProviderComoponent); \ No newline at end of file diff --git a/ui/src/contexts/WaterPumpAPIContext.js b/ui/src/contexts/WaterPumpAPIContext.js index ce8b453..92ff269 100644 --- a/ui/src/contexts/WaterPumpAPIContext.js +++ b/ui/src/contexts/WaterPumpAPIContext.js @@ -1,8 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; -import { startPump, stopPump } from '../store/slices/SystemStatus.js'; import { CWaterPumpAPI } from '../api/CWaterPumpAPI.js'; -import WaterPumpStatusProvider from '../components/WaterPumpStatusProvider.js'; +import { updateSystemStatus } from '../store/slices/SystemStatus.js'; const WaterPumpAPIContext = React.createContext(); @@ -10,10 +9,62 @@ export function useWaterPumpAPI() { return React.useContext(WaterPumpAPIContext); } +const FETCH_STATUS_INTERVAL = 5000; + +function _publicWrapper({ apiObject, apiQueue, _pouringTime, _powerLevel }) { + if(null == apiObject) return { API: null }; + return { + API: { + stopPump: () => { + apiQueue.push({ + action: async () => await apiObject.stop(), + failMessage: 'Failed to stop the pump' + }); + }, + startPump: () => { + apiQueue.push({ + action: async () => await apiObject.start( + _pouringTime.current, + _powerLevel.current + ), + failMessage: 'Failed to start the pump' + }); + }, + } + }; +} + +function _makeStatusAction(apiObject) { + return { + action: async () => await apiObject.status(), + failMessage: 'Failed to get the pump status' + }; +} + +async function _processQueue({ apiQueue, lastUpdateTime, statusAction, updateStatus }) { + const deltaTime = Date.now() - lastUpdateTime.current; + const hasTasks = (0 < apiQueue.length); + if((deltaTime < FETCH_STATUS_INTERVAL) && !hasTasks) return; + + const action = hasTasks ? apiQueue.shift() : statusAction; + const oldTime = lastUpdateTime.current; + lastUpdateTime.current = Number.MAX_SAFE_INTEGER; // prevent concurrent tasks, just in case + try { + await updateStatus(action); + lastUpdateTime.current = Date.now(); + } catch(error) { + lastUpdateTime.current = oldTime; + if(hasTasks) { // re-queue the action if it failed + apiQueue.unshift(action); + } + throw error; + } +} + function WaterPumpAPIProviderComponent({ children, apiHost, pouringTime, powerLevel, - startPump, stopPump, + updateStatus, }) { // to prevent the callbacks from changing when the pouringTime or powerLevel changes const _pouringTime = React.useRef(pouringTime); @@ -22,41 +73,35 @@ function WaterPumpAPIProviderComponent({ const _powerLevel = React.useRef(powerLevel); React.useEffect(() => { _powerLevel.current = powerLevel; }, [powerLevel]); - const apiObject = React.useMemo( - () => new CWaterPumpAPI({ URL: apiHost }), + const { apiObject, apiQueue } = React.useMemo( + () => ({ + apiObject: new CWaterPumpAPI({ URL: apiHost }), + apiQueue: [] + }), [apiHost] ); //////////////// - // create an API wrapper that dispatches actions to the Redux store - const value = React.useMemo( - () => { - if(null == apiObject) return { API: null }; - return { - API: { - stopPump: async () => { - return await stopPump({ api: apiObject }); - }, - startPump: async () => { - return await startPump({ - api: apiObject, - pouringTime: _pouringTime.current, - powerLevel: _powerLevel.current, - }); - }, - status: async () => { - return await apiObject.status(); - } - } - }; - }, - [apiObject, startPump, stopPump, _pouringTime, _powerLevel] + const statusAction = React.useMemo(() => _makeStatusAction(apiObject), [apiObject]); + const lastUpdateTime = React.useRef(0); + const onTick = React.useCallback( + async () => _processQueue({ apiQueue, lastUpdateTime, statusAction, updateStatus }), + [apiQueue, lastUpdateTime, updateStatus, statusAction] ); + // Run the timer + React.useEffect(() => { + const timer = setInterval(onTick, 100); + return () => { clearInterval(timer); }; + }, [onTick]); + + //////////////// + const value = React.useMemo( + () => _publicWrapper({ apiObject, apiQueue, _pouringTime, _powerLevel }), + [apiObject, apiQueue, _pouringTime, _powerLevel] + ); return ( - - {children} - + {children} ); } @@ -67,7 +112,7 @@ const WaterPumpAPIProvider = connect( pouringTime: state.UI.pouringTime, powerLevel: state.UI.powerLevelInPercents, }), - { startPump, stopPump } + { updateStatus: updateSystemStatus } )(WaterPumpAPIProviderComponent); export default WaterPumpAPIProvider; diff --git a/ui/src/store/slices/SystemStatus.js b/ui/src/store/slices/SystemStatus.js index 074479f..c0789ac 100644 --- a/ui/src/store/slices/SystemStatus.js +++ b/ui/src/store/slices/SystemStatus.js @@ -1,71 +1,29 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import { NotificationsSystemActions } from './Notifications'; -function withNotification(action, message) { - return async (params, { dispatch }) => { +// Async thunks +export const updateSystemStatus = createAsyncThunk( + 'systemStatus/update', + async ({ action, failMessage }, { dispatch }) => { try { - return await action(params); + return await action(); } catch(error) { - dispatch(NotificationsSystemActions.alert({ + await dispatch(NotificationsSystemActions.alert({ type: 'error', - message: `${message} (${error.message})` + message: `${failMessage} (${error.message})` })); throw error; } - }; -} - -// Async thunks -export const startPump = createAsyncThunk( - 'systemStatus/startPump', - withNotification( - async ({ api, pouringTime, powerLevel }) => { - return await api.start(pouringTime, powerLevel); - }, - 'Failed to start pump' - ) -); - -export const stopPump = createAsyncThunk( - 'systemStatus/stopPump', - withNotification( - async ({ api }) => { - return await api.stop(); - }, - 'Failed to stop pump' - ) -); - -export const updateSystemStatus = createAsyncThunk( - 'systemStatus/update', - withNotification( - async ({ api }) => { - return await api.status(); - }, - 'Failed to update system status' - ) -); - -// slice for system status -const bindStatus = (state, action) => { - return action.payload; -}; + } +); export const SystemStatusSlice = createSlice({ name: 'systemStatus', initialState: null, reducers: {}, extraReducers: (builder) => { - // update system status on start/stop pump - builder.addCase(startPump.fulfilled, bindStatus); - builder.addCase(stopPump.fulfilled, bindStatus); - builder.addCase(updateSystemStatus.fulfilled, bindStatus); - // on error, do not update system status - builder.addCase(startPump.rejected, (state, action) => state); - builder.addCase(stopPump.rejected, (state, action) => state); - builder.addCase(updateSystemStatus.rejected, (state, action) => { - return null; - }); + builder.addCase(updateSystemStatus.fulfilled, (state, action) => action.payload); + builder.addCase(updateSystemStatus.rejected, (state, action) => state); } });