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 ( + <> +
+
+
+ Cup +
+ {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;