diff --git a/ui/src/App.css b/ui/src/App.css
index 2914275..8018e3a 100644
--- a/ui/src/App.css
+++ b/ui/src/App.css
@@ -10,6 +10,5 @@
.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 9d40e06..913ccc8 100644
--- a/ui/src/App.js
+++ b/ui/src/App.js
@@ -1,6 +1,6 @@
import React from 'react';
import './App.css';
-import { Container, Form } from 'react-bootstrap';
+import { Col, Container, Form, Row } from 'react-bootstrap';
import { connect } from 'react-redux';
import NotificationsArea from './components/NotificationsArea.js';
@@ -11,6 +11,7 @@ import SystemStatusArea from './components/SystemStatusArea.js';
import CurrentOperationInfoArea from './components/CurrentOperationInfoArea.js';
import HoldToPour from './components/HoldToPour.js';
import PowerLevel from './components/PowerLevel.js';
+import TeaLevel from './components/TeaLevel.js';
function App({ isConnected }) {
return (
@@ -26,7 +27,14 @@ function App({ isConnected }) {
-
+
+
+
+
+
+
+
+
>
) : null}
diff --git a/ui/src/components/HoldToPour.js b/ui/src/components/HoldToPour.js
index 1cdab7e..f2552af 100644
--- a/ui/src/components/HoldToPour.js
+++ b/ui/src/components/HoldToPour.js
@@ -3,12 +3,20 @@ import { connect } from 'react-redux';
import { Container, Form } from 'react-bootstrap';
import { useWaterPumpAPI } from '../contexts/WaterPumpAPIContext';
-export function HoldToPourComponent({ interval }) {
+export function HoldToPourComponent({ interval, estimatedTeaLevel }) {
const { API }= useWaterPumpAPI();
const [isPouring, setIsPouring] = useState(false);
const [clickToPour, setClickToPour] = useState(false);
// continuously pour water while the button is pressed
const lastPouringTime = React.useRef(0);
+ // stop pouring if estimated level is greater than 100%
+ useEffect(() => {
+ if(!isPouring) return;
+ if(estimatedTeaLevel >= 100) {
+ setIsPouring(false);
+ }
+ }, [isPouring, estimatedTeaLevel]);
+
const onTick = React.useCallback(
async () => {
if(Date.now() < lastPouringTime.current) return;
@@ -77,6 +85,7 @@ function HoldToPourComponent_withExtras({ pouringTime, ...props }) {
export default connect(
state => ({
pouringTime: state.UI.pouringTime,
+ estimatedTeaLevel: state.Temp.estimatedTeaLevel,
}),
{ }
)(HoldToPourComponent_withExtras);
\ No newline at end of file
diff --git a/ui/src/components/TeaLevel.css b/ui/src/components/TeaLevel.css
new file mode 100644
index 0000000..2869bf9
--- /dev/null
+++ b/ui/src/components/TeaLevel.css
@@ -0,0 +1,60 @@
+.tea-glass {
+ height: 100%;
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ user-select: none;
+}
+
+.tea-container {
+ position: relative;
+ height: 100%;
+ max-width: 100%;
+ width: fit-content;
+ display: flex;
+ padding: 1rem;
+}
+
+.cup-image {
+ max-height: 30vh;
+ max-width: 100%;
+ object-fit: contain;
+ position: relative;
+ z-index: 1;
+ cursor: pointer;
+}
+
+.tea-level {
+ position: absolute;
+ left: 0px;
+ right: 0px;
+ height: 3px;
+ background-color: #76b7b2;
+ z-index: 2;
+ color: #76b7b2;
+}
+
+.est-tea-level {
+ position: absolute;
+ left: 0px;
+ right: 0px;
+ height: 3px;
+ border-bottom: 3px dashed red;
+ z-index: 3;
+ text-align: right;
+ padding-bottom: 2rem;
+ color: red;
+}
+
+.prev-tea-level {
+ position: absolute;
+ left: 0px;
+ right: 0px;
+ height: 3px;
+ border-bottom: 3px dashed black;
+ z-index: 3;
+ text-align: center;
+ padding-bottom: 2rem;
+ color: black;
+}
\ No newline at end of file
diff --git a/ui/src/components/TeaLevel.js b/ui/src/components/TeaLevel.js
new file mode 100644
index 0000000..3a4045f
--- /dev/null
+++ b/ui/src/components/TeaLevel.js
@@ -0,0 +1,94 @@
+import React, { useEffect, useState } from 'react';
+import './TeaLevel.css';
+import cup from './cup.jpg';
+import { connect } from 'react-redux';
+import { changeEstimatedTeaLevel, changeStartTeaLevel } from '../store/slices/Temp';
+import { changeSpeed } from '../store/slices/UI';
+
+const bottomLevel = 20; // where the tea starts (0%)
+const maxLevel = 80; // where the tea ends (100%)
+
+function toRealLevel(level) {
+ const H = maxLevel - bottomLevel;
+ return (level / 100 * H) + bottomLevel;
+}
+
+function toPercentage(realLevel) {
+ const H = maxLevel - bottomLevel;
+ return (realLevel - bottomLevel) / H * 100;
+}
+
+function TeaLevel({
+ lastOperationDuration, speed, startTeaLevel, estimatedTeaLevel,
+ changeStartTeaLevel, changeEstimatedTeaLevel, changeSpeed, lastTeaLevel,
+}) {
+ const [calcSpeed, setCalcSpeed] = useState(speed);
+ // update the estimated level if speed or duration changes
+ useEffect(() => {
+ const estimatedLevel = startTeaLevel + speed * lastOperationDuration / 1000;
+ changeEstimatedTeaLevel(estimatedLevel);
+ }, [lastOperationDuration, speed, startTeaLevel, changeEstimatedTeaLevel]);
+
+ const handleCupClick = (e) => {
+ const { top, height } = e.target.getBoundingClientRect();
+ const clickY = e.clientY;
+ const clickedPosition = top - clickY + height;
+ const clickedPercentage = (clickedPosition / height) * 100;
+ const newLevel = toPercentage(clickedPercentage);
+ // limit the new level to the range [0, 100]
+ const level = Math.min(Math.max(newLevel, 0), 100);
+ changeStartTeaLevel( level );
+ // find speed
+ const newSpeed = (level - lastTeaLevel) / (lastOperationDuration / 1000);
+ setCalcSpeed(newSpeed);
+ };
+
+ function onSpeedSet(e) {
+ e.preventDefault();
+ changeSpeed(calcSpeed);
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {startTeaLevel.toFixed(0)}%
+
+
+
+ {estimatedTeaLevel.toFixed(0)}%
+
+
+
+ {lastTeaLevel.toFixed(0)}%
+
+
+
+
+
+ setCalcSpeed(parseFloat(e.target.value))}
+ />
+
+
+ >
+ );
+}
+
+export default connect(
+ state => ({
+ lastOperationDuration: state.Temp.lastOperationDuration,
+ speed: state.UI.speed,
+ startTeaLevel: state.Temp.startTeaLevel,
+ estimatedTeaLevel: state.Temp.estimatedTeaLevel,
+ lastTeaLevel: state.Temp.prevTeaLevel,
+ }),
+ { changeStartTeaLevel, changeEstimatedTeaLevel, changeSpeed }
+)(TeaLevel);
\ No newline at end of file
diff --git a/ui/src/components/cup.jpg b/ui/src/components/cup.jpg
new file mode 100644
index 0000000..d95cdaa
Binary files /dev/null and b/ui/src/components/cup.jpg differ
diff --git a/ui/src/contexts/WaterPumpAPIContext.js b/ui/src/contexts/WaterPumpAPIContext.js
index 92ff269..0e4c9ae 100644
--- a/ui/src/contexts/WaterPumpAPIContext.js
+++ b/ui/src/contexts/WaterPumpAPIContext.js
@@ -1,7 +1,8 @@
-import React from 'react';
+import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { connect } from 'react-redux';
import { CWaterPumpAPI } from '../api/CWaterPumpAPI.js';
import { updateSystemStatus } from '../store/slices/SystemStatus.js';
+import { changeLastOperationDuration, pumpStartedEvent } from '../store/slices/Temp.js';
const WaterPumpAPIContext = React.createContext();
@@ -11,22 +12,30 @@ export function useWaterPumpAPI() {
const FETCH_STATUS_INTERVAL = 5000;
-function _publicWrapper({ apiObject, apiQueue, _pouringTime, _powerLevel }) {
- if(null == apiObject) return { API: null };
+function _publicWrapper({
+ apiObject, apiQueue, _pouringTime, _powerLevel, startTimeRef, onPumpStart
+}) {
+ if (null == apiObject) return { API: null };
return {
API: {
stopPump: () => {
apiQueue.push({
- action: async () => await apiObject.stop(),
+ action: async () => {
+ startTimeRef.current = null; // reset the start time
+ return await apiObject.stop();
+ },
failMessage: 'Failed to stop the pump'
});
},
startPump: () => {
apiQueue.push({
- action: async () => await apiObject.start(
- _pouringTime.current,
- _powerLevel.current
- ),
+ action: async () => {
+ if (startTimeRef.current === null) {
+ startTimeRef.current = Date.now();
+ await onPumpStart();
+ }
+ return await apiObject.start(_pouringTime.current, _powerLevel.current);
+ },
failMessage: 'Failed to start the pump'
});
},
@@ -44,17 +53,17 @@ function _makeStatusAction(apiObject) {
async function _processQueue({ apiQueue, lastUpdateTime, statusAction, updateStatus }) {
const deltaTime = Date.now() - lastUpdateTime.current;
const hasTasks = (0 < apiQueue.length);
- if((deltaTime < FETCH_STATUS_INTERVAL) && !hasTasks) return;
-
+ if ((deltaTime < FETCH_STATUS_INTERVAL) && !hasTasks) return;
+
const action = hasTasks ? apiQueue.shift() : statusAction;
const oldTime = lastUpdateTime.current;
lastUpdateTime.current = Number.MAX_SAFE_INTEGER; // prevent concurrent tasks, just in case
try {
await updateStatus(action);
lastUpdateTime.current = Date.now();
- } catch(error) {
+ } catch (error) {
lastUpdateTime.current = oldTime;
- if(hasTasks) { // re-queue the action if it failed
+ if (hasTasks) { // re-queue the action if it failed
apiQueue.unshift(action);
}
throw error;
@@ -65,40 +74,47 @@ function WaterPumpAPIProviderComponent({
children,
apiHost, pouringTime, powerLevel,
updateStatus,
+ changeLastOperationDuration,
+ onPumpStart,
}) {
- // to prevent the callbacks from changing when the pouringTime or powerLevel changes
- const _pouringTime = React.useRef(pouringTime);
- React.useEffect(() => { _pouringTime.current = pouringTime; }, [pouringTime]);
-
- const _powerLevel = React.useRef(powerLevel);
- React.useEffect(() => { _powerLevel.current = powerLevel; }, [powerLevel]);
-
- const { apiObject, apiQueue } = React.useMemo(
+ const _pouringTime = useRef(pouringTime);
+ useEffect(() => { _pouringTime.current = pouringTime; }, [pouringTime]);
+
+ const _powerLevel = useRef(powerLevel);
+ useEffect(() => { _powerLevel.current = powerLevel; }, [powerLevel]);
+
+ const startTimeRef = useRef(null);
+ const { apiObject, apiQueue } = useMemo(
() => ({
apiObject: new CWaterPumpAPI({ URL: apiHost }),
apiQueue: []
}),
[apiHost]
);
- ////////////////
- const statusAction = React.useMemo(() => _makeStatusAction(apiObject), [apiObject]);
- const lastUpdateTime = React.useRef(0);
- const onTick = React.useCallback(
- async () => _processQueue({ apiQueue, lastUpdateTime, statusAction, updateStatus }),
- [apiQueue, lastUpdateTime, updateStatus, statusAction]
+
+ const statusAction = useMemo(() => _makeStatusAction(apiObject), [apiObject]);
+ const lastUpdateTime = useRef(0);
+ const onTick = useCallback(
+ async () => {
+ if(null != startTimeRef.current) { // update the total duration of the last operation
+ const T = Date.now() - startTimeRef.current;
+ changeLastOperationDuration(T);
+ }
+ _processQueue({ apiQueue, lastUpdateTime, statusAction, updateStatus });
+ },
+ [apiQueue, lastUpdateTime, statusAction, updateStatus, changeLastOperationDuration]
);
- // Run the timer
- React.useEffect(() => {
+ useEffect(() => {
const timer = setInterval(onTick, 100);
return () => { clearInterval(timer); };
}, [onTick]);
- ////////////////
- const value = React.useMemo(
- () => _publicWrapper({ apiObject, apiQueue, _pouringTime, _powerLevel }),
- [apiObject, apiQueue, _pouringTime, _powerLevel]
+ const value = useMemo(
+ () => _publicWrapper({ apiObject, apiQueue, _pouringTime, _powerLevel, startTimeRef, onPumpStart }),
+ [apiObject, apiQueue, _pouringTime, _powerLevel, startTimeRef, onPumpStart]
);
+
return (
{children}
@@ -112,8 +128,11 @@ const WaterPumpAPIProvider = connect(
pouringTime: state.UI.pouringTime,
powerLevel: state.UI.powerLevelInPercents,
}),
- { updateStatus: updateSystemStatus }
+ {
+ updateStatus: updateSystemStatus, changeLastOperationDuration,
+ onPumpStart: pumpStartedEvent
+ }
)(WaterPumpAPIProviderComponent);
export default WaterPumpAPIProvider;
-export { WaterPumpAPIProvider };
\ No newline at end of file
+export { WaterPumpAPIProvider };
diff --git a/ui/src/store/slices/Temp.js b/ui/src/store/slices/Temp.js
new file mode 100644
index 0000000..83c9776
--- /dev/null
+++ b/ui/src/store/slices/Temp.js
@@ -0,0 +1,41 @@
+import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
+
+const initialState = {
+ lastOperationDuration: 0,
+ prevTeaLevel: 0,
+ startTeaLevel: 0,
+ estimatedTeaLevel: 50,
+ estimation: true,
+}
+
+export const TempSlice = createSlice({
+ name: 'Temp',
+ initialState: initialState,
+ reducers: {
+ changeLastOperationDuration: (state, action) => {
+ state.lastOperationDuration = action.payload;
+ },
+ changeEstimatedTeaLevel: (state, action) => {
+ if (state.estimation) {
+ state.estimatedTeaLevel = action.payload;
+ }
+ },
+ pumpStartedEvent: (state, action) => {
+ state.prevTeaLevel = state.startTeaLevel;
+ state.estimation = true;
+ },
+ changeStartTeaLevel: (state, action) => {
+ state.startTeaLevel = action.payload;
+ state.estimatedTeaLevel = state.startTeaLevel;
+ state.estimation = false;
+ },
+ }
+});
+
+export const actions = TempSlice.actions;
+export const {
+ changeLastOperationDuration,
+ changeEstimatedTeaLevel,
+ pumpStartedEvent,
+ changeStartTeaLevel
+} = actions;
\ No newline at end of file
diff --git a/ui/src/store/slices/UI.js b/ui/src/store/slices/UI.js
index b936cae..3dd055e 100644
--- a/ui/src/store/slices/UI.js
+++ b/ui/src/store/slices/UI.js
@@ -13,6 +13,7 @@ const INITIAL_STATE = {
pouringTime: 1000,
powerLevelInPercents: 100,
apiHost: '',
+ speed: 10,
};
// slice for system status
export const UISlice = createSlice({
@@ -27,9 +28,12 @@ export const UISlice = createSlice({
},
updatePowerLevel(state, action) {
state.powerLevelInPercents = validatePowerLevel(action.payload);
- }
+ },
+ changeSpeed(state, action) {
+ state.speed = action.payload;
+ },
},
});
export const actions = UISlice.actions;
-export const { updatePouringTime, updateAPIHost, updatePowerLevel } = actions;
\ No newline at end of file
+export const { updatePouringTime, updateAPIHost, updatePowerLevel, changeSpeed } = actions;
\ No newline at end of file
diff --git a/ui/src/store/slices/index.js b/ui/src/store/slices/index.js
index 9031b1d..51f47a2 100644
--- a/ui/src/store/slices/index.js
+++ b/ui/src/store/slices/index.js
@@ -1,8 +1,9 @@
import { SystemStatusSlice } from "./SystemStatus";
import { UISlice } from "./UI";
import { NotificationsSlice } from "./Notifications";
+import { TempSlice } from "./Temp";
-const slices = [ SystemStatusSlice, UISlice, NotificationsSlice ];
+const slices = [ SystemStatusSlice, UISlice, NotificationsSlice, TempSlice ];
// export all slices as an object { [sliceName]: slice }
export const ALL_APP_SLICES = slices.reduce((acc, slice) => {
acc[slice.name] = slice;