merged device dashboard status stuff with websockets
This commit is contained in:
commit
55ae922e09
17 changed files with 738 additions and 162 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
168
src/bin/BinControllerDisplay.tsx
Normal file
168
src/bin/BinControllerDisplay.tsx
Normal file
|
|
@ -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 <ObjectHeaterIcon />
|
||||
}else{
|
||||
return <AerationFanIcon />
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<ResponsiveDialog open={openDialog} onClose={() => {setOpenDialog(false)}}>
|
||||
<DialogTitle>Set Controller State</DialogTitle>
|
||||
<DialogContent>
|
||||
This Action will change the output state of the controller
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={()=>{setOpenDialog(false)}}>Cancel</Button>
|
||||
<Button onClick={()=>{setController()}} variant="contained" color="primary">Confirm</Button>
|
||||
</DialogActions>
|
||||
</ResponsiveDialog>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<React.Fragment>
|
||||
{controllerDialog()}
|
||||
<Box className={classes.controllerDisplay} key={component.key()} marginTop={1} marginBottom={1}>
|
||||
<Box display="flex" marginY="auto">
|
||||
<Box margin="auto">
|
||||
{controllerIcon()}
|
||||
</Box>
|
||||
<Typography
|
||||
align="center"
|
||||
style={{
|
||||
marginBottom: "auto",
|
||||
marginTop: "auto",
|
||||
paddingLeft: 5,
|
||||
fontSize: isMobile ? "0.85rem" : "1.0rem",
|
||||
fontWeight: 650
|
||||
}}>
|
||||
{component.name()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box display="flex">
|
||||
<ButtonGroup
|
||||
buttons={[
|
||||
{
|
||||
title: "Auto",
|
||||
function: () => {
|
||||
setNewOutputState(0)
|
||||
setOpenDialog(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "On",
|
||||
function: () => {
|
||||
setNewOutputState(1)
|
||||
setOpenDialog(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Off",
|
||||
function: () => {
|
||||
setNewOutputState(2)
|
||||
setOpenDialog(true)
|
||||
}
|
||||
}
|
||||
]}
|
||||
toggledButtons={[controllerState]}
|
||||
toggle
|
||||
/>
|
||||
<Typography
|
||||
style={{
|
||||
minWidth: 30,
|
||||
marginLeft: "10px",
|
||||
marginBottom: "auto",
|
||||
marginTop: "auto",
|
||||
fontSize: isMobile ? "0.85rem" : "1.0rem",
|
||||
fontWeight: 650,
|
||||
color: componentOn() ? "green" : "orange"
|
||||
}}>
|
||||
{componentOn() ? "ON" : "OFF"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -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<string, Component>;
|
||||
componentDevices?: Map<string, number>;
|
||||
componentDevices: Map<string, number>;
|
||||
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<pond.GrainSettings>()
|
||||
|
||||
const [filteredComponents, setFilteredComponents] = useState<Component[]>([])
|
||||
|
||||
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
|
||||
</Typography>
|
||||
<Box className={classes.displayBox}>
|
||||
{bin.status.fans.map(fan => (
|
||||
<Box className={classes.controllerDisplay} key={fan.key}>
|
||||
<Box display="flex">
|
||||
<AerationFanIcon />
|
||||
<Typography
|
||||
align="center"
|
||||
style={{
|
||||
paddingLeft: 5,
|
||||
fontSize: isMobile ? "0.85rem" : "1.0rem",
|
||||
fontWeight: 650
|
||||
}}>
|
||||
{fan.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
style={{
|
||||
fontSize: isMobile ? "0.85rem" : "1.0rem",
|
||||
fontWeight: 650,
|
||||
color: fan.state ? "green" : "orange"
|
||||
}}>
|
||||
{fan.state ? "ON" : "OFF"}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{bin.status.heaters.map(heater => (
|
||||
<Box className={classes.controllerDisplay} key={heater.key}>
|
||||
<Box display="flex">
|
||||
<ObjectHeaterIcon />
|
||||
<Typography
|
||||
align="center"
|
||||
style={{
|
||||
paddingLeft: 5,
|
||||
fontSize: isMobile ? "0.85rem" : "1.0rem",
|
||||
fontWeight: 650
|
||||
}}>
|
||||
{heater.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
style={{
|
||||
fontSize: isMobile ? "0.85rem" : "1.0rem",
|
||||
fontWeight: 650,
|
||||
color: heater.state ? "green" : "orange"
|
||||
}}>
|
||||
{heater.state ? "ON" : "OFF"}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{bin.status.fans.map(fan => {
|
||||
let fanComp = components.get(fan.key)
|
||||
let device = componentDevices.get(fan.key)
|
||||
if(fanComp && device){
|
||||
return (
|
||||
<BinControllerDisplay key={fanComp.key()} component={fanComp} deviceID={device} currentState={fan.state}/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
{bin.status.heaters.map(heater => {
|
||||
let heaterComp = components.get(heater.key)
|
||||
let device = componentDevices.get(heater.key)
|
||||
if(heaterComp && device){
|
||||
return (
|
||||
<BinControllerDisplay key={heaterComp.key()} component={heaterComp} deviceID={device} currentState={heater.state}/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -2098,7 +2079,7 @@ export default function BinVisualizer(props: Props) {
|
|||
{selectedCable && cableDevice && (
|
||||
<GrainNodeInteractions
|
||||
binKey={bin.key()}
|
||||
interactionComponents={Array.from(components.values())}
|
||||
interactionComponents={filteredComponents}
|
||||
grain={bin.grain()}
|
||||
cable={selectedCable}
|
||||
open={openNodeDialog}
|
||||
|
|
|
|||
|
|
@ -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<number[]>([])
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
38
src/common/RelativeTimestamp.tsx
Normal file
38
src/common/RelativeTimestamp.tsx
Normal file
|
|
@ -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 (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
opacity: 0.7,
|
||||
fontSize: "0.7rem",
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{date.fromNow()}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
|||
</Typography>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
{showTimestamp && device.status.sen5x?.timestamp && (
|
||||
<Grid2>
|
||||
<RelativeTimestamp timestamp={device.status.sen5x.timestamp} />
|
||||
</Grid2>
|
||||
)}
|
||||
</Grid2>
|
||||
</PulseBox>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
}
|
||||
<Grid2 container direction="column">
|
||||
<Grid2>
|
||||
{gas !== "o2" ?
|
||||
<Typography variant="body2" style={{ color: teal[500]}}>
|
||||
Gas: {device.status[gas]?.ppm.toFixed(1)}ppm
|
||||
</Typography>
|
||||
:
|
||||
{(gas === "o2" || gas === "lel") ?
|
||||
<Typography variant="body2" style={{ color: blue[500]}}>
|
||||
Gas: {(device.status[gas]?.ppm!/10000).toFixed(1)}%
|
||||
</Typography>
|
||||
:
|
||||
<Typography variant="body2" style={{ color: teal[500]}}>
|
||||
Gas: {device.status[gas]?.ppm.toFixed(1)}ppm
|
||||
</Typography>
|
||||
}
|
||||
</Grid2>
|
||||
<Grid2>
|
||||
|
|
@ -203,6 +207,11 @@ export default function StatusGas(props: Props) {
|
|||
Volt: {device.status[gas]?.millivolts.toFixed(1)}mV
|
||||
</Typography>
|
||||
</Grid2>
|
||||
{showTimestamp && device.status[gas]?.timestamp && (
|
||||
<Grid2>
|
||||
<RelativeTimestamp timestamp={device.status[gas].timestamp} />
|
||||
</Grid2>
|
||||
)}
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</PulseBox>
|
||||
|
|
|
|||
|
|
@ -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)}%
|
||||
</Typography>
|
||||
</Grid2>
|
||||
{showTimestamp && device.status.plenum?.timestamp && (
|
||||
<Grid2>
|
||||
<RelativeTimestamp timestamp={device.status.plenum.timestamp} />
|
||||
</Grid2>
|
||||
)}
|
||||
</Grid2>
|
||||
</PulseBox>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</Typography>
|
||||
</Grid2>
|
||||
</Grid2>}
|
||||
{showTimestamp && device.status.sen5x?.timestamp && (
|
||||
<Grid2>
|
||||
<RelativeTimestamp timestamp={device.status.sen5x.timestamp} />
|
||||
</Grid2>
|
||||
)}
|
||||
</Grid2>
|
||||
</PulseBox>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<StatusChip status="pending" />
|
||||
</Grid>
|
||||
)}
|
||||
{device.settings.needsBusControl && (
|
||||
<Grid>
|
||||
<Tooltip title="Device needs the Bus Control component to power the I2C port">
|
||||
<Chip
|
||||
variant="outlined"
|
||||
label={"I2C Bus"}
|
||||
icon={<DeviceHub />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
{<DeviceTags device={device} disableAdd={disableAddTag} />}
|
||||
</Grid>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</Grid>
|
||||
)}
|
||||
{user.hasAdmin() && (
|
||||
<Grid size={{ xs: 12, sm: 12 }}>
|
||||
<React.Fragment>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
|
|
@ -516,6 +523,23 @@ export default function DeviceSettings(props: Props) {
|
|||
labelPlacement="end"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={deviceForm.settings.needsBusControl}
|
||||
onChange={toggleBusRequired}
|
||||
name="needsI2CBus"
|
||||
aria-label="needsI2CBus"
|
||||
color="secondary"
|
||||
/>
|
||||
}
|
||||
disabled={!canEdit}
|
||||
label="I2C Bus Required"
|
||||
labelPlacement="end"
|
||||
/>
|
||||
</Grid>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Grid>
|
||||
</React.Fragment>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
|
|
|||
|
|
@ -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 <CheckCircleOutline sx={{color: "green"}}/>
|
||||
return (
|
||||
<Box display="flex">
|
||||
<CheckCircleOutline sx={{color: "green"}}/>
|
||||
<Typography paddingLeft={2}>PCA in threshold</Typography>
|
||||
</Box>
|
||||
)
|
||||
case pond.PCAState.PCA_STATE_OUT_BOUNDS:
|
||||
return <ErrorOutline sx={{color: "red"}} />
|
||||
return (
|
||||
<Box display="flex">
|
||||
<ErrorOutline sx={{color: "red"}} />
|
||||
<Typography paddingLeft={2}>PCA out of threshold</Typography>
|
||||
</Box>
|
||||
)
|
||||
case pond.PCAState.PCA_STATE_OFF:
|
||||
return <DoNotDisturb />
|
||||
return (
|
||||
<Box display="flex">
|
||||
<DoNotDisturb />
|
||||
<Typography paddingLeft={2}>PCA off</Typography>
|
||||
</Box>
|
||||
)
|
||||
default:
|
||||
return <HelpOutlineOutlined />
|
||||
return (
|
||||
<Box display="flex">
|
||||
<HelpOutlineOutlined />
|
||||
<Typography paddingLeft={2}>PCA device not found</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
|||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Device State",
|
||||
// cellStyle: {width: 100000},
|
||||
// sortKey: "state",
|
||||
render: gate => {
|
||||
const deviceStateHelper = getDeviceStateHelper(gate.status.pcaDeviceState);
|
||||
return (
|
||||
<Box className={classes.cellContainer}>
|
||||
<Tooltip title={deviceStateHelper.description}>{deviceStateHelper.icon}</Tooltip>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
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(
|
||||
<Box>
|
||||
<Box padding={2} display="flex" flexDirection="row" justifyContent="space-between">
|
||||
|
|
@ -334,6 +375,12 @@ export default function GateList(props: Props) {
|
|||
<Typography>Delta Temperature:</Typography>
|
||||
{conditionDisplay(gate)}
|
||||
</Box>
|
||||
<Box padding={2} display="flex" flexDirection="row" justifyContent="space-between">
|
||||
<Typography>Device State:</Typography>
|
||||
<Box>
|
||||
{deviceStateHelper.icon}
|
||||
</Box>
|
||||
</Box>
|
||||
{/* taking these out for now because we are not sure if the customer wants them */}
|
||||
{/* <Box padding={2} display="flex" flexDirection="row" justifyContent="space-between">
|
||||
<Typography>Estimated Temp:</Typography>
|
||||
|
|
|
|||
138
src/hooks/useDeviceStatusStreams.ts
Normal file
138
src/hooks/useDeviceStatusStreams.ts
Normal file
|
|
@ -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<Map<string, WebSocket>>(new Map());
|
||||
const retriesRef = useRef<Map<string, number>>(new Map());
|
||||
const reconnectTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(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]);
|
||||
}
|
||||
|
|
@ -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<Group[]>([]);
|
||||
|
|
@ -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<any>(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 (
|
||||
<Box sx={{ marginLeft: 1 }}>
|
||||
<StatusPlenum device={device} />
|
||||
<StatusPlenum device={device} showTimestamp={showTimestamp} />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
|
|
@ -644,7 +701,7 @@ export default function Devices() {
|
|||
if (device.status.sen5x?.temperature) {
|
||||
return (
|
||||
<Box sx={{ marginLeft: 1 }}>
|
||||
<StatusSen5x device={device} noDust={separateDust} />
|
||||
<StatusSen5x device={device} noDust={separateDust} showTimestamp={showTimestamp} />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
|
|
@ -660,7 +717,7 @@ export default function Devices() {
|
|||
if (device.status.sen5x?.temperature) {
|
||||
return (
|
||||
<Box sx={{ marginLeft: 1 }}>
|
||||
<StatusDust device={device} />
|
||||
<StatusDust device={device} showTimestamp={showTimestamp} />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
|
|
@ -678,7 +735,7 @@ export default function Devices() {
|
|||
if (device.status.co) {
|
||||
return (
|
||||
<Box sx={{ marginLeft: 1 }}>
|
||||
<StatusGas device={device} gasType={groupGas ? "all" : "co"}/>
|
||||
<StatusGas device={device} gasType={groupGas ? "all" : "co"} showTimestamp={showTimestamp} />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
|
|
@ -696,7 +753,7 @@ export default function Devices() {
|
|||
if (device.status.co2) {
|
||||
return (
|
||||
<Box sx={{ marginLeft: 1 }}>
|
||||
<StatusGas device={device} gasType={"co2"}/>
|
||||
<StatusGas device={device} gasType={"co2"} showTimestamp={showTimestamp} />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
|
|
@ -714,7 +771,7 @@ export default function Devices() {
|
|||
if (device.status.no2) {
|
||||
return (
|
||||
<Box sx={{ marginLeft: 1 }}>
|
||||
<StatusGas device={device} gasType={"no2"}/>
|
||||
<StatusGas device={device} gasType={"no2"} showTimestamp={showTimestamp} />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
|
|
@ -732,7 +789,43 @@ export default function Devices() {
|
|||
if (device.status.o2) {
|
||||
return (
|
||||
<Box sx={{ marginLeft: 1 }}>
|
||||
<StatusGas device={device} gasType={"o2"}/>
|
||||
<StatusGas device={device} gasType={"o2"} showTimestamp={showTimestamp} />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if (hasLel && !groupGas) {
|
||||
columns.push({
|
||||
title: "LEL",
|
||||
render: (device: Device) => {
|
||||
if (device.status.lel) {
|
||||
return (
|
||||
<Box sx={{ marginLeft: 1 }}>
|
||||
<StatusGas device={device} gasType={"lel"} showTimestamp={showTimestamp} />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if (hasH2S && !groupGas) {
|
||||
columns.push({
|
||||
title: "H2S",
|
||||
render: (device: Device) => {
|
||||
if (device.status.h2s) {
|
||||
return (
|
||||
<Box sx={{ marginLeft: 1 }}>
|
||||
<StatusGas device={device} gasType={"h2s"} showTimestamp={showTimestamp} />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
|
|
@ -872,6 +965,16 @@ export default function Devices() {
|
|||
</ListItemIcon>
|
||||
<ListItemText primary={"Group Gas"} />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setShowTimestamp(!showTimestamp)}
|
||||
sx={{ marginLeft: -0.75 }}
|
||||
dense
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Checkbox checked={showTimestamp} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={"Show Timestamp"} />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 => {
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue