diff --git a/ui/src/components/HoldToPour.js b/ui/src/components/HoldToPour.js index cc49fee..1cdab7e 100644 --- a/ui/src/components/HoldToPour.js +++ b/ui/src/components/HoldToPour.js @@ -2,9 +2,9 @@ import React, { useState, useEffect } from 'react'; import { connect } from 'react-redux'; import { Container, Form } from 'react-bootstrap'; import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; -import { startPump, stopPump } from '../store/slices/SystemStatus.js'; -export function HoldToPourComponent({ startPump, stopPump, interval }) { +export function HoldToPourComponent({ interval }) { + const { API }= useWaterPumpAPI(); const [isPouring, setIsPouring] = useState(false); const [clickToPour, setClickToPour] = useState(false); // continuously pour water while the button is pressed @@ -14,28 +14,28 @@ export function HoldToPourComponent({ startPump, stopPump, interval }) { if(Date.now() < lastPouringTime.current) return; try { lastPouringTime.current = Number.MAX_SAFE_INTEGER; // prevent concurrent calls - await startPump(); + await API.startPump(); lastPouringTime.current = Date.now() + interval; } catch(e) { lastPouringTime.current = 0; // run again on next tick } }, - [startPump, interval] + [interval, API] ); useEffect(() => { if(!isPouring) { lastPouringTime.current = 0; - stopPump(); + API.stopPump(); return; } // tick every 100ms const tid = setInterval(onTick, 100); return async () => { clearInterval(tid); - if(isPouring) await stopPump(); + if(isPouring) await API.stopPump(); }; - }, [onTick, isPouring, stopPump, lastPouringTime]); + }, [onTick, isPouring, API]); const handlePress = () => { setIsPouring(true); }; const handleRelease = () => { setIsPouring(false); }; @@ -65,43 +65,18 @@ export function HoldToPourComponent({ startPump, stopPump, interval }) { } // Helper wrapper to simplify the code in the component -function HoldToPourComponent_withExtras({ pouringTime, powerLevel, startPump, stopPump }) { - const api = useWaterPumpAPI().API; - // to prevent the callback from changing when the pouringTime or powerLevel changes - const _pouringTime = React.useRef(pouringTime); - React.useEffect(() => { _pouringTime.current = pouringTime; }, [pouringTime]); - - const _powerLevel = React.useRef(powerLevel); - React.useEffect(() => { _powerLevel.current = powerLevel; }, [powerLevel]); - - const _startPump = React.useCallback( - async () => { - await startPump({ - api, - pouringTime: _pouringTime.current, - powerLevel: _powerLevel.current, - }); - }, [api, startPump, _pouringTime, _powerLevel] - ); - const _stopPump = React.useCallback( - async () => { await stopPump({ api }); }, - [api, stopPump] - ); +function HoldToPourComponent_withExtras({ pouringTime, ...props }) { // a bit smaller than the actual pouring time, to prevent the pump from stopping // which could damage the pump const interval = Math.max(Math.round(pouringTime - 500), 100); return ( - + ); }; export default connect( state => ({ pouringTime: state.UI.pouringTime, - powerLevel: state.UI.powerLevelInPercents, }), - { startPump, stopPump } + { } )(HoldToPourComponent_withExtras); \ No newline at end of file diff --git a/ui/src/components/SystemControls.js b/ui/src/components/SystemControls.js index 86b085f..5dc2983 100644 --- a/ui/src/components/SystemControls.js +++ b/ui/src/components/SystemControls.js @@ -3,19 +3,11 @@ import { connect } from 'react-redux'; import { Button, Container } from 'react-bootstrap'; import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; -import { startPump, stopPump } from '../store/slices/SystemStatus.js'; -export function SystemControlsComponent({ - pouringTime, powerLevel, systemStatus, startPump, stopPump -}) { - const api = useWaterPumpAPI().API; - const handleStart = async () => { - await startPump({ api, pouringTime, powerLevel }); - }; - - const handleStop = async () => { - await stopPump({ api }); - }; +export function SystemControlsComponent({ systemStatus }) { + const { API } = useWaterPumpAPI(); + const handleStart = async () => { await API.startPump(); }; + const handleStop = async () => { await API.stopPump(); }; const isRunning = systemStatus.pump.running; return ( @@ -32,8 +24,6 @@ export function SystemControlsComponent({ export default connect( state => ({ - pouringTime: state.UI.pouringTime, - powerLevel: state.UI.powerLevelInPercents, systemStatus: state.systemStatus, - }), { startPump, stopPump } + }), { } )(SystemControlsComponent); \ No newline at end of file diff --git a/ui/src/components/WaterPumpStatusProvider.js b/ui/src/components/WaterPumpStatusProvider.js deleted file mode 100644 index 035cfed..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); - 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 209177b..92ff269 100644 --- a/ui/src/contexts/WaterPumpAPIContext.js +++ b/ui/src/contexts/WaterPumpAPIContext.js @@ -1,7 +1,7 @@ import React from 'react'; -import { useSelector } from 'react-redux'; +import { connect } from 'react-redux'; import { CWaterPumpAPI } from '../api/CWaterPumpAPI.js'; -import WaterPumpStatusProvider from '../components/WaterPumpStatusProvider.js'; +import { updateSystemStatus } from '../store/slices/SystemStatus.js'; const WaterPumpAPIContext = React.createContext(); @@ -9,20 +9,111 @@ export function useWaterPumpAPI() { return React.useContext(WaterPumpAPIContext); } -export function WaterPumpAPIProvider({ children }) { - const apiHost = useSelector((state) => state.UI.apiHost); - const apiObject = React.useMemo( - () => new CWaterPumpAPI({ URL: apiHost }), +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, + updateStatus, +}) { + // to prevent the callbacks from changing when the pouringTime or powerLevel changes + const _pouringTime = React.useRef(pouringTime); + React.useEffect(() => { _pouringTime.current = pouringTime; }, [pouringTime]); + + const _powerLevel = React.useRef(powerLevel); + React.useEffect(() => { _powerLevel.current = powerLevel; }, [powerLevel]); + + const { apiObject, apiQueue } = React.useMemo( + () => ({ + apiObject: new CWaterPumpAPI({ URL: apiHost }), + apiQueue: [] + }), [apiHost] ); - // TODO: provide also the API methods with binded values from the store - // to simplify the code in the components (HodlToPour and PowerLevel) - const value = { API: apiObject, }; + //////////////// + 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} ); -} \ No newline at end of file +} + +const WaterPumpAPIProvider = connect( + state => ({ + apiHost: state.UI.apiHost, + pouringTime: state.UI.pouringTime, + powerLevel: state.UI.powerLevelInPercents, + }), + { updateStatus: updateSystemStatus } +)(WaterPumpAPIProviderComponent); + +export default WaterPumpAPIProvider; +export { WaterPumpAPIProvider }; \ No newline at end of file diff --git a/ui/src/store/index.js b/ui/src/store/index.js index a585188..a9f6a86 100644 --- a/ui/src/store/index.js +++ b/ui/src/store/index.js @@ -1,6 +1,9 @@ import { combineReducers, configureStore } from "@reduxjs/toolkit"; import { Provider } from "react-redux"; -import { persistStore, persistReducer } from 'redux-persist'; +import { + persistReducer, persistStore, + FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER +} from "redux-persist"; import storage from 'redux-persist/lib/storage'; // defaults to localStorage for web // slices @@ -50,6 +53,11 @@ const AppStore = ({ children, preloadedState = {}, returnStore = false }) => { const store = configureStore({ reducer: persistedReducer, preloadedState: state, + middleware: (getDefaultMiddleware) => getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + } + }) }); const persistor = persistStore(store); diff --git a/ui/src/store/slices/SystemStatus.js b/ui/src/store/slices/SystemStatus.js index 212b469..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); } });