From 1627463be0f1d155fb775d133d578b69f6a0cf7c Mon Sep 17 00:00:00 2001 From: GreenWizard Date: Mon, 8 Jan 2024 10:42:24 +0100 Subject: [PATCH 01/15] add "time" field --- controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp | 4 +++- .../tea_poor/test/test_native/tests/CommandProcessor_test.h | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp b/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp index c776abf..e5ea18b 100644 --- a/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp +++ b/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp @@ -20,12 +20,14 @@ bool isValidIntNumber(const char *str, const int maxValue, const int minValue=0) std::string CommandProcessor::status() { std::stringstream response; + const auto now = _env->time(); response << "{"; + // send current time in milliseconds to synchronize time on client side + response << "\"time\": " << now << ", "; // send water threshold response << "\"water threshold\": " << _waterPumpSafeThreshold << ", "; // send water pump status const auto waterPumpStatus = _waterPump->status(); - const auto now = _env->time(); const auto timeLeft = waterPumpStatus.isRunning ? waterPumpStatus.stopTime - now : 0; response << "\"pump\": {" diff --git a/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h b/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h index 291f814..c99b5d2 100644 --- a/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h +++ b/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h @@ -46,6 +46,7 @@ TEST(CommandProcessor, status) { CommandProcessor commandProcessor(123, env, waterPump); const auto response = commandProcessor.status(); ASSERT_EQ(response, "{" + "\"time\": 0, " "\"water threshold\": 123, " "\"pump\": {" " \"running\": false, " @@ -69,6 +70,7 @@ TEST(CommandProcessor, status_running) { const auto response = commandProcessor.status(); ASSERT_EQ(response, "{" + "\"time\": 123, " "\"water threshold\": 12345, " "\"pump\": {" " \"running\": true, " From 6e34b971ecdfcb22a759cbd3727b8fb4148e067d Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Mon, 8 Jan 2024 11:05:28 +0000 Subject: [PATCH 02/15] move response preprocessing to api and add tasks --- ui/src/api/CWaterPumpAPI.js | 39 ++++++++++++++++++++++++++--- ui/src/store/slices/SystemStatus.js | 17 ------------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/ui/src/api/CWaterPumpAPI.js b/ui/src/api/CWaterPumpAPI.js index 978eb55..2942d15 100644 --- a/ui/src/api/CWaterPumpAPI.js +++ b/ui/src/api/CWaterPumpAPI.js @@ -10,6 +10,39 @@ function preprocessApiHost(apiHost) { return url; } +function preprocessResponse(response) { + if(null == response) return null; + if('error' in response) { + // TODO: handle errors in slice/SystemStatus.js + throw new Error(response.error); + } + // normal response + // convert "water threshold" to "waterThreshold" + response.waterThreshold = response["water threshold"]; + delete response["water threshold"]; + + // convert "time left" to "timeLeft" + response.pump.timeLeft = response.pump["time left"]; + delete response.pump["time left"]; + + // add field "updated" + response.updated = Date.now(); + // difference between current time on client and time on device + response.timeDelta = response.updated - response.time; + return response; +} + +// TODO: probably we need to know "ping" time to sync time more accurately +// Example: +// 00:00.000 - client sends request +// 00:00.100 - server receives request and set 'time' to 00:00.100, timeLeft = 1000ms +// 00:00.200 - server sends response +// 00:00.300 - client receives response, but 'time' is 00:00.100 and timeLeft = 1000ms +// total time: 300ms +// on average, time to one-way trip is 150ms +// so, we adjust time by 150ms i.e. time = 00:00.250, timeLeft = 850ms +// in this case, error is 50ms (150ms - actual 00:00.100), instead of 200ms (300ms - actual 00:00.100) +////////////////////////////////////////////////////////////////////// class CWaterPumpAPI { constructor({ client=null, URL }) { this._client = client || axios.create({ baseURL: preprocessApiHost(URL) }); @@ -19,17 +52,17 @@ class CWaterPumpAPI { const response = await this._client.get('/pour_tea', { milliseconds: runTimeMs, }); - return response.data; + return preprocessResponse(response.data); } async stop() { const response = await this._client.get('/stop'); - return response.data; + return preprocessResponse(response.data); } async status() { const response = await this._client.get('/status'); - return response.data; + return preprocessResponse(response.data); } } diff --git a/ui/src/store/slices/SystemStatus.js b/ui/src/store/slices/SystemStatus.js index 8abba9f..4910334 100644 --- a/ui/src/store/slices/SystemStatus.js +++ b/ui/src/store/slices/SystemStatus.js @@ -1,25 +1,9 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; -function preprocessSystemStatus(systemStatus) { - if(null == systemStatus) return null; - // convert "water threshold" to "waterThreshold" - systemStatus.waterThreshold = systemStatus["water threshold"]; - delete systemStatus["water threshold"]; - - // convert "time left" to "timeLeft" - systemStatus.pump.timeLeft = systemStatus.pump["time left"]; - delete systemStatus.pump["time left"]; - - // add field "updated" - systemStatus.updated = Date.now(); - return systemStatus; -} - // Async thunks export const startPump = createAsyncThunk( 'systemStatus/startPump', async ({ api, pouringTime }, { dispatch }) => { - console.log('startPump: pouringTime = ' + pouringTime); const response = await api.start(pouringTime); return response; } @@ -28,7 +12,6 @@ export const startPump = createAsyncThunk( export const stopPump = createAsyncThunk( 'systemStatus/stopPump', async ({ api }, { dispatch }) => { - console.log('stopPump'); const response = await api.stop(); return response; } From fe49ff73ee02338faaf18fa02f796b0c5de3448e Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Mon, 8 Jan 2024 13:31:02 +0000 Subject: [PATCH 03/15] countdown for operation and some refactoring --- ui/src/App.css | 7 ++++++ ui/src/App.js | 11 +++++----- ui/src/Utils/time.js | 20 +++++++++++++++++ ui/src/api/CWaterPumpAPI.js | 1 + ui/src/components/CurrentOperationInfoArea.js | 22 +++++++++++++++++++ ui/src/components/SystemStatusArea.js | 22 ++----------------- ui/src/components/TimerArea.js | 22 +++++++++++++++++++ ui/src/store/slices/SystemStatus.js | 2 +- 8 files changed, 81 insertions(+), 26 deletions(-) create mode 100644 ui/src/Utils/time.js create mode 100644 ui/src/components/CurrentOperationInfoArea.js create mode 100644 ui/src/components/TimerArea.js diff --git a/ui/src/App.css b/ui/src/App.css index 8b908d5..1c69a9f 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -1,2 +1,9 @@ .App { +} + +.countdown-area { + width: 100%; + text-align: center; + font-weight: bold; + font-size: 2rem; } \ No newline at end of file diff --git a/ui/src/App.js b/ui/src/App.js index f27ad68..bb625c7 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -4,13 +4,13 @@ import { Container, Form } from 'react-bootstrap'; import { connect } from 'react-redux'; import NotificationsArea from './components/NotificationsArea.js'; -import APIAddressField from './components/APIAddressField'; -import PourTimeField from './components/PourTimeField'; -import SystemControls from './components/SystemControls'; -import SystemStatusArea from './components/SystemStatusArea'; +import APIAddressField from './components/APIAddressField.js'; +import PourTimeField from './components/PourTimeField.js'; +import SystemControls from './components/SystemControls.js'; +import SystemStatusArea from './components/SystemStatusArea.js'; +import CurrentOperationInfoArea from './components/CurrentOperationInfoArea.js'; function App({ isConnected }) { - // TODO: Add a fake countdown timer of timeLeft return (

Tea System UI

@@ -21,6 +21,7 @@ function App({ isConnected }) { {isConnected ? ( <> + ) : null} diff --git a/ui/src/Utils/time.js b/ui/src/Utils/time.js new file mode 100644 index 0000000..248738e --- /dev/null +++ b/ui/src/Utils/time.js @@ -0,0 +1,20 @@ +function toTimeStr(diff) { + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + const secondsStr = (seconds % 60).toString().padStart(2, '0'); + const minutesStr = (minutes % 60).toString().padStart(2, '0'); + const hoursStr = hours.toString().padStart(2, '0'); + + return `${hoursStr}:${minutesStr}:${secondsStr}`; +} + +export function timeBetweenAsString({endTime=null, startTime=null}) { + if (null === startTime) startTime = new Date(); + if (null === endTime) endTime = new Date(); + + const diff = endTime - startTime; // in ms + if (diff < 0) return '-' + toTimeStr(-diff); + return toTimeStr(diff); +} \ No newline at end of file diff --git a/ui/src/api/CWaterPumpAPI.js b/ui/src/api/CWaterPumpAPI.js index 2942d15..d77098f 100644 --- a/ui/src/api/CWaterPumpAPI.js +++ b/ui/src/api/CWaterPumpAPI.js @@ -29,6 +29,7 @@ function preprocessResponse(response) { response.updated = Date.now(); // difference between current time on client and time on device response.timeDelta = response.updated - response.time; + // TODO: add field response.pump.estimatedEndTime return response; } diff --git a/ui/src/components/CurrentOperationInfoArea.js b/ui/src/components/CurrentOperationInfoArea.js new file mode 100644 index 0000000..b4ed46c --- /dev/null +++ b/ui/src/components/CurrentOperationInfoArea.js @@ -0,0 +1,22 @@ +import React from "react"; +import { connect } from "react-redux"; +import TimerArea from "./TimerArea"; + +export function CurrentOperationInfoAreaComponent({ + isRunning, estimatedEndTime +}) { + if (!isRunning) return null; + return ( +
+ +
+ ); +} + +export default connect( + state => ({ + isRunning: state.systemStatus.pump.running, + estimatedEndTime: state.systemStatus.pump.estimatedEndTime, + }), + [] +)(CurrentOperationInfoAreaComponent); \ No newline at end of file diff --git a/ui/src/components/SystemStatusArea.js b/ui/src/components/SystemStatusArea.js index 1279b0b..b95373f 100644 --- a/ui/src/components/SystemStatusArea.js +++ b/ui/src/components/SystemStatusArea.js @@ -1,25 +1,7 @@ import React from 'react'; import { Card } from 'react-bootstrap'; import { connect } from 'react-redux'; - -// time elapsed since last update -function TimeElapsedComponent({ updated }) { - const [diffString, setDiffString] = React.useState(''); - React.useEffect(() => { - const interval = setInterval(() => { - const now = new Date(); - const diff = now - updated; - const newDiffString = new Date(diff).toISOString().substr(11, 8); - setDiffString(newDiffString); - }, 1000); - - return () => clearInterval(interval); - }, [updated]); - - return ( - {diffString} - ); -} +import TimerArea from '../components/TimerArea'; function _systemStatus(status) { if (null === status) { @@ -30,7 +12,7 @@ function _systemStatus(status) { return ( <> Time since last update:{' '} - +
Pump Running: {pump.running ? "Yes" : "No"}
Time Left: {pump.timeLeft} ms diff --git a/ui/src/components/TimerArea.js b/ui/src/components/TimerArea.js new file mode 100644 index 0000000..2eb0a69 --- /dev/null +++ b/ui/src/components/TimerArea.js @@ -0,0 +1,22 @@ +import React from "react"; +import { timeBetweenAsString } from "../Utils/time"; + +export function TimerArea({ startTime=null, endTime=null, interval=450 }) { + const [countdown, setCountdown] = React.useState(''); + + React.useEffect(() => { + const tid = setInterval(() => { + setCountdown(timeBetweenAsString({ startTime, endTime })); + }, interval); + + return () => clearInterval(tid); + }, [startTime, endTime, interval]); + + return ( + + {countdown} + + ); +} + +export default TimerArea; \ No newline at end of file diff --git a/ui/src/store/slices/SystemStatus.js b/ui/src/store/slices/SystemStatus.js index 4910334..745cf4f 100644 --- a/ui/src/store/slices/SystemStatus.js +++ b/ui/src/store/slices/SystemStatus.js @@ -19,7 +19,7 @@ export const stopPump = createAsyncThunk( // slice for system status const bindStatus = (state, action) => { - return preprocessSystemStatus(action.payload); + return action.payload; }; export const SystemStatusSlice = createSlice({ From 20bdb88ccd34756f219cf419ec19991c698e1633 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Wed, 10 Jan 2024 12:28:31 +0000 Subject: [PATCH 04/15] update API --- ui/src/App.test.js | 6 -- ui/src/api/CWaterPumpAPI.js | 58 ++---------- ui/src/api/CWaterPumpAPIImpl.js | 66 +++++++++++++ ui/src/api/CWaterPumpAPIImpl.test.js | 135 +++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 57 deletions(-) delete mode 100644 ui/src/App.test.js create mode 100644 ui/src/api/CWaterPumpAPIImpl.js create mode 100644 ui/src/api/CWaterPumpAPIImpl.test.js diff --git a/ui/src/App.test.js b/ui/src/App.test.js deleted file mode 100644 index 0d76197..0000000 --- a/ui/src/App.test.js +++ /dev/null @@ -1,6 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('1 == 1', () => { - expect(1).toEqual(1); -}); \ No newline at end of file diff --git a/ui/src/api/CWaterPumpAPI.js b/ui/src/api/CWaterPumpAPI.js index d77098f..33b6a8a 100644 --- a/ui/src/api/CWaterPumpAPI.js +++ b/ui/src/api/CWaterPumpAPI.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import { CWaterPumpAPIImpl } from './CWaterPumpAPIImpl.js'; // helper function to preprocess the API host function preprocessApiHost(apiHost) { @@ -10,61 +11,16 @@ function preprocessApiHost(apiHost) { return url; } -function preprocessResponse(response) { - if(null == response) return null; - if('error' in response) { - // TODO: handle errors in slice/SystemStatus.js - throw new Error(response.error); - } - // normal response - // convert "water threshold" to "waterThreshold" - response.waterThreshold = response["water threshold"]; - delete response["water threshold"]; - - // convert "time left" to "timeLeft" - response.pump.timeLeft = response.pump["time left"]; - delete response.pump["time left"]; - - // add field "updated" - response.updated = Date.now(); - // difference between current time on client and time on device - response.timeDelta = response.updated - response.time; - // TODO: add field response.pump.estimatedEndTime - return response; -} - -// TODO: probably we need to know "ping" time to sync time more accurately -// Example: -// 00:00.000 - client sends request -// 00:00.100 - server receives request and set 'time' to 00:00.100, timeLeft = 1000ms -// 00:00.200 - server sends response -// 00:00.300 - client receives response, but 'time' is 00:00.100 and timeLeft = 1000ms -// total time: 300ms -// on average, time to one-way trip is 150ms -// so, we adjust time by 150ms i.e. time = 00:00.250, timeLeft = 850ms -// in this case, error is 50ms (150ms - actual 00:00.100), instead of 200ms (300ms - actual 00:00.100) -////////////////////////////////////////////////////////////////////// class CWaterPumpAPI { - constructor({ client=null, URL }) { - this._client = client || axios.create({ baseURL: preprocessApiHost(URL) }); - } - - async start(runTimeMs) { - const response = await this._client.get('/pour_tea', { - milliseconds: runTimeMs, + constructor({ URL }) { + this._impl = new CWaterPumpAPIImpl({ + client: axios.create({ baseURL: preprocessApiHost(URL) }), }); - return preprocessResponse(response.data); } - async stop() { - const response = await this._client.get('/stop'); - return preprocessResponse(response.data); - } - - async status() { - const response = await this._client.get('/status'); - return preprocessResponse(response.data); - } + async start(runTimeMs) { return await this._impl.start(runTimeMs); } + async stop() { return await this._impl.stop(); } + async status() { return await this._impl.status(); } } export default CWaterPumpAPI; diff --git a/ui/src/api/CWaterPumpAPIImpl.js b/ui/src/api/CWaterPumpAPIImpl.js new file mode 100644 index 0000000..e83837f --- /dev/null +++ b/ui/src/api/CWaterPumpAPIImpl.js @@ -0,0 +1,66 @@ +class CWaterPumpAPIImpl { + constructor({ client, currentTime=null }) { + this._client = client; + this._currentTime = currentTime || Date.now; + } + + async _execute(callback) { + const start = this._currentTime(); + const response = await callback(); + const end = this._currentTime(); + return { response, requestTime: end - start }; + } + + async start(runTimeMs) { + const { response: { data }, requestTime } = await this._execute( + async () => await this._client.get('/pour_tea', { milliseconds: runTimeMs }) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + + async stop() { + const { response: { data }, requestTime } = await this._execute( + async () => await this._client.get('/stop', {}) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + + async status() { + const { response: { data }, requestTime } = await this._execute( + async () => await this._client.get('/status', {}) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + /////////////////////// + // helper functions + preprocessResponse({ response, requestTime }) { + if(null == response) return null; + if('error' in response) { + // TODO: handle errors in slice/SystemStatus.js + throw new Error(response.error); + } + // make a deep copy of response + response = JSON.parse(JSON.stringify(response)); + // normal response + // convert "water threshold" to "waterThreshold" + response.waterThreshold = response["water threshold"]; + delete response["water threshold"]; + + // convert "time left" to "timeLeft" and adjust time + response.pump.timeLeft = response.pump["time left"]; + delete response.pump["time left"]; + + // adjust time by network delay + const oneWayTripTime = Math.round(requestTime / 2); + response.time += oneWayTripTime; + response.pump.timeLeft -= oneWayTripTime; + + const now = this._currentTime(); + response.updated = now; + response.pump.estimatedEndTime = response.pump.timeLeft + now; + return response; + } +} + +export default CWaterPumpAPIImpl; +export { CWaterPumpAPIImpl }; \ No newline at end of file diff --git a/ui/src/api/CWaterPumpAPIImpl.test.js b/ui/src/api/CWaterPumpAPIImpl.test.js new file mode 100644 index 0000000..8d75b99 --- /dev/null +++ b/ui/src/api/CWaterPumpAPIImpl.test.js @@ -0,0 +1,135 @@ +import { CWaterPumpAPIImpl } from './CWaterPumpAPIImpl.js'; + +describe('CWaterPumpAPIImpl', () => { + const DUMMY_STATUS = { + pump: { + "running": true, + "time left": 1000, + "water threshold": 100, + }, + time: 1000, + }; + // common test cases + async function shouldThrowErrorFromResponse(apiCall) { + const mockClient = { get: jest.fn() }; + const errorMessage = 'Error ' + Math.random(); + mockClient.get.mockResolvedValue({ data: { error: errorMessage } }); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + await expect(apiCall(api)).rejects.toThrow(errorMessage); + } + + async function shouldBeCalledWith(apiCall, url, params=null) { + const mockClient = { get: jest.fn() }; + mockClient.get.mockResolvedValue({ data: DUMMY_STATUS }); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + await apiCall(api); + + expect(mockClient.get).toHaveBeenCalledWith(url, params); + } + + async function shouldRethrowError(apiCall) { + const mockClient = { get: jest.fn() }; + mockClient.get.mockRejectedValue(new Error('Network Error')); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + await expect(apiCall(api)).rejects.toThrow('Network Error'); + } + + async function shouldPreprocessResponse(apiCall) { + const mockClient = { get: jest.fn() }; + mockClient.get.mockResolvedValue({ data: DUMMY_STATUS }); + + const api = new CWaterPumpAPIImpl({ client: mockClient }); + const response = await apiCall(api); + + expect(response.waterThreshold).toBe(DUMMY_STATUS["water threshold"]); + expect(response.pump.timeLeft).toBe(DUMMY_STATUS.pump["time left"]); + expect(response).toHaveProperty('updated'); + } + // end of common test cases + // tests per method + describe('start', () => { + it('common test cases', async () => { + const T = Math.random() * 1000; + const callback = async (api) => await api.start(T); + await shouldThrowErrorFromResponse(callback); + await shouldRethrowError(callback); + await shouldPreprocessResponse(callback); + await shouldBeCalledWith(callback, '/pour_tea', { milliseconds: T }); + }); + }); + + describe('stop', () => { + it('common test cases', async () => { + const callback = async (api) => await api.stop(); + await shouldThrowErrorFromResponse(callback); + await shouldRethrowError(callback); + await shouldPreprocessResponse(callback); + await shouldBeCalledWith(callback, '/stop', {}); + }); + }); + + describe('status', () => { + it('common test cases', async () => { + const callback = async (api) => await api.status(); + await shouldThrowErrorFromResponse(callback); + await shouldRethrowError(callback); + await shouldPreprocessResponse(callback); + await shouldBeCalledWith(callback, '/status', {}); + }); + }); + // tests for helper function preprocessResponse + describe('preprocessResponse', () => { + it('should return null if response is null', () => { + const api = new CWaterPumpAPIImpl({ client: {} }); + expect(api.preprocessResponse({ response: null, requestTime: 0 })).toBeNull(); + }); + + it('should throw error if response has error', () => { + const api = new CWaterPumpAPIImpl({ client: {} }); + const errorMessage = 'Error ' + Math.random(); + expect(() => api.preprocessResponse({ + response: { error: errorMessage }, + requestTime: 0, + })).toThrow(errorMessage); + }); + + it('should preprocess response', () => { + const api = new CWaterPumpAPIImpl({ client: {} }); + const response = api.preprocessResponse({ response: DUMMY_STATUS, requestTime: 0 }); + expect(response.waterThreshold).toBe(DUMMY_STATUS["water threshold"]); + expect(response.pump.timeLeft).toBe(DUMMY_STATUS.pump["time left"]); + }); + + it('should add field "updated" with current time', () => { + const T = Math.random() * 1000; + const api = new CWaterPumpAPIImpl({ client: {}, currentTime: () => T }); + const response = api.preprocessResponse({ response: DUMMY_STATUS, requestTime: 0 }); + expect(response.updated).toBe(T); + }); + + /////////// + // Scenario: + // 00:00.000 - client sends request + // 00:00.100 - server receives request and set 'time' to 00:00.100, timeLeft = 1234ms + // 00:00.200 - server sends response + // 00:00.300 - client receives response, but 'time' is 00:00.100 and timeLeft = 1234ms + // total time: 300ms + // on average, time to one-way trip is 150ms + // so, we adjust time by 150ms i.e. time = 00:00.250, timeLeft = 1084ms + // estimatedEndTime = 00:00.300 + 1084ms = 00:01.384 + it('should adjust time', () => { + const responseObj = JSON.parse(JSON.stringify(DUMMY_STATUS)); + responseObj.time = 100; + responseObj.pump["time left"] = 1234; + + const api = new CWaterPumpAPIImpl({ client: {}, currentTime: () => 300 }); + const response = api.preprocessResponse({ response: responseObj, requestTime: 300 }); + expect(response.time).toBe(250); + expect(response.pump.timeLeft).toBe(1084); + expect(response.pump.estimatedEndTime).toBe(1384); + }); + }); +}); \ No newline at end of file From 754d33d1eb39611cb10d62e98e86f0b03714b626 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Wed, 10 Jan 2024 21:04:02 +0000 Subject: [PATCH 05/15] misc --- ui/src/{contexts => components}/WaterPumpStatusProvider.js | 4 ++-- ui/src/contexts/WaterPumpAPIContext.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename ui/src/{contexts => components}/WaterPumpStatusProvider.js (92%) diff --git a/ui/src/contexts/WaterPumpStatusProvider.js b/ui/src/components/WaterPumpStatusProvider.js similarity index 92% rename from ui/src/contexts/WaterPumpStatusProvider.js rename to ui/src/components/WaterPumpStatusProvider.js index 915caa5..3cadb5b 100644 --- a/ui/src/contexts/WaterPumpStatusProvider.js +++ b/ui/src/components/WaterPumpStatusProvider.js @@ -1,8 +1,8 @@ import React from 'react'; import { connect } from 'react-redux'; import { updateSystemStatus } from '../store/slices/SystemStatus'; -import { useWaterPumpAPI } from './WaterPumpAPIContext'; -import { useNotificationsSystem } from './NotificationsContext'; +import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; +import { useNotificationsSystem } from '../contexts/NotificationsContext'; const FETCH_INTERVAL = 5000; const CHECK_INTERVAL = Math.round(FETCH_INTERVAL / 10); diff --git a/ui/src/contexts/WaterPumpAPIContext.js b/ui/src/contexts/WaterPumpAPIContext.js index aff1fac..9f5ae25 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 { CWaterPumpAPI } from '../api/CWaterPumpAPI.js'; -import WaterPumpStatusProvider from './WaterPumpStatusProvider.js'; +import WaterPumpStatusProvider from '../components/WaterPumpStatusProvider.js'; const WaterPumpAPIContext = React.createContext(); From c59fca4834ff2ee42b681fd0cdb1bb95c297d273 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Wed, 10 Jan 2024 22:09:29 +0000 Subject: [PATCH 06/15] rework notifications --- ui/src/components/NotificationsArea.js | 24 +++++---- ui/src/components/SystemControls.js | 15 +----- ui/src/components/WaterPumpStatusProvider.js | 13 +---- ui/src/contexts/NotificationsContext.js | 23 --------- ui/src/index.js | 9 ++-- ui/src/store/slices/Notifications.js | 18 +++++++ ui/src/store/slices/SystemStatus.js | 54 +++++++++++++++----- ui/src/store/slices/index.js | 3 +- 8 files changed, 82 insertions(+), 77 deletions(-) delete mode 100644 ui/src/contexts/NotificationsContext.js create mode 100644 ui/src/store/slices/Notifications.js diff --git a/ui/src/components/NotificationsArea.js b/ui/src/components/NotificationsArea.js index afb38c0..c6e1832 100644 --- a/ui/src/components/NotificationsArea.js +++ b/ui/src/components/NotificationsArea.js @@ -1,19 +1,23 @@ import React from 'react'; +import { connect } from 'react-redux'; import { Alert } from 'react-bootstrap'; -import { useNotificationsSystem } from '../contexts/NotificationsContext'; +import { NotificationsSystemActions } from '../store/slices/Notifications'; -function NotificationsArea() { - const NotificationsSystem = useNotificationsSystem(); - const { currentNotifications } = NotificationsSystem; - if(!currentNotifications) return null; - - const hideNotifications = () => { NotificationsSystem.clear(); }; +function NotificationsArea({ hasNotifications, message, clearNotifications }) { + if(!hasNotifications) return null; return ( - - {currentNotifications.message} + + {message} ); } -export default NotificationsArea; +export default connect( + (state) => ({ + hasNotifications: state.notifications.currentNotifications != null, + message: state.notifications.currentNotifications?.message + }), { + clearNotifications: NotificationsSystemActions.clear + } +)(NotificationsArea); \ No newline at end of file diff --git a/ui/src/components/SystemControls.js b/ui/src/components/SystemControls.js index 6076831..16d1a8a 100644 --- a/ui/src/components/SystemControls.js +++ b/ui/src/components/SystemControls.js @@ -3,29 +3,18 @@ import { connect } from 'react-redux'; import { Button } from 'react-bootstrap'; import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; -import { useNotificationsSystem } from '../contexts/NotificationsContext.js'; import { startPump, stopPump } from '../store/slices/SystemStatus.js'; export function SystemControlsComponent({ pouringTime, systemStatus, startPump, stopPump }) { const api = useWaterPumpAPI().API; - const NotificationsSystem = useNotificationsSystem(); - const handleStart = async () => { - try { - await startPump({ api , pouringTime }); - } catch (error) { - NotificationsSystem.alert('Error starting water pump: ' + error.message); - } + await startPump({ api , pouringTime }); }; const handleStop = async () => { - try { - await stopPump({ api }); - } catch (error) { - NotificationsSystem.alert('Error stopping water pump: ' + error.message); - } + await stopPump({ api }); }; const isRunning = systemStatus.pump.running; diff --git a/ui/src/components/WaterPumpStatusProvider.js b/ui/src/components/WaterPumpStatusProvider.js index 3cadb5b..035cfed 100644 --- a/ui/src/components/WaterPumpStatusProvider.js +++ b/ui/src/components/WaterPumpStatusProvider.js @@ -2,14 +2,12 @@ import React from 'react'; import { connect } from 'react-redux'; import { updateSystemStatus } from '../store/slices/SystemStatus'; import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; -import { useNotificationsSystem } from '../contexts/NotificationsContext'; const FETCH_INTERVAL = 5000; const CHECK_INTERVAL = Math.round(FETCH_INTERVAL / 10); function WaterPumpStatusProviderComoponent({ children, updateStatus, systemStatus }) { const { API } = useWaterPumpAPI(); - const NotificationsSystem = useNotificationsSystem(); const nextFetchTime = React.useRef(0); // Function to fetch water pump status @@ -19,16 +17,10 @@ function WaterPumpStatusProviderComoponent({ children, updateStatus, systemStatu if(null == API) return; nextFetchTime.current = Number.MAX_SAFE_INTEGER; // prevent concurrent fetches - try { - const status = await API.status(); - updateStatus(status); - } catch (error) { - NotificationsSystem.alert('Error fetching system status: ' + error.message); - updateStatus(null); - } + await updateStatus(API); nextFetchTime.current = Date.now() + FETCH_INTERVAL; }, - [API, NotificationsSystem, updateStatus, nextFetchTime] + [API, updateStatus, nextFetchTime] ); // Effect to start fetching periodically and when API changes @@ -58,6 +50,5 @@ export default connect( systemStatus: state.systemStatus }), { updateStatus: updateSystemStatus - } )(WaterPumpStatusProviderComoponent); \ No newline at end of file diff --git a/ui/src/contexts/NotificationsContext.js b/ui/src/contexts/NotificationsContext.js deleted file mode 100644 index 6685a68..0000000 --- a/ui/src/contexts/NotificationsContext.js +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; - -const NotificationsContext = React.createContext(); - -export function useNotificationsSystem() { - return React.useContext(NotificationsContext); -} - -export function NotificationsProvider({ children }) { - const [notifications, setNotifications] = React.useState(null); - - const value = { - alert: (message) => { setNotifications({ message }); }, - clear: () => { setNotifications(null); }, - currentNotifications: notifications, - }; - - return ( - - {children} - - ); -} \ No newline at end of file diff --git a/ui/src/index.js b/ui/src/index.js index 6777eb1..aa24bae 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -2,7 +2,6 @@ import React from 'react'; import App from './App.js'; import 'bootstrap/dist/css/bootstrap.min.css'; // Importing Bootstrap CSS -import { NotificationsProvider } from './contexts/NotificationsContext.js'; import { WaterPumpAPIProvider } from './contexts/WaterPumpAPIContext.js'; // Redux store import { AppStore } from './store'; @@ -12,11 +11,9 @@ const root = createRoot(document.getElementById('root')); root.render( - - - - - + + + ); diff --git a/ui/src/store/slices/Notifications.js b/ui/src/store/slices/Notifications.js new file mode 100644 index 0000000..9fbc377 --- /dev/null +++ b/ui/src/store/slices/Notifications.js @@ -0,0 +1,18 @@ +import { createSlice } from '@reduxjs/toolkit'; + +export const NotificationsSlice = createSlice({ + name: 'notifications', + initialState: { + currentNotifications: null + }, + reducers: { + alert: (state, action) => { + state.currentNotifications = action.payload; + }, + clear: state => { + state.currentNotifications = null; + } + } +}); + +export const NotificationsSystemActions = NotificationsSlice.actions; \ No newline at end of file diff --git a/ui/src/store/slices/SystemStatus.js b/ui/src/store/slices/SystemStatus.js index 745cf4f..8e80d2f 100644 --- a/ui/src/store/slices/SystemStatus.js +++ b/ui/src/store/slices/SystemStatus.js @@ -1,20 +1,49 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { NotificationsSystemActions } from './Notifications'; + +function withNotification(action, message) { + return async (params, { dispatch }) => { + try { + return await action(params); + } catch(error) { + dispatch(NotificationsSystemActions.alert({ + type: 'error', + message: `${message} (${error.message})` + })); + throw error; + } + }; +} // Async thunks export const startPump = createAsyncThunk( 'systemStatus/startPump', - async ({ api, pouringTime }, { dispatch }) => { - const response = await api.start(pouringTime); - return response; - } + withNotification( + async ({ api, pouringTime }) => { + return await api.start(pouringTime); + }, + 'Failed to start pump' + ) ); export const stopPump = createAsyncThunk( 'systemStatus/stopPump', - async ({ api }, { dispatch }) => { - const response = await api.stop(); - return response; - } + 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 @@ -25,18 +54,17 @@ const bindStatus = (state, action) => { export const SystemStatusSlice = createSlice({ name: 'systemStatus', initialState: null, - reducers: { - updateSystemStatus: bindStatus, - }, + 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) => state); } }); -export const actions = SystemStatusSlice.actions; -export const { updateSystemStatus } = actions; \ No newline at end of file +export const actions = SystemStatusSlice.actions; \ No newline at end of file diff --git a/ui/src/store/slices/index.js b/ui/src/store/slices/index.js index 6f7cd36..9031b1d 100644 --- a/ui/src/store/slices/index.js +++ b/ui/src/store/slices/index.js @@ -1,7 +1,8 @@ import { SystemStatusSlice } from "./SystemStatus"; import { UISlice } from "./UI"; +import { NotificationsSlice } from "./Notifications"; -const slices = [ SystemStatusSlice, UISlice ]; +const slices = [ SystemStatusSlice, UISlice, NotificationsSlice ]; // export all slices as an object { [sliceName]: slice } export const ALL_APP_SLICES = slices.reduce((acc, slice) => { acc[slice.name] = slice; From f4d462332166b2a49491f483aeab82ca193759d4 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Wed, 10 Jan 2024 22:15:29 +0000 Subject: [PATCH 07/15] fix requests params --- ui/src/api/CWaterPumpAPIImpl.js | 7 +++---- ui/src/api/CWaterPumpAPIImpl.test.js | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/ui/src/api/CWaterPumpAPIImpl.js b/ui/src/api/CWaterPumpAPIImpl.js index e83837f..995f260 100644 --- a/ui/src/api/CWaterPumpAPIImpl.js +++ b/ui/src/api/CWaterPumpAPIImpl.js @@ -13,21 +13,21 @@ class CWaterPumpAPIImpl { async start(runTimeMs) { const { response: { data }, requestTime } = await this._execute( - async () => await this._client.get('/pour_tea', { milliseconds: runTimeMs }) + async () => await this._client.get('/pour_tea', { params: { milliseconds: runTimeMs } }) ); return this.preprocessResponse({ response: data, requestTime }); } async stop() { const { response: { data }, requestTime } = await this._execute( - async () => await this._client.get('/stop', {}) + async () => await this._client.get('/stop', { params: {} }) ); return this.preprocessResponse({ response: data, requestTime }); } async status() { const { response: { data }, requestTime } = await this._execute( - async () => await this._client.get('/status', {}) + async () => await this._client.get('/status', { params: {} }) ); return this.preprocessResponse({ response: data, requestTime }); } @@ -36,7 +36,6 @@ class CWaterPumpAPIImpl { preprocessResponse({ response, requestTime }) { if(null == response) return null; if('error' in response) { - // TODO: handle errors in slice/SystemStatus.js throw new Error(response.error); } // make a deep copy of response diff --git a/ui/src/api/CWaterPumpAPIImpl.test.js b/ui/src/api/CWaterPumpAPIImpl.test.js index 8d75b99..5a61036 100644 --- a/ui/src/api/CWaterPumpAPIImpl.test.js +++ b/ui/src/api/CWaterPumpAPIImpl.test.js @@ -19,14 +19,14 @@ describe('CWaterPumpAPIImpl', () => { await expect(apiCall(api)).rejects.toThrow(errorMessage); } - async function shouldBeCalledWith(apiCall, url, params=null) { + async function shouldBeCalledWith(apiCall, url, params) { const mockClient = { get: jest.fn() }; mockClient.get.mockResolvedValue({ data: DUMMY_STATUS }); const api = new CWaterPumpAPIImpl({ client: mockClient }); await apiCall(api); - expect(mockClient.get).toHaveBeenCalledWith(url, params); + expect(mockClient.get).toHaveBeenCalledWith(url, { params }); } async function shouldRethrowError(apiCall) { From 20cc1116f7fb631d8f95278126733fc3a76d9698 Mon Sep 17 00:00:00 2001 From: GreenWizard Date: Sat, 13 Jan 2024 10:28:24 +0100 Subject: [PATCH 08/15] fix upper threshold --- .../lib/CommandProcessor/CommandProcessor.cpp | 2 +- .../test_native/tests/CommandProcessor_test.h | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp b/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp index e5ea18b..a58434e 100644 --- a/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp +++ b/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp @@ -45,7 +45,7 @@ std::string CommandProcessor::status() { } std::string CommandProcessor::pour_tea(const char *milliseconds) { - if (!isValidIntNumber(milliseconds, _waterPumpSafeThreshold)) { + if (!isValidIntNumber(milliseconds, _waterPumpSafeThreshold + 1)) { // send error message as JSON return std::string("{ \"error\": \"invalid milliseconds value\" }"); } diff --git a/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h b/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h index c99b5d2..ad0ff38 100644 --- a/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h +++ b/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h @@ -3,21 +3,28 @@ #include "mocks/FakeWaterPumpSchedulerAPI.h" #include "mocks/FakeEnvironment.h" +const auto INVALID_TIME_ERROR_MESSAGE = "{ \"error\": \"invalid milliseconds value\" }"; // test that pour_tea() method returns error message if milliseconds: // - greater than threshold // - less than 0 // - empty string // - not a number TEST(CommandProcessor, pour_tea_invalid_milliseconds) { - const auto EXPECTED_ERROR_MESSAGE = "{ \"error\": \"invalid milliseconds value\" }"; CommandProcessor commandProcessor(123, nullptr, nullptr); + ASSERT_EQ(commandProcessor.pour_tea("1234"), INVALID_TIME_ERROR_MESSAGE); + ASSERT_EQ(commandProcessor.pour_tea("-1"), INVALID_TIME_ERROR_MESSAGE); + ASSERT_EQ(commandProcessor.pour_tea(""), INVALID_TIME_ERROR_MESSAGE); + ASSERT_EQ(commandProcessor.pour_tea("abc"), INVALID_TIME_ERROR_MESSAGE); +} + +// for simplicity of the UI, we should accept as valid 0 and exactly threshold value +TEST(CommandProcessor, pour_tea_valid_boundary_values) { + auto env = std::make_shared(); + auto waterPump = std::make_shared(); + CommandProcessor commandProcessor(123, env, waterPump); - // array of invalid parameters - const char *PARAMS[] = { "1234", "-1", "", "abc" }; - for (auto param : PARAMS) { - const auto response = commandProcessor.pour_tea(param); - ASSERT_EQ(response, EXPECTED_ERROR_MESSAGE); - } + ASSERT_NE(commandProcessor.pour_tea("0"), INVALID_TIME_ERROR_MESSAGE); + ASSERT_NE(commandProcessor.pour_tea("123"), INVALID_TIME_ERROR_MESSAGE); } // test that start pouring tea by calling pour_tea() method and its stops after T milliseconds From 3901ce4e20357dfe9474901a22e375d51e2c46c2 Mon Sep 17 00:00:00 2001 From: GreenWizard Date: Sat, 13 Jan 2024 11:23:30 +0100 Subject: [PATCH 09/15] note --- controller/tea_poor/lib/Arduino/WaterPumpController.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/controller/tea_poor/lib/Arduino/WaterPumpController.cpp b/controller/tea_poor/lib/Arduino/WaterPumpController.cpp index 25768ec..6aef2a0 100644 --- a/controller/tea_poor/lib/Arduino/WaterPumpController.cpp +++ b/controller/tea_poor/lib/Arduino/WaterPumpController.cpp @@ -12,7 +12,9 @@ WaterPumpController::WaterPumpController(int directionPin, int brakePin, int pow WaterPumpController::~WaterPumpController() {} void WaterPumpController::setup() { - pinMode(_directionPin, OUTPUT); + // NOTE: we use one-directional motor, so we can't use direction pin + // but I keep it here for future reference + // pinMode(_directionPin, OUTPUT); pinMode(_brakePin, OUTPUT); pinMode(_powerPin, OUTPUT); stop(); From 96a68cd711760086b5b3541d01bca2358ca2c9c3 Mon Sep 17 00:00:00 2001 From: GreenWizard Date: Sat, 13 Jan 2024 13:19:21 +0100 Subject: [PATCH 10/15] static IP, reconnect, etc. --- .../tea_poor/lib/Arduino/RemoteControl.cpp | 56 ++++++++++--------- .../tea_poor/lib/Arduino/RemoteControl.h | 19 ++++--- controller/tea_poor/src/main.cpp | 26 ++++++--- controller/tea_poor/src/secrets.h.example | 3 + 4 files changed, 63 insertions(+), 41 deletions(-) diff --git a/controller/tea_poor/lib/Arduino/RemoteControl.cpp b/controller/tea_poor/lib/Arduino/RemoteControl.cpp index 16c3ec9..5d9b1e7 100644 --- a/controller/tea_poor/lib/Arduino/RemoteControl.cpp +++ b/controller/tea_poor/lib/Arduino/RemoteControl.cpp @@ -38,16 +38,7 @@ void debugNetworkInfo() { Serial.println(); } -RemoteControl::RemoteControl(const char* SSID, const char* SSIDPassword) : - _SSID(SSID), _SSIDPassword(SSIDPassword), - _server(80), _app() -{ -} - -RemoteControl::~RemoteControl() { -} - -void RemoteControl::_setupNetwork() { +void verifyNetwork() { if (WiFi.status() == WL_NO_MODULE) { Serial.println("Communication with WiFi module failed!"); while(true) delay(500); @@ -59,15 +50,25 @@ void RemoteControl::_setupNetwork() { Serial.println(WIFI_FIRMWARE_LATEST_VERSION); Serial.println("Please upgrade your firmware."); } +} + +RemoteControl::RemoteControl(const NetworkConnectCallback &onConnect) : + _onConnect(onConnect) +{ +} +RemoteControl::~RemoteControl() { +} + +void RemoteControl::connectTo(const char* ssid, const char* password) { Serial.print("Connecting to "); - Serial.println(_SSID); + Serial.println(ssid); int attempts = 0; while (WL_CONNECTED != WiFi.status()) { // try to connect to the network attempts++; - Serial.println("Atempt to connect: " + String(attempts)); - WiFi.begin(_SSID.c_str(), _SSIDPassword.c_str()); + Serial.println("Attempt to connect: " + String(attempts)); + WiFi.begin(ssid, password); for (int i = 0; i < 50; i++) { // wait for connection Serial.print("."); delay(500); @@ -77,30 +78,33 @@ void RemoteControl::_setupNetwork() { Serial.println("Connection status: " + String(WiFi.status())); } Serial.println(); - + // successfully connected debugNetworkInfo(); } -void RemoteControl::setup(RemoteControlRoutesCallback routes) { - _setupNetwork(); - routes(_app); // setup routes +void RemoteControl::setup() { reconnect(); } + +void RemoteControl::reconnect() { + // reset everything + WiFi.disconnect(); + verifyNetwork(); + _app = Application(); // reset routes + _server = WiFiServer(80); // reset server + // reconnect + _onConnect(*this, _app); _server.begin(); } void RemoteControl::process() { - // TODO: check if we still have a connection. If not, reconnect. + if(WL_CONNECTED != WiFi.status()) { + reconnect(); + return; // wait for next tick, just to be sure that all is ok + } + /////////////////////////// WiFiClient client = _server.available(); if (client.connected()) { _app.process(&client); client.stop(); } -} - -String RemoteControl::asJSONString() const { - String result = "{"; - result += "\"SSID\": \"" + _SSID + "\","; - result += "\"signal strength\": " + String(WiFi.RSSI()); - result += "}"; - return result; } \ No newline at end of file diff --git a/controller/tea_poor/lib/Arduino/RemoteControl.h b/controller/tea_poor/lib/Arduino/RemoteControl.h index 1447248..9cc6c95 100644 --- a/controller/tea_poor/lib/Arduino/RemoteControl.h +++ b/controller/tea_poor/lib/Arduino/RemoteControl.h @@ -4,20 +4,25 @@ #include #include #include +#include -// define routes callback function signature -typedef void (*RemoteControlRoutesCallback)(Application &app); +// forward declaration +class RemoteControl; + +// define callback for (re)connecting to WiFi, use std::function +typedef std::function NetworkConnectCallback; class RemoteControl { public: - RemoteControl(const char* SSID, const char* SSIDPassword); + RemoteControl(const NetworkConnectCallback &onConnect); ~RemoteControl(); - void setup(RemoteControlRoutesCallback routes); + void setup(); void process(); - String asJSONString() const; + void reconnect(); + /////////////////// + void connectTo(const char* ssid, const char* password); private: - const String _SSID; - const String _SSIDPassword; + NetworkConnectCallback _onConnect; WiFiServer _server; Application _app; diff --git a/controller/tea_poor/src/main.cpp b/controller/tea_poor/src/main.cpp index 7509e0b..ad142fe 100644 --- a/controller/tea_poor/src/main.cpp +++ b/controller/tea_poor/src/main.cpp @@ -18,9 +18,6 @@ auto waterPump = std::make_shared( ) ); -// setting up remote control -RemoteControl remoteControl(WIFI_SSID, WIFI_PASSWORD); - // build command processor CommandProcessor commandProcessor( WATER_PUMP_SAFE_THRESHOLD, @@ -35,10 +32,17 @@ void withExtraHeaders(Response &res) { res.set("Content-Type", "application/json"); } -void setup() { - Serial.begin(9600); - waterPump->setup(); - remoteControl.setup([](Application &app) { +RemoteControl remoteControl( + // lambda function to setup network + [](RemoteControl &remoteControl, Application &app) { + // connect to WiFi + // set static IP address, if defined in configs + #ifdef WIFI_IP_ADDRESS + WiFi.config(WIFI_IP_ADDRESS); + #endif + + remoteControl.connectTo(WIFI_SSID, WIFI_PASSWORD); + // setup routes app.get("/pour_tea", [](Request &req, Response &res) { char milliseconds[64]; req.query("milliseconds", milliseconds, 64); @@ -59,7 +63,13 @@ void setup() { withExtraHeaders(res); res.print(response.c_str()); }); - }); + } +); + +void setup() { + Serial.begin(9600); + waterPump->setup(); + remoteControl.setup(); } void loop() { diff --git a/controller/tea_poor/src/secrets.h.example b/controller/tea_poor/src/secrets.h.example index 4fe08d5..c5b9e57 100644 --- a/controller/tea_poor/src/secrets.h.example +++ b/controller/tea_poor/src/secrets.h.example @@ -15,4 +15,7 @@ const int WATER_PUMP_POWER_PIN = 3; // Their is no reason to make it configurable and add unnecessary complexity const int WATER_PUMP_SAFE_THRESHOLD = 10 * 1000; +// Static IP address. If not defined, dynamic IP address will be used +// #define WIFI_IP_ADDRESS IPAddress(192, 168, 1, 123) + #endif // SECRETS_H \ No newline at end of file From 7674189950ab3f83c5233e51ed22f06fdd29af98 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Sat, 13 Jan 2024 16:49:25 +0000 Subject: [PATCH 11/15] hack to prevent CORS error --- ui/src/api/CWaterPumpAPI.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ui/src/api/CWaterPumpAPI.js b/ui/src/api/CWaterPumpAPI.js index 33b6a8a..7c297ac 100644 --- a/ui/src/api/CWaterPumpAPI.js +++ b/ui/src/api/CWaterPumpAPI.js @@ -13,8 +13,19 @@ function preprocessApiHost(apiHost) { class CWaterPumpAPI { constructor({ URL }) { + // quick hack to add headers to axios client + // this is needed to prevent CORS error + const axiosClient = axios.create({ baseURL: preprocessApiHost(URL) }); + const fakeClient = { + get: async (path, params) => { + params = params || {}; + params.headers = params.headers || {}; + params.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + return await axiosClient.get(path, params); + } + }; this._impl = new CWaterPumpAPIImpl({ - client: axios.create({ baseURL: preprocessApiHost(URL) }), + client: fakeClient, }); } From 86153fc9a4fae7b69b7a490c7be92b4918ab9eed Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Sat, 13 Jan 2024 17:45:49 +0000 Subject: [PATCH 12/15] UI tweaks and fixes --- ui/src/Utils/time.js | 6 ++++-- ui/src/components/CurrentOperationInfoArea.js | 2 +- ui/src/components/SystemControls.js | 12 ++++++------ ui/src/components/SystemStatusArea.js | 10 ++++++---- ui/src/components/TimerArea.js | 6 +++--- ui/src/store/slices/Notifications.js | 9 ++++++++- ui/src/store/slices/SystemStatus.js | 4 +++- 7 files changed, 31 insertions(+), 18 deletions(-) diff --git a/ui/src/Utils/time.js b/ui/src/Utils/time.js index 248738e..c5efa65 100644 --- a/ui/src/Utils/time.js +++ b/ui/src/Utils/time.js @@ -10,11 +10,13 @@ function toTimeStr(diff) { return `${hoursStr}:${minutesStr}:${secondsStr}`; } -export function timeBetweenAsString({endTime=null, startTime=null}) { +export function timeBetweenAsString({endTime=null, startTime=null, bounded=false}) { if (null === startTime) startTime = new Date(); if (null === endTime) endTime = new Date(); - const diff = endTime - startTime; // in ms + let diff = endTime - startTime; // in ms + if (bounded && (diff < 0)) diff = 0; + if (diff < 0) return '-' + toTimeStr(-diff); return toTimeStr(diff); } \ No newline at end of file diff --git a/ui/src/components/CurrentOperationInfoArea.js b/ui/src/components/CurrentOperationInfoArea.js index b4ed46c..016fb18 100644 --- a/ui/src/components/CurrentOperationInfoArea.js +++ b/ui/src/components/CurrentOperationInfoArea.js @@ -5,7 +5,7 @@ import TimerArea from "./TimerArea"; export function CurrentOperationInfoAreaComponent({ isRunning, estimatedEndTime }) { - if (!isRunning) return null; + estimatedEndTime = isRunning ? estimatedEndTime : null; return (
diff --git a/ui/src/components/SystemControls.js b/ui/src/components/SystemControls.js index 16d1a8a..b98b6e5 100644 --- a/ui/src/components/SystemControls.js +++ b/ui/src/components/SystemControls.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Button } from 'react-bootstrap'; +import { Button, Container } from 'react-bootstrap'; import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; import { startPump, stopPump } from '../store/slices/SystemStatus.js'; @@ -19,14 +19,14 @@ export function SystemControlsComponent({ const isRunning = systemStatus.pump.running; return ( - <> - {' '} - + - + ); } diff --git a/ui/src/components/SystemStatusArea.js b/ui/src/components/SystemStatusArea.js index b95373f..974fb4d 100644 --- a/ui/src/components/SystemStatusArea.js +++ b/ui/src/components/SystemStatusArea.js @@ -9,20 +9,22 @@ function _systemStatus(status) { } const pump = status.pump; + const color = pump.running ? "green" : "black"; return ( <> Time since last update:{' '} -
- Pump Running: {pump.running ? "Yes" : "No"}
- Time Left: {pump.timeLeft} ms + {' | '} + + Pump Running: {pump.running ? "Yes" : "No"} + ); } export function SystemStatusAreaComponent({ status }) { return ( - + System Status diff --git a/ui/src/components/TimerArea.js b/ui/src/components/TimerArea.js index 2eb0a69..90e4429 100644 --- a/ui/src/components/TimerArea.js +++ b/ui/src/components/TimerArea.js @@ -1,16 +1,16 @@ import React from "react"; import { timeBetweenAsString } from "../Utils/time"; -export function TimerArea({ startTime=null, endTime=null, interval=450 }) { +export function TimerArea({ startTime=null, endTime=null, interval=450, bounded=true }) { const [countdown, setCountdown] = React.useState(''); React.useEffect(() => { const tid = setInterval(() => { - setCountdown(timeBetweenAsString({ startTime, endTime })); + setCountdown(timeBetweenAsString({ startTime, endTime, bounded })); }, interval); return () => clearInterval(tid); - }, [startTime, endTime, interval]); + }, [startTime, endTime, bounded, interval]); return ( diff --git a/ui/src/store/slices/Notifications.js b/ui/src/store/slices/Notifications.js index 9fbc377..7913f1c 100644 --- a/ui/src/store/slices/Notifications.js +++ b/ui/src/store/slices/Notifications.js @@ -7,7 +7,14 @@ export const NotificationsSlice = createSlice({ }, reducers: { alert: (state, action) => { - state.currentNotifications = action.payload; + const { message, ...rest } = action.payload; + // prepend HH:MM:SS to message + const now = new Date(); + const time = now.toTimeString().split(' ')[0]; + state.currentNotifications = { + message: `[${time}] ${message}`, + ...rest + }; }, clear: state => { state.currentNotifications = null; diff --git a/ui/src/store/slices/SystemStatus.js b/ui/src/store/slices/SystemStatus.js index 8e80d2f..a1f4eae 100644 --- a/ui/src/store/slices/SystemStatus.js +++ b/ui/src/store/slices/SystemStatus.js @@ -63,7 +63,9 @@ export const SystemStatusSlice = createSlice({ // 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) => state); + builder.addCase(updateSystemStatus.rejected, (state, action) => { + return null; + }); } }); From f9ae6cc962814a8c2097577de08effd85e7b538f Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Sat, 13 Jan 2024 18:21:26 +0000 Subject: [PATCH 13/15] "Hold to pour" button added to UI --- ui/public/valve.png | Bin 0 -> 24268 bytes ui/src/App.css | 6 +++++ ui/src/App.js | 2 ++ ui/src/components/HoldToPour.js | 40 ++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 ui/public/valve.png create mode 100644 ui/src/components/HoldToPour.js diff --git a/ui/public/valve.png b/ui/public/valve.png new file mode 100644 index 0000000000000000000000000000000000000000..9b53ded7aef3fbc8497f26fb24021994d034138c GIT binary patch literal 24268 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 + ) : null} diff --git a/ui/src/components/HoldToPour.js b/ui/src/components/HoldToPour.js new file mode 100644 index 0000000..2471575 --- /dev/null +++ b/ui/src/components/HoldToPour.js @@ -0,0 +1,40 @@ +import React, { useState, useEffect } from 'react'; +import { connect } from 'react-redux'; +import { Container } from 'react-bootstrap'; +import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; +import { startPump, stopPump } from '../store/slices/SystemStatus.js'; + +export function HoldToPourComponent({ startPump, stopPump }) { + const pouringTime = 1500; + const api = useWaterPumpAPI().API; + const [isPouring, setIsPouring] = useState(false); + + useEffect(() => { + if (!isPouring) return; + + const tid = setInterval(() => { + startPump({ api, pouringTime }); + }, pouringTime - 500); + + return () => { + clearInterval(tid); + stopPump({ api }); + }; + }, [isPouring, api, startPump, stopPump]); + + const handlePress = () => { setIsPouring(true); }; + const handleRelease = () => { setIsPouring(false); }; + + return ( + + Hold to pour button + + ); +} + +export default connect( + state => ({}), + { startPump, stopPump } +)(HoldToPourComponent); \ No newline at end of file From 936e9b3485442578f20d6926e99964305239c472 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Mon, 15 Jan 2024 12:29:27 +0000 Subject: [PATCH 14/15] better HoldToPour --- ui/src/components/HoldToPour.js | 90 +++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 20 deletions(-) diff --git a/ui/src/components/HoldToPour.js b/ui/src/components/HoldToPour.js index 2471575..4266962 100644 --- a/ui/src/components/HoldToPour.js +++ b/ui/src/components/HoldToPour.js @@ -1,40 +1,90 @@ import React, { useState, useEffect } from 'react'; import { connect } from 'react-redux'; -import { Container } from 'react-bootstrap'; +import { Container, Form } from 'react-bootstrap'; import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext'; import { startPump, stopPump } from '../store/slices/SystemStatus.js'; -export function HoldToPourComponent({ startPump, stopPump }) { - const pouringTime = 1500; - const api = useWaterPumpAPI().API; +export function HoldToPourComponent({ startPump, stopPump, interval }) { const [isPouring, setIsPouring] = useState(false); + const [clickToPour, setClickToPour] = useState(false); + // continuously pour water while the button is pressed + const lastPouringTime = React.useRef(0); + const onTick = React.useCallback( + async () => { + if(Date.now() < lastPouringTime.current) return; + try { + lastPouringTime.current = Number.MAX_SAFE_INTEGER; // prevent concurrent calls + await startPump(); + lastPouringTime.current = Date.now() + interval; + } catch(e) { + lastPouringTime.current = 0; // run again on next tick + } + }, + [startPump, interval] + ); useEffect(() => { - if (!isPouring) return; - - const tid = setInterval(() => { - startPump({ api, pouringTime }); - }, pouringTime - 500); - - return () => { - clearInterval(tid); - stopPump({ api }); - }; - }, [isPouring, api, startPump, stopPump]); + if(!isPouring) { + lastPouringTime.current = 0; + stopPump(); + return; + } + // tick every 100ms + const tid = setInterval(onTick, 100); + return () => { clearInterval(tid); } + }, [onTick, isPouring, stopPump, lastPouringTime]); const handlePress = () => { setIsPouring(true); }; const handleRelease = () => { setIsPouring(false); }; - + const handleCheckboxChange = e => { setClickToPour(e.target.checked); }; + const handleToggle = () => { setIsPouring(!isPouring); }; + // FIX: onMouseDown/onMouseUp is not working on mobile return ( - + Hold to pour button + + + Click to pour (dangerous) + + } /> ); } +// Helper wrapper to simplify the code in the component +function HoldToPourComponent_withExtras({ pouringTime, startPump, stopPump }) { + const api = useWaterPumpAPI().API; + + const _startPump = React.useCallback( + async () => { await startPump({ api, pouringTime }); }, + [api, startPump, pouringTime] + ); + const _stopPump = React.useCallback( + async () => { await stopPump({ api }); }, + [api, stopPump] + ); + // 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 => ({}), + state => ({ pouringTime: state.UI.pouringTime }), { startPump, stopPump } -)(HoldToPourComponent); \ No newline at end of file +)(HoldToPourComponent_withExtras); \ No newline at end of file From a5b064db1f105fc8c051ba55f8a0b56d73863233 Mon Sep 17 00:00:00 2001 From: GreenWizard2015 Date: Fri, 19 Jan 2024 07:56:54 +0000 Subject: [PATCH 15/15] FIX BUG --- ui/src/components/HoldToPour.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/src/components/HoldToPour.js b/ui/src/components/HoldToPour.js index 4266962..87a8914 100644 --- a/ui/src/components/HoldToPour.js +++ b/ui/src/components/HoldToPour.js @@ -31,7 +31,10 @@ export function HoldToPourComponent({ startPump, stopPump, interval }) { } // tick every 100ms const tid = setInterval(onTick, 100); - return () => { clearInterval(tid); } + return async () => { + clearInterval(tid); + if(isPouring) await stopPump(); + }; }, [onTick, isPouring, stopPump, lastPouringTime]); const handlePress = () => { setIsPouring(true); };