diff --git a/package-lock.json b/package-lock.json index 8d82f84..486db5e 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#dev", "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#bcf085706791036841f7047a5b37f00c18e54574", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#40e15b5ed18b10c97125260a2d53a81d1eec398e", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/src/bin/BinControllerDisplay.tsx b/src/bin/BinControllerDisplay.tsx new file mode 100644 index 0000000..32cbd94 --- /dev/null +++ b/src/bin/BinControllerDisplay.tsx @@ -0,0 +1,168 @@ +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, useSnackbar } from "hooks"; +import ButtonGroup from "common/ButtonGroup"; +import ObjectHeaterIcon from "products/Construction/ObjectHeaterIcon"; +import ResponsiveDialog from "common/ResponsiveDialog"; + + +interface Props { + deviceID: number + component: Component + currentState?: boolean +} + +const useStyles = makeStyles(() => { + return ({ + controllerDisplay: { + display: "flex", + justifyContent: "space-between", + paddingLeft: 5, + paddingRight: 5 + }, + }) +}) + +export default function BinControllerDisplay(props: 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() + + 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 + 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 = () => { + return ( + {setOpenDialog(false)}}> + Set Controller State + + This Action will change the output state of the controller + + + + + + + ) + } + + const componentOn = () => { + 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 + } + } + } + } + }) + return state + } + } + + return ( + + {controllerDialog()} + + + + {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..2da6f78 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,8 @@ export default function BinVisualizer(props: Props) { const [modeChangeInProgress, setModeChangeInProgress] = useState(false) const [newGrainSettings, setNewGrainSettings] = useState() + const [filteredComponents, setFilteredComponents] = useState([]) + useEffect(() => { setModeTime(moment(bin.status.lastModeChange)); }, [bin.status.lastModeChange]); @@ -1413,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); } } @@ -1544,54 +1555,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 ( + + ) + } + })} ); @@ -2098,7 +2079,7 @@ export default function BinVisualizer(props: Props) { {selectedCable && cableDevice && ( { }) 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) } 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..b220d96 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" | "lel" | "h2s" | "all"; + showTimestamp?: boolean; } const useStyles = makeStyles((theme: Theme) => { @@ -23,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", @@ -33,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', @@ -61,29 +62,33 @@ 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[] = [] 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(() => { @@ -106,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 @@ -188,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 + } @@ -203,6 +207,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/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" + /> + + )} 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 23669f3..0303123 100644 --- a/src/gate/GateDeviceInteraction.tsx +++ b/src/gate/GateDeviceInteraction.tsx @@ -93,15 +93,14 @@ 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 ) { + console.log(component) setPressureComponent(component); setPressureSource( quack.ComponentID.create({ @@ -111,13 +110,13 @@ 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 @@ -129,13 +128,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 +162,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 +199,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 +218,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 +242,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 +271,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 +287,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 +388,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 +395,8 @@ export default function GateDeviceInteraction(props: Props) { console.error(err) }) }} - disabled={buttonDisabled()}> + disabled={buttonDisabled()} + > {adding ? "Adding Interaction" : "Create Interaction"} 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/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 80a3f0d..08fb013 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"; @@ -141,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([]); @@ -188,9 +192,38 @@ 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() + 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 +275,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) @@ -265,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 } @@ -455,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 @@ -464,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) @@ -472,6 +527,8 @@ export default function Devices() { setHasCo(hasCo) setHasCo2(hasCo2) setHasNo2(hasNo2) + setHasLel(hasLel) + setHasH2S(hasH2S) setDevices(newDevices) setTotal(resp.data.total) }).finally(() => { @@ -624,7 +681,7 @@ export default function Devices() { if (device.status.plenum?.temperature) { return ( - + ) } else { @@ -644,7 +701,7 @@ export default function Devices() { if (device.status.sen5x?.temperature) { return ( - + ) } else { @@ -660,7 +717,7 @@ export default function Devices() { if (device.status.sen5x?.temperature) { return ( - + ) } else { @@ -678,7 +735,7 @@ export default function Devices() { if (device.status.co) { return ( - + ) } else { @@ -696,7 +753,7 @@ export default function Devices() { if (device.status.co2) { return ( - + ) } else { @@ -714,7 +771,7 @@ export default function Devices() { if (device.status.no2) { return ( - + ) } else { @@ -732,7 +789,43 @@ export default function Devices() { if (device.status.o2) { return ( - + + + ) + } else { + return ( + <> + ) + } + } + }) + } + 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 { @@ -872,6 +965,16 @@ export default function Devices() { + setShowTimestamp(!showTimestamp)} + sx={{ marginLeft: -0.75 }} + dense + > + + + + + ) } @@ -959,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 @@ -968,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) @@ -976,6 +1083,8 @@ export default function Devices() { setHasCo(hasCo) setHasCo2(hasCo2) setHasNo2(hasNo2) + setHasLel(hasLel) + setHasH2S(hasH2S) setDevices(currentRows.concat(newDevices)) }).catch(_err => { diff --git a/src/pbHelpers/ComponentTypes/DragerGasDongle.ts b/src/pbHelpers/ComponentTypes/DragerGasDongle.ts index 951a29b..1a13bbf 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",