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/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(); diff --git a/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp b/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp index c776abf..a58434e 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\": {" @@ -43,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/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 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..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 @@ -46,6 +53,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 +77,7 @@ TEST(CommandProcessor, status_running) { const auto response = commandProcessor.status(); ASSERT_EQ(response, "{" + "\"time\": 123, " "\"water threshold\": 12345, " "\"pump\": {" " \"running\": true, " diff --git a/ui/public/valve.png b/ui/public/valve.png new file mode 100644 index 0000000..9b53ded Binary files /dev/null and b/ui/public/valve.png differ diff --git a/ui/src/App.css b/ui/src/App.css index 8b908d5..2914275 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -1,2 +1,15 @@ .App { +} + +.countdown-area { + width: 100%; + text-align: center; + font-weight: bold; + font-size: 2rem; +} + +.hold-to-pour-image { + object-fit: contain; + width: 25%; + height: auto; } \ No newline at end of file diff --git a/ui/src/App.js b/ui/src/App.js index f27ad68..5f6949a 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -4,13 +4,14 @@ 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'; +import HoldToPour from './components/HoldToPour.js'; function App({ isConnected }) { - // TODO: Add a fake countdown timer of timeLeft return (

Tea System UI

@@ -21,7 +22,9 @@ function App({ isConnected }) { {isConnected ? ( <> + + ) : null} 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/Utils/time.js b/ui/src/Utils/time.js new file mode 100644 index 0000000..c5efa65 --- /dev/null +++ b/ui/src/Utils/time.js @@ -0,0 +1,22 @@ +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, bounded=false}) { + if (null === startTime) startTime = new Date(); + if (null === endTime) endTime = new Date(); + + 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/api/CWaterPumpAPI.js b/ui/src/api/CWaterPumpAPI.js index 978eb55..7c297ac 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) { @@ -11,26 +12,26 @@ function preprocessApiHost(apiHost) { } 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 }) { + // 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: fakeClient, }); - return response.data; } - async stop() { - const response = await this._client.get('/stop'); - return response.data; - } - - async status() { - const response = await this._client.get('/status'); - return 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..995f260 --- /dev/null +++ b/ui/src/api/CWaterPumpAPIImpl.js @@ -0,0 +1,65 @@ +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', { params: { milliseconds: runTimeMs } }) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + + async stop() { + const { response: { data }, requestTime } = await this._execute( + 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', { params: {} }) + ); + return this.preprocessResponse({ response: data, requestTime }); + } + /////////////////////// + // helper functions + preprocessResponse({ response, requestTime }) { + if(null == response) return null; + if('error' in response) { + 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..5a61036 --- /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) { + 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 diff --git a/ui/src/components/CurrentOperationInfoArea.js b/ui/src/components/CurrentOperationInfoArea.js new file mode 100644 index 0000000..016fb18 --- /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 +}) { + estimatedEndTime = isRunning ? estimatedEndTime : 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/HoldToPour.js b/ui/src/components/HoldToPour.js new file mode 100644 index 0000000..87a8914 --- /dev/null +++ b/ui/src/components/HoldToPour.js @@ -0,0 +1,93 @@ +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 }) { + 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) { + lastPouringTime.current = 0; + stopPump(); + return; + } + // tick every 100ms + const tid = setInterval(onTick, 100); + return async () => { + clearInterval(tid); + if(isPouring) await stopPump(); + }; + }, [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 => ({ pouringTime: state.UI.pouringTime }), + { startPump, stopPump } +)(HoldToPourComponent_withExtras); \ No newline at end of file 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..b98b6e5 100644 --- a/ui/src/components/SystemControls.js +++ b/ui/src/components/SystemControls.js @@ -1,43 +1,32 @@ 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 { 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; return ( - <> - {' '} - + - +
); } diff --git a/ui/src/components/SystemStatusArea.js b/ui/src/components/SystemStatusArea.js index 1279b0b..974fb4d 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) { @@ -27,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 new file mode 100644 index 0000000..90e4429 --- /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, bounded=true }) { + const [countdown, setCountdown] = React.useState(''); + + React.useEffect(() => { + const tid = setInterval(() => { + setCountdown(timeBetweenAsString({ startTime, endTime, bounded })); + }, interval); + + return () => clearInterval(tid); + }, [startTime, endTime, bounded, interval]); + + return ( + + {countdown} + + ); +} + +export default TimerArea; \ No newline at end of file diff --git a/ui/src/contexts/WaterPumpStatusProvider.js b/ui/src/components/WaterPumpStatusProvider.js similarity index 75% rename from ui/src/contexts/WaterPumpStatusProvider.js rename to ui/src/components/WaterPumpStatusProvider.js index 915caa5..035cfed 100644 --- a/ui/src/contexts/WaterPumpStatusProvider.js +++ b/ui/src/components/WaterPumpStatusProvider.js @@ -1,15 +1,13 @@ 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'; 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/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(); 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..7913f1c --- /dev/null +++ b/ui/src/store/slices/Notifications.js @@ -0,0 +1,25 @@ +import { createSlice } from '@reduxjs/toolkit'; + +export const NotificationsSlice = createSlice({ + name: 'notifications', + initialState: { + currentNotifications: null + }, + reducers: { + alert: (state, action) => { + 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; + } + } +}); + +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 8abba9f..a1f4eae 100644 --- a/ui/src/store/slices/SystemStatus.js +++ b/ui/src/store/slices/SystemStatus.js @@ -1,59 +1,72 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { NotificationsSystemActions } from './Notifications'; -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; +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 }) => { - console.log('startPump: pouringTime = ' + pouringTime); - 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 }) => { - console.log('stopPump'); - 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 const bindStatus = (state, action) => { - return preprocessSystemStatus(action.payload); + return action.payload; }; 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) => { + return null; + }); } }); -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;