From a9f5b9594b61de46bdf8d2b6d865f92eecbd64ff Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 6 Mar 2026 15:12:25 -0600 Subject: [PATCH 01/12] added controller display component for the controllers on a bin --- src/bin/BinControllerDisplay.tsx | 145 +++++++++++++++++++++++++++++++ src/bin/BinVisualizerV2.tsx | 77 ++++++---------- src/common/ButtonGroup.tsx | 10 ++- 3 files changed, 180 insertions(+), 52 deletions(-) create mode 100644 src/bin/BinControllerDisplay.tsx diff --git a/src/bin/BinControllerDisplay.tsx b/src/bin/BinControllerDisplay.tsx new file mode 100644 index 0000000..9744ad2 --- /dev/null +++ b/src/bin/BinControllerDisplay.tsx @@ -0,0 +1,145 @@ +import { Component } from "models" +import React, { useEffect, useState } from "react" +import { makeStyles } from "@mui/styles"; +import { Box, Button, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material"; +import AerationFanIcon from "products/AgIcons/AerationFanIcon"; +import { quack } from "protobuf-ts/quack"; +import { useComponentAPI, useMobile } from "hooks"; +import ButtonGroup from "common/ButtonGroup"; +import ObjectHeaterIcon from "products/Construction/ObjectHeaterIcon"; +import ResponsiveDialog from "common/ResponsiveDialog"; + + +interface Props { + deviceID: number + component: Component +} + +const useStyles = makeStyles(() => { + return ({ + controllerDisplay: { + display: "flex", + justifyContent: "space-between", + paddingLeft: 5, + paddingRight: 5 + }, + }) +}) + +export default function BinControllerDisplay(props: Props) { + const {component, deviceID} = props + const [controllerState, setControllerState] = useState(0) + const [openDialog, setOpenDialog] = useState(false) + const [newOutputState, setNewOutputState] = useState(0) + const componentAPI = useComponentAPI() + const isMobile = useMobile() + const classes = useStyles() + + useEffect(()=>{ + setControllerState(component.settings.defaultOutputState) + },[component]) + + + const controllerIcon = () => { + if(component.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_HEATER){ + return + }else{ + return + } + } + + const setController = () => { + let newSettings = component.settings + newSettings.defaultOutputState = newOutputState + } + + const controllerDialog = () => { + return ( + {setOpenDialog(false)}}> + Set Controller State + + This Action will change the output state of the controller + + + + + + + ) + } + + const componentOn = () => { + let state = false + console.log(component.status) + component.status.lastGoodMeasurement.forEach(um => { + if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){ + if(um.values.length > 0){ + let readings = um.values + if(readings[readings.length-1].values.length > 0){ + let nodes = readings[readings.length-1].values + if (nodes[nodes.length -1] === 1){ + state = true + } + //um.values[um.values.length-1].values[um.values[um.values.length-1].values.length-1] + } + } + } + }) + return state + } + + return ( + + + {controllerIcon()} + + {component.name()} + + + { + setNewOutputState(0) + setOpenDialog(true) + } + }, + { + title: "On", + function: () => { + setNewOutputState(1) + setOpenDialog(true) + } + }, + { + title: "Off", + function: () => { + setNewOutputState(2) + setOpenDialog(true) + } + } + ]} + toggledButtons={[controllerState]} + toggle + /> + + {componentOn() ? "ON" : "OFF"} + + + ) + +} \ No newline at end of file diff --git a/src/bin/BinVisualizerV2.tsx b/src/bin/BinVisualizerV2.tsx index 2e2f987..cfdff05 100644 --- a/src/bin/BinVisualizerV2.tsx +++ b/src/bin/BinVisualizerV2.tsx @@ -31,7 +31,7 @@ import { round } from "lodash"; import { Bin, Component, Device } from "models"; import { GetComponentIcon } from "pbHelpers/ComponentType"; import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; -import { useMobile } from "hooks"; +import { useComponentAPI, useMobile } from "hooks"; import { pond } from "protobuf-ts/pond"; import { quack } from "protobuf-ts/quack"; import { useGlobalState, useSnackbar } from "providers"; @@ -71,6 +71,7 @@ import { makeStyles } from "@mui/styles"; import ButtonGroup from "common/ButtonGroup"; import ModeChangeDialog from "./conditioning/modeChangeDialog"; import CustomGrainSelector from "grain/CustomGrainSelector"; +import BinControllerDisplay from "./BinControllerDisplay"; const useStyles = makeStyles((theme: Theme) => { return ({ @@ -180,7 +181,7 @@ interface Props { bin: Bin; loading: boolean; components: Map; - componentDevices?: Map; + componentDevices: Map; devices: Device[]; plenums?: Plenum[]; ambient?: Ambient; @@ -289,6 +290,12 @@ export default function BinVisualizer(props: Props) { const [modeChangeInProgress, setModeChangeInProgress] = useState(false) const [newGrainSettings, setNewGrainSettings] = useState() + const componentAPI = useComponentAPI() + const [openControllerDIalog, setOpenControllerDialog] = useState(false) + const [controllerOutputStates, setControllerOutputStates] = useState>(new Map()) + const [selectedController, setSelectedController] = useState() + const [newOutputState, setNewOutputState] = useState() + useEffect(() => { setModeTime(moment(bin.status.lastModeChange)); }, [bin.status.lastModeChange]); @@ -1544,54 +1551,24 @@ export default function BinVisualizer(props: Props) { Controllers - {bin.status.fans.map(fan => ( - - - - - {fan.name} - - - - {fan.state ? "ON" : "OFF"} - - - ))} - {bin.status.heaters.map(heater => ( - - - - - {heater.name} - - - - {heater.state ? "ON" : "OFF"} - - - ))} + {bin.status.fans.map(fan => { + let fanComp = components.get(fan.key) + let device = componentDevices.get(fan.key) + if(fanComp && device){ + return ( + + ) + } + })} + {bin.status.heaters.map(heater => { + let heaterComp = components.get(heater.key) + let device = componentDevices.get(heater.key) + if(heaterComp && device){ + return ( + + ) + } + })} ); diff --git a/src/common/ButtonGroup.tsx b/src/common/ButtonGroup.tsx index e326d64..ee9e3e4 100644 --- a/src/common/ButtonGroup.tsx +++ b/src/common/ButtonGroup.tsx @@ -48,7 +48,11 @@ interface Props { * * @default undefined */ - toggledButtons?: number[] + toggledButtons?: number[] + /** + * This can be used to set which button starts toggled by default, and from there it will be controlled internally, toggledButtons will override this + */ + defaultToggle?: number /** * Numerical value for the size of the text for buttons that dont use the icon */ @@ -97,7 +101,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function ButtonGroup(props: Props){ - const { buttons, toggle, toggledButtons, multiToggle, textSize, disableAll } = props + const { buttons, toggle, toggledButtons, defaultToggle, multiToggle, textSize, disableAll } = props const classes = useStyles() const [currentToggle, setCurrentToggle] = useState([]) @@ -107,6 +111,8 @@ export default function ButtonGroup(props: Props){ let toggled: number [] = [0] if(toggledButtons){ toggled = toggledButtons + } else if(defaultToggle){ + toggled = [defaultToggle] } setCurrentToggle(toggled) } From c821d6f265791080f1ad190773d752e980485788 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 6 Mar 2026 16:07:57 -0600 Subject: [PATCH 02/12] fixed the creation of interactions for gates to use the node at the outlet, this would be node 2 for old setups and node 1 for new ones and the interactions no longer use node diffs it just uses the raw measurement of that nodes pressure --- src/gate/GateDeviceInteraction.tsx | 78 ++++++++++++++---------------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/src/gate/GateDeviceInteraction.tsx b/src/gate/GateDeviceInteraction.tsx index 23669f3..55140d4 100644 --- a/src/gate/GateDeviceInteraction.tsx +++ b/src/gate/GateDeviceInteraction.tsx @@ -93,13 +93,11 @@ export default function GateDeviceInteraction(props: Props) { compDevice.components.forEach(comp => { let component = Component.any(comp); //checks the address for the LED components to get the red and green LED's - if (component.locationString() === redAddr && component.subType() === 1) { setRedComponent(component); } else if (component.locationString() === greenAddr && component.subType() === 1) { setGreenComponent(component); - } else if (component.type() === quack.ComponentType.COMPONENT_TYPE_PRESSURE_CABLE) { - if ( + } else if ( gate.preferences[component.key()] === pond.GateComponentType.GATE_COMPONENT_TYPE_PRESSURE ) { setPressureComponent(component); @@ -111,7 +109,7 @@ export default function GateDeviceInteraction(props: Props) { }) ) } - } + }); if(compDevice.device){ @@ -129,13 +127,13 @@ export default function GateDeviceInteraction(props: Props) { const createInteractions = async () => { //the interactions to be made - if ( greenComponent !== undefined && redComponent !== undefined && pressureComponent !== undefined ) { - + //get the number of nodes in the pressure components + let nodes = pressureComponent.numNodes() let lightInteractions: pond.InteractionSettings[] = [] //making variables for the parameters of the interactions const redSink = quack.ComponentID.create({ @@ -163,19 +161,18 @@ export default function GateDeviceInteraction(props: Props) { source: pressureSource, conditions: [ pond.InteractionCondition.create({ - comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, - value: -highDelta, + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN, + value: highDelta, measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE }), pond.InteractionCondition.create({ - comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN, - value: -lowDelta, + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, + value: lowDelta, measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE }) ], - nodeOne: 2, - nodeTwo: 1, - subtype: 18, + nodeOne: nodes, + subtype: nodes+1, schedule: lightSchedule, result: pond.InteractionResult.create({ type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE, @@ -201,14 +198,13 @@ export default function GateDeviceInteraction(props: Props) { source: pressureSource, conditions: [ pond.InteractionCondition.create({ - comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN, - value: -highDelta, + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, + value: highDelta, measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE }) ], - nodeOne: 2, - nodeTwo: 1, - subtype: 18, + nodeOne: nodes, + subtype: nodes+1, schedule: lightSchedule, result: pond.InteractionResult.create({ type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_SET, @@ -221,19 +217,18 @@ export default function GateDeviceInteraction(props: Props) { source: pressureSource, conditions: [ pond.InteractionCondition.create({ - comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, - value: -highDelta, + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN, + value: highDelta, measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE }), pond.InteractionCondition.create({ - comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN, - value: -lowDelta, + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, + value: lowDelta, measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE }) ], - nodeOne: 2, - nodeTwo: 1, - subtype: 18, + nodeOne: nodes, + subtype: nodes+1, schedule: lightSchedule, result: pond.InteractionResult.create({ type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_SET, @@ -246,19 +241,18 @@ export default function GateDeviceInteraction(props: Props) { source: pressureSource, conditions: [ pond.InteractionCondition.create({ - comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, - value: -lowDelta, + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN, + value: lowDelta, measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE }), pond.InteractionCondition.create({ - comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN, - value: -thresholdPascals, + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, + value: thresholdPascals, measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE }), ], - nodeOne: 2, - nodeTwo: 1, - subtype: 18, + nodeOne: nodes, + subtype: nodes+1, schedule: lightSchedule, result: pond.InteractionResult.create({ type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_SET, @@ -276,7 +270,8 @@ export default function GateDeviceInteraction(props: Props) { measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE }), ], - subtype: 1, + nodeOne: nodes, + subtype: nodes+1, schedule: lightSchedule, result: pond.InteractionResult.create({ type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_SET, @@ -291,19 +286,18 @@ export default function GateDeviceInteraction(props: Props) { source: pressureSource, conditions: [ pond.InteractionCondition.create({ - comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, - value: -highDelta, + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN, + value: highDelta, measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE }), pond.InteractionCondition.create({ - comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN, - value: -lowDelta, + comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN, + value: lowDelta, measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE }) ], - nodeOne: 2, - nodeTwo: 1, - subtype: 18, + nodeOne: nodes, + subtype: nodes+1, schedule: lightSchedule, result: pond.InteractionResult.create({ type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE, @@ -393,7 +387,6 @@ export default function GateDeviceInteraction(props: Props) { color="primary" onClick={() => { //TODO: Once we figure out how to fix the backend to handle rapid addition/removal of interactions - //removeCurrentInteractions(); setAdding(true); interactionsAPI.clearInteractions(device.id(), [pressureSource]).then(resp => { createInteractions(); @@ -401,7 +394,8 @@ export default function GateDeviceInteraction(props: Props) { console.error(err) }) }} - disabled={buttonDisabled()}> + disabled={buttonDisabled()} + > {adding ? "Adding Interaction" : "Create Interaction"} From b992b6785c7a2f15c5394eaa60b099aa6d9a6d69 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Mon, 9 Mar 2026 11:41:17 -0600 Subject: [PATCH 03/12] connected the api to the button to update the controller state --- src/bin/BinControllerDisplay.tsx | 61 ++++++++++++++++++++++---------- src/bin/BinVisualizerV2.tsx | 4 +-- 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/bin/BinControllerDisplay.tsx b/src/bin/BinControllerDisplay.tsx index 9744ad2..32cbd94 100644 --- a/src/bin/BinControllerDisplay.tsx +++ b/src/bin/BinControllerDisplay.tsx @@ -4,7 +4,7 @@ import { makeStyles } from "@mui/styles"; import { Box, Button, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material"; import AerationFanIcon from "products/AgIcons/AerationFanIcon"; import { quack } from "protobuf-ts/quack"; -import { useComponentAPI, useMobile } from "hooks"; +import { useComponentAPI, useMobile, useSnackbar } from "hooks"; import ButtonGroup from "common/ButtonGroup"; import ObjectHeaterIcon from "products/Construction/ObjectHeaterIcon"; import ResponsiveDialog from "common/ResponsiveDialog"; @@ -13,6 +13,7 @@ import ResponsiveDialog from "common/ResponsiveDialog"; interface Props { deviceID: number component: Component + currentState?: boolean } const useStyles = makeStyles(() => { @@ -27,10 +28,11 @@ const useStyles = makeStyles(() => { }) export default function BinControllerDisplay(props: Props) { - const {component, deviceID} = props + const {component, deviceID, currentState} = props const [controllerState, setControllerState] = useState(0) const [openDialog, setOpenDialog] = useState(false) const [newOutputState, setNewOutputState] = useState(0) + const {openSnack} = useSnackbar() const componentAPI = useComponentAPI() const isMobile = useMobile() const classes = useStyles() @@ -51,6 +53,14 @@ export default function BinControllerDisplay(props: Props) { const setController = () => { let newSettings = component.settings newSettings.defaultOutputState = newOutputState + componentAPI.update(deviceID, newSettings).then(resp => { + openSnack("Updated controllers output state") + setControllerState(newOutputState) + }).catch(err => { + openSnack("There was a problem updating the controller") + }).finally(() => { + setOpenDialog(false) + }) } const controllerDialog = () => { @@ -62,39 +72,47 @@ export default function BinControllerDisplay(props: Props) { - + ) } const componentOn = () => { - let state = false - console.log(component.status) - component.status.lastGoodMeasurement.forEach(um => { - if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){ - if(um.values.length > 0){ - let readings = um.values - if(readings[readings.length-1].values.length > 0){ - let nodes = readings[readings.length-1].values - if (nodes[nodes.length -1] === 1){ - state = true + if(currentState){ + return currentState + }else{ + let state = false + component.status.lastGoodMeasurement.forEach(um => { + if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN){ + if(um.values.length > 0){ + let readings = um.values + if(readings[readings.length-1].values.length > 0){ + let nodes = readings[readings.length-1].values + if (nodes[nodes.length -1] === 1){ + state = true + } } - //um.values[um.values.length-1].values[um.values[um.values.length-1].values.length-1] } } - } - }) - return state + }) + return state + } } return ( - + + {controllerDialog()} + - {controllerIcon()} + + {controllerIcon()} + + {componentOn() ? "ON" : "OFF"} + + ) } \ No newline at end of file diff --git a/src/bin/BinVisualizerV2.tsx b/src/bin/BinVisualizerV2.tsx index cfdff05..c51908a 100644 --- a/src/bin/BinVisualizerV2.tsx +++ b/src/bin/BinVisualizerV2.tsx @@ -1556,7 +1556,7 @@ export default function BinVisualizer(props: Props) { let device = componentDevices.get(fan.key) if(fanComp && device){ return ( - + ) } })} @@ -1565,7 +1565,7 @@ export default function BinVisualizer(props: Props) { let device = componentDevices.get(heater.key) if(heaterComp && device){ return ( - + ) } })} From 989ca20a31904701d70c70115ecc7cccdf98660b Mon Sep 17 00:00:00 2001 From: csawatzky Date: Mon, 9 Mar 2026 14:17:44 -0600 Subject: [PATCH 04/12] fixed some bugs related to gates component preferences, noww when assigning a new component to a position ie moving the pressure from one component to another it will make sure to remove the old component preference as well as add the new one, and am also making sure to update the gates preferences on the frontend so a page load is not required to set interactions --- src/bin/BinVisualizerV2.tsx | 4 ++-- src/gate/GateDevice.tsx | 37 ++++++++++++++++++++++++------ src/gate/GateDeviceInteraction.tsx | 3 ++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/bin/BinVisualizerV2.tsx b/src/bin/BinVisualizerV2.tsx index c51908a..37ed664 100644 --- a/src/bin/BinVisualizerV2.tsx +++ b/src/bin/BinVisualizerV2.tsx @@ -1556,7 +1556,7 @@ export default function BinVisualizer(props: Props) { let device = componentDevices.get(fan.key) if(fanComp && device){ return ( - + ) } })} @@ -1565,7 +1565,7 @@ export default function BinVisualizer(props: Props) { let device = componentDevices.get(heater.key) if(heaterComp && device){ return ( - + ) } })} diff --git a/src/gate/GateDevice.tsx b/src/gate/GateDevice.tsx index f62dd25..a445b28 100644 --- a/src/gate/GateDevice.tsx +++ b/src/gate/GateDevice.tsx @@ -126,9 +126,24 @@ export default function GateDevice(props: Props) { } }, [comprehensiveDevice, gate, linkedCompList, user]); - const gateComponentUpdate = (componentKey: string, gateCompType: pond.GateComponentType) => { + const gateComponentUpdate = (oldKey: string, componentKey: string, gateCompType: pond.GateComponentType) => { + let promises: any[] = [] + if (oldKey !== "") { + //the promise for removing the pref from the old component + promises.push(gateAPI + .updatePrefs( + gate.key, + "component", + device.id() + ":" + oldKey, + pond.GateComponentType.GATE_COMPONENT_TYPE_UNKNOWN, + [gate.key], + ["gate"], + as + )) + } if (componentKey !== "") { - gateAPI + //the promise for adding the pref to the new component + promises.push(gateAPI .updatePrefs( gate.key, "component", @@ -137,11 +152,17 @@ export default function GateDevice(props: Props) { [gate.key], ["gate"], as - ) - .then(resp => { - openSnack("Component Prefence Updated"); - }); + )) } + Promise.all(promises).then(resp => { + openSnack("Component Prefence Updated"); + let clone = cloneDeep(gate.preferences) + clone[oldKey] = pond.GateComponentType.GATE_COMPONENT_TYPE_UNKNOWN + clone[componentKey] = gateCompType + gate.preferences = clone + + }).catch( + ); }; useEffect(() => { @@ -205,7 +226,7 @@ export default function GateDevice(props: Props) { style={{ fontSize: 8 }} onChange={e => { var key = e.target.value as string; - gateComponentUpdate(key, pond.GateComponentType.GATE_COMPONENT_TYPE_AMBIENT); + gateComponentUpdate(ambientKey, key, pond.GateComponentType.GATE_COMPONENT_TYPE_AMBIENT); setAmbientKey(key); if (tempKey === key) { setTempKey(""); @@ -238,6 +259,7 @@ export default function GateDevice(props: Props) { style={{ fontSize: 8 }} onChange={e => { gateComponentUpdate( + tempKey, e.target.value as string, pond.GateComponentType.GATE_COMPONENT_TYPE_TEMP ); @@ -273,6 +295,7 @@ export default function GateDevice(props: Props) { style={{ fontSize: 8 }} onChange={e => { gateComponentUpdate( + pressureKey, e.target.value as string, pond.GateComponentType.GATE_COMPONENT_TYPE_PRESSURE ); diff --git a/src/gate/GateDeviceInteraction.tsx b/src/gate/GateDeviceInteraction.tsx index 55140d4..0303123 100644 --- a/src/gate/GateDeviceInteraction.tsx +++ b/src/gate/GateDeviceInteraction.tsx @@ -100,6 +100,7 @@ export default function GateDeviceInteraction(props: Props) { } else if ( gate.preferences[component.key()] === pond.GateComponentType.GATE_COMPONENT_TYPE_PRESSURE ) { + console.log(component) setPressureComponent(component); setPressureSource( quack.ComponentID.create({ @@ -115,7 +116,7 @@ export default function GateDeviceInteraction(props: Props) { if(compDevice.device){ setDevice(Device.create(compDevice.device)) } - }, [gate, densityTemp, user, compDevice]); + }, [gate, densityTemp, user, compDevice, gate.preferences]); const buttonDisabled = () => { if (greenComponent === undefined) return true From a2c68e0089e5d3399600a2430a53d907f0c5fb69 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 10 Mar 2026 12:58:07 -0600 Subject: [PATCH 05/12] added to the device settings and the chip for if a device needs the I2C bus component to power the I2C Line --- package-lock.json | 4 ++-- package.json | 2 +- src/device/DeviceOverview.tsx | 13 ++++++++++++- src/device/DeviceSettings.tsx | 26 +++++++++++++++++++++++++- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 82d123f..7a960a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bus_chip", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11967,7 +11967,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#080793fb450e5721fefd7a6f6f28df98e810ba90", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#dbe6d5a395c8fe8a098574e645aa14a99cdd7c85", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 90ac71b..9470353 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bus_chip", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/device/DeviceOverview.tsx b/src/device/DeviceOverview.tsx index 0170bde..0115568 100644 --- a/src/device/DeviceOverview.tsx +++ b/src/device/DeviceOverview.tsx @@ -1,5 +1,5 @@ import { Chip, Grid2 as Grid, Theme, Tooltip, Skeleton } from "@mui/material"; -import { DataUsage, SimCard, Launch } from "@mui/icons-material"; +import { DataUsage, SimCard, Launch, DeviceHub } from "@mui/icons-material"; import StatusChip from "common/StatusChip"; import DeviceTags from "device/DeviceTags"; import VersionChip from "device/VersionChip"; @@ -233,6 +233,17 @@ export default function DeviceOverview(props: Props) { )} + {device.settings.needsBusControl && ( + + + } + /> + + + )} {} ); diff --git a/src/device/DeviceSettings.tsx b/src/device/DeviceSettings.tsx index dea3cc7..a9277f5 100644 --- a/src/device/DeviceSettings.tsx +++ b/src/device/DeviceSettings.tsx @@ -235,6 +235,12 @@ export default function DeviceSettings(props: Props) { setDeviceForm(updatedForm); }; + const toggleBusRequired = (event: any) => { + let updatedForm = Device.clone(deviceForm); + updatedForm.settings.needsBusControl = event.target.checked; + setDeviceForm(updatedForm); + }; + const toggleSleeps = (event: any) => { let updatedForm = Device.clone(deviceForm); let updatedSleeps = event.target.checked; @@ -500,7 +506,8 @@ export default function DeviceSettings(props: Props) { )} {user.hasAdmin() && ( - + + + + + } + disabled={!canEdit} + label="I2C Bus Required" + labelPlacement="end" + /> + + )} From 5502d9670e4b2f3de1afa45c76bbe8d54e7c617f Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 10 Mar 2026 13:05:43 -0600 Subject: [PATCH 06/12] proto update --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7a960a2..79e2f18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bus_chip", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11967,7 +11967,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#dbe6d5a395c8fe8a098574e645aa14a99cdd7c85", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#1c00e059fe16126e85c50581786b7510759920d7", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 9470353..90ac71b 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bus_chip", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", From 115e2c5ed49354436ea3ef4e8d134198d1233c57 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 10 Mar 2026 16:50:59 -0600 Subject: [PATCH 07/12] added column for the device state and some wording next to the icond for the pca state so its not as ambiguous --- package-lock.json | 4 +- package.json | 2 +- src/gate/GateList.tsx | 59 +++++++++++++++++-- .../ComponentTypes/DragerGasDongle.ts | 5 ++ 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 79e2f18..d46565d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#terminal_dashboard_changes", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11967,7 +11967,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#1c00e059fe16126e85c50581786b7510759920d7", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bfe8ce6f4ee17a178304e17718a8911b060b2e69", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 90ac71b..5f7818c 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#terminal_dashboard_changes", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/gate/GateList.tsx b/src/gate/GateList.tsx index 2cdec52..5910808 100644 --- a/src/gate/GateList.tsx +++ b/src/gate/GateList.tsx @@ -1,5 +1,5 @@ import { Gate } from "models/Gate"; -import { Box, Card, IconButton, List, ListItem, ListItemText, Theme, Typography } from "@mui/material"; +import { Box, Card, IconButton, List, ListItem, ListItemText, Theme, Tooltip, Typography } from "@mui/material"; import React, { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useMobile } from "hooks"; @@ -20,6 +20,7 @@ import { quack } from "protobuf-ts/quack"; import { getTemperatureUnit } from "utils"; import { teal } from "@mui/material/colors"; import { react } from "@babel/types"; +import { getDeviceStateHelper } from "pbHelpers/DeviceState"; interface Props { //gates: Gate[]; @@ -42,6 +43,12 @@ const useStyles = makeStyles((theme: Theme) => ({ gateCard: { marginBottom: 10, paddingLeft: 15 + }, + cellContainer: { + margin: theme.spacing(1), + marginLeft: theme.spacing(2), + display: "flex", + width: theme.spacing(10) } })); @@ -100,13 +107,33 @@ export default function GateList(props: Props) { const displayPCAStatus = (state: pond.PCAState) => { switch(state){ case pond.PCAState.PCA_STATE_IN_BOUNDS: - return + return ( + + + PCA in threshold + + ) case pond.PCAState.PCA_STATE_OUT_BOUNDS: - return + return ( + + + PCA out of threshold + + ) case pond.PCAState.PCA_STATE_OFF: - return + return ( + + + PCA off + + ) default: - return + return ( + + + PCA device not found + + ) } } @@ -166,7 +193,7 @@ export default function GateList(props: Props) { const conditionDisplay = (gate: Gate) => { let display = "" if(gate.status.pcaState === pond.PCAState.PCA_STATE_OFF){ - display = "Inactive" + display = "PCA Off" } else if (gate.status.pcaState === pond.PCAState.PCA_STATE_UNKNOWN){ display = "--" } else { //the pca is currently active calulate the delta temp (outlet - ambient) @@ -241,6 +268,19 @@ export default function GateList(props: Props) { ) }, + { + title: "Device State", + // cellStyle: {width: 100000}, + // sortKey: "state", + render: gate => { + const deviceStateHelper = getDeviceStateHelper(gate.status.pcaDeviceState); + return ( + + {deviceStateHelper.icon} + + ) + } + }, { title: "PCA Status", render: gate => { @@ -324,6 +364,7 @@ export default function GateList(props: Props) { } const gutter = (gate: Gate) => { + const deviceStateHelper = getDeviceStateHelper(gate.status.pcaDeviceState) return( @@ -334,6 +375,12 @@ export default function GateList(props: Props) { Delta Temperature: {conditionDisplay(gate)} + + Device State: + + {deviceStateHelper.icon} + + {/* taking these out for now because we are not sure if the customer wants them */} {/* Estimated Temp: diff --git a/src/pbHelpers/ComponentTypes/DragerGasDongle.ts b/src/pbHelpers/ComponentTypes/DragerGasDongle.ts index dd28f75..648b01d 100644 --- a/src/pbHelpers/ComponentTypes/DragerGasDongle.ts +++ b/src/pbHelpers/ComponentTypes/DragerGasDongle.ts @@ -92,6 +92,11 @@ export function dragerGasDongle(subtype: number = 0): ComponentTypeExtension { key: quack.DragerGasDongleSubtype.DRAGER_GAS_DONGLE_SUBTYPE_LEL, value: "DRAGER_GAS_DONGLE_SUBTYPE_LEL", friendlyName: "Drager Gas LEL" + }, + { + key: quack.DragerGasDongleSubtype.DRAGER_GAS_DONGLE_SUBTYPE_AMMONIA, + value: "DRAGER_GAS_DONGLE_SUBTYPE_AMMONIA", + friendlyName: "Drager Gas Ammonia" } ], friendlyName: "Drager Gas", From 863f87da1c7dc47168d35615bc2f1227916791fd Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 10 Mar 2026 16:52:02 -0600 Subject: [PATCH 08/12] switch to staging proto --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d46565d..9b726d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#terminal_dashboard_changes", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11967,7 +11967,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bfe8ce6f4ee17a178304e17718a8911b060b2e69", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#e4a1e598240d504e3ccfe09d6f023c5865ab2c71", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 5f7818c..90ac71b 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#terminal_dashboard_changes", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", From c7016be6cdb783e83ab4d49692fa67aae4c46786 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 11 Mar 2026 11:21:36 -0600 Subject: [PATCH 09/12] fixing bug to make sure controller from other devices dont show as options for interactions when using the nodes on the bin svg --- src/bin/BinVisualizerV2.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/bin/BinVisualizerV2.tsx b/src/bin/BinVisualizerV2.tsx index 37ed664..2da6f78 100644 --- a/src/bin/BinVisualizerV2.tsx +++ b/src/bin/BinVisualizerV2.tsx @@ -290,11 +290,7 @@ export default function BinVisualizer(props: Props) { const [modeChangeInProgress, setModeChangeInProgress] = useState(false) const [newGrainSettings, setNewGrainSettings] = useState() - const componentAPI = useComponentAPI() - const [openControllerDIalog, setOpenControllerDialog] = useState(false) - const [controllerOutputStates, setControllerOutputStates] = useState>(new Map()) - const [selectedController, setSelectedController] = useState() - const [newOutputState, setNewOutputState] = useState() + const [filteredComponents, setFilteredComponents] = useState([]) useEffect(() => { setModeTime(moment(bin.status.lastModeChange)); @@ -1420,13 +1416,21 @@ export default function BinVisualizer(props: Props) { coldestNodeTemp={valueDisplay === "low" ? lowNodeConditions?.tempC : undefined} cableNodeClicked={(cable, fillNode) => { if (cable) { - let devId = componentDevices?.get(cable.key()); + let devId = componentDevices.get(cable.key()); if (devId) { let device = devMap.get(devId); if (device) { setSelectedCable(cable); setSelectedNode(fillNode); setCableDevice(device); + //need to set the filtered components so that components on other devices do not show up as options in interaction settings + let filtered: Component[] = [] + components.forEach((component, key) => { + if(componentDevices.get(key) === devId){ + filtered.push(component) + } + }) + setFilteredComponents(filtered) setOpenNodeDialog(true); } } @@ -1556,7 +1560,7 @@ export default function BinVisualizer(props: Props) { let device = componentDevices.get(fan.key) if(fanComp && device){ return ( - + ) } })} @@ -2075,7 +2079,7 @@ export default function BinVisualizer(props: Props) { {selectedCable && cableDevice && ( Date: Fri, 13 Mar 2026 12:29:06 -0600 Subject: [PATCH 10/12] implemented live web socket on the devices page for status readings --- src/pages/Devices.tsx | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/pages/Devices.tsx b/src/pages/Devices.tsx index 80a3f0d..d24c829 100644 --- a/src/pages/Devices.tsx +++ b/src/pages/Devices.tsx @@ -6,7 +6,9 @@ import ResponsiveTable, { Column } from "common/ResponsiveTable"; import ProvisionDevice from "device/ProvisionDevice"; import { pond } from "protobuf-ts/pond"; import { useDeviceAPI, useGlobalState, useGroupAPI, useTeamAPI } from "providers"; -import { useCallback, useEffect, useState } from "react"; +import { useHTTP } from "hooks"; +import { useDeviceStatusStreams } from "hooks/useDeviceStatusStreams"; +import { useCallback, useEffect, useMemo, useState } from "react"; import PageContainer from "./PageContainer"; import { useMobile } from "hooks"; import { useLocation, useNavigate, useParams } from "react-router-dom"; @@ -191,6 +193,26 @@ export default function Devices() { const [preferencesAnchor, setPreferencesAnchor] = useState(null) const [{ as, team }] = useGlobalState() + const { token } = useHTTP() + + const deviceIds = useMemo(() => devices.map((d) => d.id().toString()), [devices]) + const handleStatusUpdate = useCallback((deviceId: string, status: pond.DeviceStatus) => { + setDevices((prev) => + prev.map((d) => { + if (d.id().toString() !== deviceId) return d + const updated = Device.clone(d) + updated.status = status + return updated + }) + ) + }, []) + + useDeviceStatusStreams({ + deviceIds, + onStatusUpdate: handleStatusUpdate, + token, + enabled: devices.length > 0 && !!token, + }) const updateGroups = (newGroups: Group[]) => { setGroups(newGroups); @@ -242,7 +264,7 @@ export default function Devices() { const now = new Date(); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); if (compareTimestamps(device.status.sen5x?.timestamp, oneDayAgo.toISOString()) > 0) { - // console.log("timestamp success " + device.settings?.deviceId) + // console.log("timestamp success " + device.settings?.deviceId + ": " + device.status.sen5x?.timestamp) return true } else { // console.log("timestamp fail " + device.settings?.deviceId) From 1e2003a5414c78ab175d2293dfda90d654e8774c Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 13 Mar 2026 13:20:51 -0600 Subject: [PATCH 11/12] added optional timestamp --- src/common/RelativeTimestamp.tsx | 38 ++++++++ src/common/StatusDust.tsx | 12 ++- src/common/StatusGas.tsx | 14 ++- src/common/StatusPlenum.tsx | 11 ++- src/common/StatusSen5x.tsx | 11 ++- src/hooks/useDeviceStatusStreams.ts | 138 ++++++++++++++++++++++++++++ src/pages/Devices.tsx | 33 +++++-- 7 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 src/common/RelativeTimestamp.tsx create mode 100644 src/hooks/useDeviceStatusStreams.ts diff --git a/src/common/RelativeTimestamp.tsx b/src/common/RelativeTimestamp.tsx new file mode 100644 index 0000000..811ded6 --- /dev/null +++ b/src/common/RelativeTimestamp.tsx @@ -0,0 +1,38 @@ +import { Typography } from "@mui/material"; +import moment from "moment"; +import { useEffect, useState } from "react"; + +interface Props { + /** RFC3339 timestamp string */ + timestamp: string; +} + +/** + * Displays a timestamp as relative time (e.g. "a minute ago", "5 seconds ago"). + * Uses a subtle, muted typography style. + * Refreshes every 60 seconds to keep the relative text current. + */ +export default function RelativeTimestamp({ timestamp }: Props) { + const [, setTick] = useState(0); + + useEffect(() => { + const interval = setInterval(() => setTick((t) => t + 1), 60000); + return () => clearInterval(interval); + }, []); + + const date = moment(timestamp); + if (!date.isValid()) return null; + + return ( + + {date.fromNow()} + + ); +} diff --git a/src/common/StatusDust.tsx b/src/common/StatusDust.tsx index a7e8e3a..4963269 100644 --- a/src/common/StatusDust.tsx +++ b/src/common/StatusDust.tsx @@ -3,10 +3,13 @@ import { green } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; import { Device } from "models"; import PulseBox from "./PulseBox"; +import RelativeTimestamp from "./RelativeTimestamp"; import { or } from "utils"; import { useEffect, useState } from "react"; + interface Props { - device: Device + device: Device; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -48,7 +51,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusDust(props: Props) { - const { device } = props; + const { device, showTimestamp } = props; const classes = useStyles() const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); @@ -165,6 +168,11 @@ export default function StatusDust(props: Props) { + {showTimestamp && device.status.sen5x?.timestamp && ( + + + + )} diff --git a/src/common/StatusGas.tsx b/src/common/StatusGas.tsx index dcf7ed3..4571278 100644 --- a/src/common/StatusGas.tsx +++ b/src/common/StatusGas.tsx @@ -3,13 +3,14 @@ import { blue, deepOrange, teal } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; import { Device } from "models"; import PulseBox from "./PulseBox"; +import RelativeTimestamp from "./RelativeTimestamp"; import { or } from "utils"; import { useEffect, useState } from "react"; interface Props { - device: Device - // noDust?: boolean; - gasType: "co2" | "co" | "no2" | "o2" | "all" + device: Device; + gasType: "co2" | "co" | "no2" | "o2" | "all"; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -61,7 +62,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusGas(props: Props) { - const { device, gasType } = props; + const { device, gasType, showTimestamp } = props; const classes = useStyles() // console.log(noDust) let colors: string[] = [] @@ -203,6 +204,11 @@ export default function StatusGas(props: Props) { Volt: {device.status[gas]?.millivolts.toFixed(1)}mV + {showTimestamp && device.status[gas]?.timestamp && ( + + + + )} diff --git a/src/common/StatusPlenum.tsx b/src/common/StatusPlenum.tsx index fe2b00d..250660a 100644 --- a/src/common/StatusPlenum.tsx +++ b/src/common/StatusPlenum.tsx @@ -3,11 +3,13 @@ import { blue, orange } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; import { Device } from "models"; import PulseBox from "./PulseBox"; +import RelativeTimestamp from "./RelativeTimestamp"; import { or } from "utils"; import { useEffect, useState } from "react"; interface Props { - device: Device + device: Device; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -25,7 +27,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusPlenum(props: Props) { - const { device } = props; + const { device, showTimestamp } = props; const classes = useStyles() const colors = or(device.status.plenum?.overlays.map(overlay => overlay.color), []); @@ -117,6 +119,11 @@ export default function StatusPlenum(props: Props) { {device.status.plenum?.humidity.toFixed(1)}% + {showTimestamp && device.status.plenum?.timestamp && ( + + + + )} diff --git a/src/common/StatusSen5x.tsx b/src/common/StatusSen5x.tsx index fad0ecb..533fde2 100644 --- a/src/common/StatusSen5x.tsx +++ b/src/common/StatusSen5x.tsx @@ -3,12 +3,14 @@ import { blue, green, orange } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; import { Device } from "models"; import PulseBox from "./PulseBox"; +import RelativeTimestamp from "./RelativeTimestamp"; import { or } from "utils"; import { useEffect, useState } from "react"; interface Props { - device: Device + device: Device; noDust?: boolean; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -50,7 +52,7 @@ const useStyles = makeStyles((theme: Theme) => { }) export default function StatusSen5x(props: Props) { - const { device, noDust } = props; + const { device, noDust, showTimestamp } = props; const classes = useStyles() // console.log(noDust) const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []); @@ -200,6 +202,11 @@ export default function StatusSen5x(props: Props) { } + {showTimestamp && device.status.sen5x?.timestamp && ( + + + + )} diff --git a/src/hooks/useDeviceStatusStreams.ts b/src/hooks/useDeviceStatusStreams.ts new file mode 100644 index 0000000..24ca8bb --- /dev/null +++ b/src/hooks/useDeviceStatusStreams.ts @@ -0,0 +1,138 @@ +import { pond } from "protobuf-ts/pond"; +import { useEffect, useRef, useCallback, useMemo } from "react"; + +/** + * Derives the WebSocket base URL from VITE_APP_API_URL. + * Matches the logic in useWebSocket.ts. + */ +function getWsBaseUrl(): string { + const apiUrl = import.meta.env.VITE_APP_API_URL; + if (apiUrl) { + return apiUrl.replace(/^http/, "ws"); + } + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}`; +} + +export interface UseDeviceStatusStreamsOptions { + /** Device IDs to stream status for (e.g. from the visible devices list) */ + deviceIds: string[]; + /** Called when a device status update is received */ + onStatusUpdate: (deviceId: string, status: pond.DeviceStatus) => void; + /** Auth token passed as ?token= query param */ + token?: string; + /** Whether the connections should be active. Default true. */ + enabled?: boolean; +} + +/** + * Subscribes to live device status streams for multiple devices. + * Uses the /v1/live/devices/:device/status endpoint per device. + * Readings (plenum, sen5x, co, co2, no2, o2, etc.) stream in real time. + */ +export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) { + const { deviceIds, onStatusUpdate, token, enabled = true } = options; + + const onStatusUpdateRef = useRef(onStatusUpdate); + onStatusUpdateRef.current = onStatusUpdate; + + const wsMapRef = useRef>(new Map()); + const retriesRef = useRef>(new Map()); + const reconnectTimeoutsRef = useRef>>(new Map()); + + const connect = useCallback( + (deviceId: string) => { + if (!token || !deviceId) return; + + const params = new URLSearchParams(); + params.set("token", token); + + const path = `/live/devices/${deviceId}/status`; + const url = `${getWsBaseUrl()}${path}?${params.toString()}`; + const ws = new WebSocket(url); + wsMapRef.current.set(deviceId, ws); + + ws.onopen = () => { + console.debug(`[ws] connected: ${path}`); + retriesRef.current.set(deviceId, 0); + }; + + ws.onmessage = (event) => { + try { + const raw = JSON.parse(event.data); + const status = pond.DeviceStatus.fromObject(raw ?? {}); + onStatusUpdateRef.current(deviceId, status); + } catch (err) { + console.warn(`[ws] failed to parse device status for ${deviceId}:`, err); + } + }; + + ws.onerror = () => { + console.warn(`[ws] error on ${path}`); + }; + + ws.onclose = (event) => { + wsMapRef.current.delete(deviceId); + + // Don't reconnect on clean close or auth rejection + if (event.code === 1000 || event.code === 4001 || event.code === 4003) { + return; + } + + const retries = retriesRef.current.get(deviceId) ?? 0; + const delay = Math.min(1000 * Math.pow(2, retries), 30000); + retriesRef.current.set(deviceId, retries + 1); + console.debug(`[ws] reconnecting ${path} in ${delay}ms...`); + + const timeout = setTimeout(() => connect(deviceId), delay); + reconnectTimeoutsRef.current.set(deviceId, timeout); + }; + }, + [token] + ); + + // Normalize for stable comparison: same set of IDs = same string regardless of order + const deviceIdsKey = useMemo( + () => [...new Set(deviceIds)].sort().join(","), + [deviceIds.join(",")] + ); + + useEffect(() => { + if (!enabled || !token || deviceIds.length === 0) { + wsMapRef.current.forEach((ws) => ws.close(1000, "disabled")); + wsMapRef.current.clear(); + reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t)); + reconnectTimeoutsRef.current.clear(); + return; + } + + const currentIds = new Set(deviceIds); + + // Connect to new devices + deviceIds.forEach((id) => { + if (!wsMapRef.current.has(id)) { + connect(id); + } + }); + + // Disconnect from devices no longer in the list + wsMapRef.current.forEach((ws, id) => { + if (!currentIds.has(id)) { + ws.close(1000, "device removed"); + wsMapRef.current.delete(id); + const t = reconnectTimeoutsRef.current.get(id); + if (t) { + clearTimeout(t); + reconnectTimeoutsRef.current.delete(id); + } + } + }); + + return () => { + wsMapRef.current.forEach((ws) => ws.close(1000, "cleanup")); + wsMapRef.current.clear(); + reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t)); + reconnectTimeoutsRef.current.clear(); + }; + }, [enabled, token, deviceIdsKey, deviceIds.length, connect]); +} diff --git a/src/pages/Devices.tsx b/src/pages/Devices.tsx index d24c829..d166134 100644 --- a/src/pages/Devices.tsx +++ b/src/pages/Devices.tsx @@ -190,6 +190,15 @@ export default function Devices() { localStorage.setItem('groupGas', groupGas.toString()); }, [groupGas]); + const [showTimestamp, setShowTimestamp] = useState(() => { + const stored = localStorage.getItem('showTimestamp'); + return stored !== null ? stored === 'true' : false; + }); + + useEffect(() => { + localStorage.setItem('showTimestamp', showTimestamp.toString()); + }, [showTimestamp]); + const [preferencesAnchor, setPreferencesAnchor] = useState(null) const [{ as, team }] = useGlobalState() @@ -646,7 +655,7 @@ export default function Devices() { if (device.status.plenum?.temperature) { return ( - + ) } else { @@ -666,7 +675,7 @@ export default function Devices() { if (device.status.sen5x?.temperature) { return ( - + ) } else { @@ -682,7 +691,7 @@ export default function Devices() { if (device.status.sen5x?.temperature) { return ( - + ) } else { @@ -700,7 +709,7 @@ export default function Devices() { if (device.status.co) { return ( - + ) } else { @@ -718,7 +727,7 @@ export default function Devices() { if (device.status.co2) { return ( - + ) } else { @@ -736,7 +745,7 @@ export default function Devices() { if (device.status.no2) { return ( - + ) } else { @@ -754,7 +763,7 @@ export default function Devices() { if (device.status.o2) { return ( - + ) } else { @@ -894,6 +903,16 @@ export default function Devices() { + setShowTimestamp(!showTimestamp)} + sx={{ marginLeft: -0.75 }} + dense + > + + + + + ) } From 9e3ce3c00dd4c55185ee12f3ce958c63de82bd4f Mon Sep 17 00:00:00 2001 From: Carter Date: Fri, 13 Mar 2026 13:43:05 -0600 Subject: [PATCH 12/12] added h2s lel and o2 to the device status readings on the devices dashboard --- src/common/StatusGas.tsx | 45 +++++++++++---------- src/pages/Devices.tsx | 86 +++++++++++++++++++++++++++++++++++----- 2 files changed, 101 insertions(+), 30 deletions(-) diff --git a/src/common/StatusGas.tsx b/src/common/StatusGas.tsx index 4571278..b220d96 100644 --- a/src/common/StatusGas.tsx +++ b/src/common/StatusGas.tsx @@ -9,7 +9,7 @@ import { useEffect, useState } from "react"; interface Props { device: Device; - gasType: "co2" | "co" | "no2" | "o2" | "all"; + gasType: "co2" | "co" | "no2" | "o2" | "lel" | "h2s" | "all"; showTimestamp?: boolean; } @@ -24,7 +24,7 @@ const useStyles = makeStyles((theme: Theme) => { marginRight: "auto", margin: theme.spacing(1), width: theme.spacing(14), - height: theme.spacing(6), + minHeight: theme.spacing(6), }, boxTall: { display: "inline-block", @@ -34,7 +34,7 @@ const useStyles = makeStyles((theme: Theme) => { marginRight: "auto", margin: theme.spacing(1), width: theme.spacing(14), - height: theme.spacing(8.5), + minHeight: theme.spacing(8.5), }, carouselContainer: { position: 'relative', @@ -68,23 +68,27 @@ export default function StatusGas(props: Props) { let colors: string[] = [] let messages: string[] = [] if (gasType === "all") { - colors = or(device.status.o2?.overlays.map(overlay => overlay.color), []); - colors.push(...or(device.status.co2?.overlays.map(overlay => overlay.color), [])); - colors.push(...or(device.status.no2?.overlays.map(overlay => overlay.color), [])); - colors.push(...or(device.status.co?.overlays.map(overlay => overlay.color), [])); - messages = or(device.status.o2?.overlays.map(overlay => overlay.message), []); - messages.push(...or(device.status.co2?.overlays.map(overlay => overlay.message), [])); - messages.push(...or(device.status.no2?.overlays.map(overlay => overlay.message), [])); - messages.push(...or(device.status.co?.overlays.map(overlay => overlay.message), [])); + colors = or(device.status.o2?.overlays?.map(overlay => overlay.color), []); + colors.push(...or(device.status.co2?.overlays?.map(overlay => overlay.color), [])); + colors.push(...or(device.status.no2?.overlays?.map(overlay => overlay.color), [])); + colors.push(...or(device.status.co?.overlays?.map(overlay => overlay.color), [])); + colors.push(...or(device.status.lel?.overlays?.map(overlay => overlay.color), [])); + colors.push(...or(device.status.h2s?.overlays?.map(overlay => overlay.color), [])); + messages = or(device.status.o2?.overlays?.map(overlay => overlay.message), []); + messages.push(...or(device.status.co2?.overlays?.map(overlay => overlay.message), [])); + messages.push(...or(device.status.no2?.overlays?.map(overlay => overlay.message), [])); + messages.push(...or(device.status.co?.overlays?.map(overlay => overlay.message), [])); + messages.push(...or(device.status.lel?.overlays?.map(overlay => overlay.message), [])); + messages.push(...or(device.status.h2s?.overlays?.map(overlay => overlay.message), [])); } else { - colors = or(device.status[gasType]?.overlays.map(overlay => overlay.color), []); - messages = or(device.status[gasType]?.overlays.map(overlay => overlay.message), []); + colors = or(device.status[gasType]?.overlays?.map(overlay => overlay.color), []); + messages = or(device.status[gasType]?.overlays?.map(overlay => overlay.message), []); } const [color, setColor] = useState(colors.length > 0 ? colors[0] : ""); const [, setColorIndex] = useState(0) - const gasTypes: ("o2" | "no2" | "co2" | "co")[] = ["o2", "no2", "co2", "co"] + const gasTypes: ("o2" | "no2" | "co2" | "co" | "lel" | "h2s")[] = ["o2", "no2", "co2", "co", "lel", "h2s"] useEffect(() => { const interval = setInterval(() => { @@ -107,8 +111,7 @@ export default function StatusGas(props: Props) { return } const interval = setInterval(() => { - if (gasType === "all") setCurrentIndex((prevIndex) => (prevIndex + 1) % 4); - else setCurrentIndex((prevIndex) => (prevIndex + 1) % 4); + if (gasType === "all") setCurrentIndex((prevIndex) => (prevIndex + 1) % gasTypes.length); }, 6000); return () => clearInterval(interval); // Cleanup on unmount @@ -189,14 +192,14 @@ export default function StatusGas(props: Props) { } - {gas !== "o2" ? - - Gas: {device.status[gas]?.ppm.toFixed(1)}ppm - - : + {(gas === "o2" || gas === "lel") ? Gas: {(device.status[gas]?.ppm!/10000).toFixed(1)}% + : + + Gas: {device.status[gas]?.ppm.toFixed(1)}ppm + } diff --git a/src/pages/Devices.tsx b/src/pages/Devices.tsx index d166134..08fb013 100644 --- a/src/pages/Devices.tsx +++ b/src/pages/Devices.tsx @@ -143,6 +143,8 @@ export default function Devices() { const [hasCo2, setHasCo2] = useState(false) const [hasNo2, setHasNo2] = useState(false) const [hasO2, setHasO2] = useState(false) + const [hasLel, setHasLel] = useState(false) + const [hasH2S, setHasH2S] = useState(false) const [groupsLoading, setGroupsLoading] = useState(false) const [groups, setGroups] = useState([]); @@ -296,16 +298,34 @@ export default function Devices() { const doesDeviceHaveO2 = (device: pond.Device) => { if (device.status?.o2?.ppm && device.status?.o2?.timestamp) { - if (device.status.sen5x?.timestamp) { - const now = new Date(); - const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); - if (compareTimestamps(device.status.o2?.timestamp, oneDayAgo.toISOString()) > 0) { - return true - } - } else { - return false + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + if (compareTimestamps(device.status.o2?.timestamp, oneDayAgo.toISOString()) > 0) { + return true } - } + } + return false + } + + const doesDeviceHaveLel = (device: pond.Device) => { + if (device.status?.lel?.ppm && device.status?.lel?.timestamp) { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + if (compareTimestamps(device.status.lel?.timestamp, oneDayAgo.toISOString()) > 0) { + return true + } + } + return false + } + + const doesDeviceHaveH2S = (device: pond.Device) => { + if (device.status?.h2s?.ppm && device.status?.h2s?.timestamp) { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + if (compareTimestamps(device.status.h2s?.timestamp, oneDayAgo.toISOString()) > 0) { + return true + } + } return false } @@ -486,6 +506,8 @@ export default function Devices() { let hasCo = false let hasCo2 = false let hasNo2 = false + let hasLel = false + let hasH2S = false resp.data.devices.forEach(device => { setHasPlenums(doesDeviceHavePlenum(device)) if (doesDeviceHavePlenum(device)) hasPlenum = true @@ -495,6 +517,8 @@ export default function Devices() { if (doesDeviceHaveCo(device)) hasCo = true if (doesDeviceHaveCo2(device)) hasCo2 = true if (doesDeviceHaveNo2(device)) hasNo2 = true + if (doesDeviceHaveLel(device)) hasLel = true + if (doesDeviceHaveH2S(device)) hasH2S = true newDevices.push(Device.create(device)) }) setHasPlenums(hasPlenum) @@ -503,6 +527,8 @@ export default function Devices() { setHasCo(hasCo) setHasCo2(hasCo2) setHasNo2(hasNo2) + setHasLel(hasLel) + setHasH2S(hasH2S) setDevices(newDevices) setTotal(resp.data.total) }).finally(() => { @@ -774,6 +800,42 @@ export default function Devices() { } }) } + if (hasLel && !groupGas) { + columns.push({ + title: "LEL", + render: (device: Device) => { + if (device.status.lel) { + return ( + + + + ) + } else { + return ( + <> + ) + } + } + }) + } + if (hasH2S && !groupGas) { + columns.push({ + title: "H2S", + render: (device: Device) => { + if (device.status.h2s) { + return ( + + + + ) + } else { + return ( + <> + ) + } + } + }) + } return columns } @@ -1000,6 +1062,8 @@ export default function Devices() { let hasCo = false let hasCo2 = false let hasNo2 = false + let hasLel = false + let hasH2S = false resp.data.devices.forEach(device => { setHasPlenums(doesDeviceHavePlenum(device)) if (doesDeviceHavePlenum(device)) hasPlenum = true @@ -1009,6 +1073,8 @@ export default function Devices() { if (doesDeviceHaveCo(device)) hasCo = true if (doesDeviceHaveCo2(device)) hasCo2 = true if (doesDeviceHaveNo2(device)) hasNo2 = true + if (doesDeviceHaveLel(device)) hasLel = true + if (doesDeviceHaveH2S(device)) hasH2S = true newDevices.push(Device.create(device)) }) setHasPlenums(hasPlenum) @@ -1017,6 +1083,8 @@ export default function Devices() { setHasCo(hasCo) setHasCo2(hasCo2) setHasNo2(hasNo2) + setHasLel(hasLel) + setHasH2S(hasH2S) setDevices(currentRows.concat(newDevices)) }).catch(_err => {