Merge branch 'staging_environment'
This commit is contained in:
commit
911b8a5b09
12 changed files with 602 additions and 122 deletions
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -11967,7 +11967,7 @@
|
||||||
},
|
},
|
||||||
"node_modules/protobuf-ts": {
|
"node_modules/protobuf-ts": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#1c00e059fe16126e85c50581786b7510759920d7",
|
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#e4a1e598240d504e3ccfe09d6f023c5865ab2c71",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"protobufjs": "^6.8.8"
|
"protobufjs": "^6.8.8"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -290,11 +290,7 @@ export default function BinVisualizer(props: Props) {
|
||||||
const [modeChangeInProgress, setModeChangeInProgress] = useState(false)
|
const [modeChangeInProgress, setModeChangeInProgress] = useState(false)
|
||||||
const [newGrainSettings, setNewGrainSettings] = useState<pond.GrainSettings>()
|
const [newGrainSettings, setNewGrainSettings] = useState<pond.GrainSettings>()
|
||||||
|
|
||||||
const componentAPI = useComponentAPI()
|
const [filteredComponents, setFilteredComponents] = useState<Component[]>([])
|
||||||
const [openControllerDIalog, setOpenControllerDialog] = useState(false)
|
|
||||||
const [controllerOutputStates, setControllerOutputStates] = useState<Map<string, number>>(new Map<string, number>())
|
|
||||||
const [selectedController, setSelectedController] = useState<Component>()
|
|
||||||
const [newOutputState, setNewOutputState] = useState()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setModeTime(moment(bin.status.lastModeChange));
|
setModeTime(moment(bin.status.lastModeChange));
|
||||||
|
|
@ -1420,13 +1416,21 @@ export default function BinVisualizer(props: Props) {
|
||||||
coldestNodeTemp={valueDisplay === "low" ? lowNodeConditions?.tempC : undefined}
|
coldestNodeTemp={valueDisplay === "low" ? lowNodeConditions?.tempC : undefined}
|
||||||
cableNodeClicked={(cable, fillNode) => {
|
cableNodeClicked={(cable, fillNode) => {
|
||||||
if (cable) {
|
if (cable) {
|
||||||
let devId = componentDevices?.get(cable.key());
|
let devId = componentDevices.get(cable.key());
|
||||||
if (devId) {
|
if (devId) {
|
||||||
let device = devMap.get(devId);
|
let device = devMap.get(devId);
|
||||||
if (device) {
|
if (device) {
|
||||||
setSelectedCable(cable);
|
setSelectedCable(cable);
|
||||||
setSelectedNode(fillNode);
|
setSelectedNode(fillNode);
|
||||||
setCableDevice(device);
|
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);
|
setOpenNodeDialog(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2075,7 +2079,7 @@ export default function BinVisualizer(props: Props) {
|
||||||
{selectedCable && cableDevice && (
|
{selectedCable && cableDevice && (
|
||||||
<GrainNodeInteractions
|
<GrainNodeInteractions
|
||||||
binKey={bin.key()}
|
binKey={bin.key()}
|
||||||
interactionComponents={Array.from(components.values())}
|
interactionComponents={filteredComponents}
|
||||||
grain={bin.grain()}
|
grain={bin.grain()}
|
||||||
cable={selectedCable}
|
cable={selectedCable}
|
||||||
open={openNodeDialog}
|
open={openNodeDialog}
|
||||||
|
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Box, Button, Card, Checkbox, CircularProgress, Collapse, darken, Divider, Grid2, IconButton, InputAdornment, ListItemButton, ListItemIcon, ListItemText, Menu, MenuItem, Paper, SxProps, Table, TableBody, TableCell, TableContainer, TableHead, TablePagination, TableRow, TextField, Theme, Typography } from "@mui/material";
|
import { Box, Button, Card, Checkbox, CircularProgress, Collapse, darken, Divider, Grid2, IconButton, InputAdornment, ListItemButton, ListItemIcon, ListItemText, Menu, MenuItem, Paper, SxProps, Table, TableBody, TableCell, TableContainer, TableHead, TablePagination, TableRow, TextField, Theme, Typography } from "@mui/material";
|
||||||
|
import { alpha } from "@mui/system/colorManipulator";
|
||||||
import { makeStyles } from "@mui/styles";
|
import { makeStyles } from "@mui/styles";
|
||||||
import { ArrowDownward, ArrowUpward, ChevronRight, Close, Search, ViewColumn } from "@mui/icons-material"
|
import { ArrowDownward, ArrowUpward, ChevronRight, Close, Search, ViewColumn } from "@mui/icons-material"
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useMobile } from "hooks";
|
import { useMobile } from "hooks";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { getThemeType } from "theme";
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) => {
|
const useStyles = makeStyles((theme: Theme) => {
|
||||||
//const isMobile = useMobile()
|
//const isMobile = useMobile()
|
||||||
|
|
@ -15,11 +15,10 @@ const useStyles = makeStyles((theme: Theme) => {
|
||||||
border: "none"
|
border: "none"
|
||||||
},
|
},
|
||||||
tableContainer: {
|
tableContainer: {
|
||||||
// margin: theme.spacing(1),
|
width: "100%",
|
||||||
// [theme.breakpoints.up("sm")]: {
|
maxWidth: "100%",
|
||||||
// margin: theme.spacing(2)
|
overflow: "hidden",
|
||||||
// },
|
minWidth: 0,
|
||||||
// width: "auto"
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
padding: theme.spacing(1),
|
padding: theme.spacing(1),
|
||||||
|
|
@ -59,10 +58,14 @@ const useStyles = makeStyles((theme: Theme) => {
|
||||||
},
|
},
|
||||||
stickyHeader: {
|
stickyHeader: {
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
backgroundColor: getThemeType() === "light" ? "rgb(245, 245, 245)" : "rgb(40, 40, 40)",
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
// Match Paper's elevation overlay so header matches search bar area
|
||||||
|
backgroundImage: theme.palette.mode === "dark"
|
||||||
|
? `linear-gradient(${alpha("#fff", 0.05)}, ${alpha("#fff", 0.05)})`
|
||||||
|
: undefined,
|
||||||
top: 0,
|
top: 0,
|
||||||
zIndex: 300 //giving a really high z-index to make sure nothing is in front of it
|
zIndex: 300 //giving a really high z-index to make sure nothing is in front of it
|
||||||
}
|
},
|
||||||
})},
|
})},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -75,6 +78,53 @@ export interface Column<T> {
|
||||||
disableSort?: boolean;
|
disableSort?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ResizableHeaderTitle<T>(props: {
|
||||||
|
column: Column<T>;
|
||||||
|
index: number;
|
||||||
|
columnsLength: number;
|
||||||
|
resizeable: boolean;
|
||||||
|
onColumnClick: (column: Column<T>) => void;
|
||||||
|
showOrderIcon: (column: Column<T>) => React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { column, index, columnsLength, resizeable, onColumnClick, showOrderIcon } = props;
|
||||||
|
const titleRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = titleRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const checkOverflow = () => setIsOverflowing(el.scrollWidth > el.clientWidth);
|
||||||
|
checkOverflow();
|
||||||
|
const ro = new ResizeObserver(checkOverflow);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid2
|
||||||
|
ref={titleRef}
|
||||||
|
alignItems="center"
|
||||||
|
display="flex"
|
||||||
|
onClick={() => !column.disableSort && onColumnClick(column)}
|
||||||
|
sx={{
|
||||||
|
cursor: column.disableSort ? "default" : "pointer",
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
...(isOverflowing && resizeable && index < columnsLength - 1
|
||||||
|
? {
|
||||||
|
maskImage: "linear-gradient(to right, black 75%, transparent 100%)",
|
||||||
|
WebkitMaskImage: "linear-gradient(to right, black 75%, transparent 100%)",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||||
|
{column.title} {showOrderIcon(column)}
|
||||||
|
</Box>
|
||||||
|
</Grid2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
rows: T[],
|
rows: T[],
|
||||||
columns?: Column<T>[] | (() => Column<T>[]),
|
columns?: Column<T>[] | (() => Column<T>[]),
|
||||||
|
|
@ -450,14 +500,12 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const resizeClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>, index: number) => {
|
const resizeClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>, index: number) => {
|
||||||
|
const row = event.currentTarget.closest("tr")
|
||||||
let newWidths: number[] = []
|
if (!row) return
|
||||||
event.currentTarget.parentElement?.parentElement?.parentElement?.childNodes.forEach(node => {
|
const cells = Array.from(row.querySelectorAll("th, td"))
|
||||||
if (node instanceof HTMLElement) {
|
const offset = (rowSelect ? 1 : 0) + (renderGutter ? 1 : 0)
|
||||||
newWidths.push(node.clientWidth)
|
const newWidths = cells.slice(offset).map((cell) => cell.clientWidth)
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
rowWidthsRef.current = newWidths
|
rowWidthsRef.current = newWidths
|
||||||
dragIndexRef.current = index;
|
dragIndexRef.current = index;
|
||||||
dragStartRef.current = event.clientX;
|
dragStartRef.current = event.clientX;
|
||||||
|
|
@ -483,11 +531,15 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
|
||||||
if (!dragStart) return
|
if (!dragStart) return
|
||||||
|
|
||||||
let newWidths: number[] = [...rowWidths]
|
let newWidths: number[] = [...rowWidths]
|
||||||
newWidths[index] = newWidths[index] - (dragStart - e.clientX)
|
const delta = dragStart - e.clientX
|
||||||
|
const newWidthForIndex = newWidths[index] - delta
|
||||||
|
const clampedWidth = Math.max(MIN_COLUMN_WIDTH, newWidthForIndex)
|
||||||
|
const actualDelta = newWidths[index] - clampedWidth
|
||||||
|
newWidths[index] = clampedWidth
|
||||||
newWidths.every((width, j) => {
|
newWidths.every((width, j) => {
|
||||||
if (j <= index || width === 0) return true
|
if (j <= index || width === 0) return true
|
||||||
else if (newWidths[j] !== undefined) {
|
else if (newWidths[j] !== undefined) {
|
||||||
newWidths[j] = newWidths[j] + (dragStart - e.clientX)
|
newWidths[j] = newWidths[j] + actualDelta
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -495,45 +547,49 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIN_COLUMN_WIDTH = 24;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classes.tableContainer} component={Paper}>
|
<Box className={classes.tableContainer} component={Paper}>
|
||||||
<Table>
|
{(title || setSearchText) && (
|
||||||
<TableHead className={stickyHeader ? classes.stickyHeader : undefined}>
|
<Box>
|
||||||
<TableRow sx={ !title && !setSearchText ? { display: "none" } : undefined}>
|
<Grid2 container justifyContent="space-between" alignItems="center" sx={{ padding: 1 }}>
|
||||||
<TableCell colSpan={10000} sx={{ border: "none" }}>
|
<Grid2>
|
||||||
<Grid2 container justifyContent="space-between">
|
<Box sx={{ marginTop: -1 }}>
|
||||||
|
{renderTitle()}
|
||||||
|
{renderSubtitle()}
|
||||||
|
</Box>
|
||||||
|
</Grid2>
|
||||||
|
<Grid2 size={{ xs: "grow" }} sx={{ minWidth: 0, display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<Grid2 container alignItems="center" spacing={1} wrap="nowrap">
|
||||||
<Grid2>
|
<Grid2>
|
||||||
<Box sx={{ marginTop: -1 }}>
|
{actions && actions}
|
||||||
{renderTitle()}
|
|
||||||
{renderSubtitle()}
|
|
||||||
</Box>
|
|
||||||
</Grid2>
|
</Grid2>
|
||||||
<Grid2>
|
<Grid2>
|
||||||
<Grid2 container alignItems={"center"} spacing={1}>
|
{endTitleElement}
|
||||||
<Grid2>
|
</Grid2>
|
||||||
{actions && actions}
|
<Grid2 sx={{ minWidth: 200, maxWidth: 320 }}>
|
||||||
</Grid2>
|
{setSearchText && (
|
||||||
<Grid2>
|
<Box className={classes.searchContainer} sx={{ width: "100%" }}>
|
||||||
{endTitleElement}
|
{searchBar()}
|
||||||
</Grid2>
|
</Box>
|
||||||
<Grid2>
|
)}
|
||||||
{setSearchText &&<Box className={classes.searchContainer}>
|
</Grid2>
|
||||||
{searchBar()}
|
<Grid2>
|
||||||
</Box>}
|
{ columns && !hideKeys &&
|
||||||
</Grid2>
|
<IconButton onClick={openColumnMenu} >
|
||||||
<Grid2>
|
<ViewColumn />
|
||||||
{ columns && !hideKeys &&
|
</IconButton>
|
||||||
<IconButton onClick={openColumnMenu} >
|
}
|
||||||
<ViewColumn />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
</Grid2>
|
|
||||||
</Grid2>
|
|
||||||
</Grid2>
|
</Grid2>
|
||||||
</Grid2>
|
</Grid2>
|
||||||
</TableCell>
|
</Grid2>
|
||||||
</TableRow>
|
</Grid2>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<TableContainer sx={{ overflowX: "auto", paddingBottom: 1 }}>
|
||||||
|
<Table>
|
||||||
|
<TableHead className={stickyHeader ? classes.stickyHeader : undefined}>
|
||||||
{customElement &&
|
{customElement &&
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={10000} sx={{ border: "none" }}>
|
<TableCell colSpan={10000} sx={{ border: "none" }}>
|
||||||
|
|
@ -552,18 +608,23 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<TableCell key={"header-cell-"+index} width={rowWidths[index] ? rowWidths[index] : undefined}>
|
<TableCell key={"header-cell-"+index} width={rowWidths[index] ? rowWidths[index] : undefined}>
|
||||||
<Grid2 container spacing={1} justifyContent={"space-between"}>
|
<Grid2
|
||||||
<Grid2
|
container
|
||||||
alignItems="center"
|
spacing={1}
|
||||||
display="flex"
|
justifyContent={"space-between"}
|
||||||
onClick={() => !column.disableSort && columnClick(column)}
|
flexWrap="nowrap"
|
||||||
sx={{ cursor: column.disableSort ? "default" : "pointer" }}
|
>
|
||||||
>
|
<ResizableHeaderTitle
|
||||||
{column.title} {showOrderIcon(column)}
|
column={column}
|
||||||
</Grid2>
|
index={index}
|
||||||
|
columnsLength={columns.length}
|
||||||
|
resizeable={!!resizeable}
|
||||||
|
onColumnClick={columnClick}
|
||||||
|
showOrderIcon={showOrderIcon}
|
||||||
|
/>
|
||||||
{index < columns.length-1 && resizeable &&
|
{index < columns.length-1 && resizeable &&
|
||||||
<Grid2
|
<Grid2
|
||||||
sx={{ cursor: "col-resize", userSelect: "none" }}
|
sx={{ cursor: "col-resize", userSelect: "none", flexShrink: 0 }}
|
||||||
onMouseDown={event => resizeClick(event, index)}
|
onMouseDown={event => resizeClick(event, index)}
|
||||||
>
|
>
|
||||||
|
|
|
|
||||||
|
|
@ -672,6 +733,7 @@ export default function ResponsiveTable<T extends Object>(props: Props<T>) {
|
||||||
}
|
}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
{!hidePagination &&
|
{!hidePagination &&
|
||||||
<TablePagination
|
<TablePagination
|
||||||
rowsPerPageOptions={rowsPerPageOptions ? rowsPerPageOptions : [5, 10, 20]}
|
rowsPerPageOptions={rowsPerPageOptions ? rowsPerPageOptions : [5, 10, 20]}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ import { green } from "@mui/material/colors";
|
||||||
import { makeStyles } from "@mui/styles";
|
import { makeStyles } from "@mui/styles";
|
||||||
import { Device } from "models";
|
import { Device } from "models";
|
||||||
import PulseBox from "./PulseBox";
|
import PulseBox from "./PulseBox";
|
||||||
|
import RelativeTimestamp from "./RelativeTimestamp";
|
||||||
import { or } from "utils";
|
import { or } from "utils";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
device: Device
|
device: Device;
|
||||||
|
showTimestamp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) => {
|
const useStyles = makeStyles((theme: Theme) => {
|
||||||
|
|
@ -48,7 +51,7 @@ const useStyles = makeStyles((theme: Theme) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function StatusDust(props: Props) {
|
export default function StatusDust(props: Props) {
|
||||||
const { device } = props;
|
const { device, showTimestamp } = props;
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []);
|
const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []);
|
||||||
|
|
@ -165,6 +168,11 @@ export default function StatusDust(props: Props) {
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid2>
|
</Grid2>
|
||||||
</Grid2>
|
</Grid2>
|
||||||
|
{showTimestamp && device.status.sen5x?.timestamp && (
|
||||||
|
<Grid2>
|
||||||
|
<RelativeTimestamp timestamp={device.status.sen5x.timestamp} />
|
||||||
|
</Grid2>
|
||||||
|
)}
|
||||||
</Grid2>
|
</Grid2>
|
||||||
</PulseBox>
|
</PulseBox>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@ import { blue, deepOrange, teal } from "@mui/material/colors";
|
||||||
import { makeStyles } from "@mui/styles";
|
import { makeStyles } from "@mui/styles";
|
||||||
import { Device } from "models";
|
import { Device } from "models";
|
||||||
import PulseBox from "./PulseBox";
|
import PulseBox from "./PulseBox";
|
||||||
|
import RelativeTimestamp from "./RelativeTimestamp";
|
||||||
import { or } from "utils";
|
import { or } from "utils";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
device: Device
|
device: Device;
|
||||||
// noDust?: boolean;
|
gasType: "co2" | "co" | "no2" | "o2" | "lel" | "h2s" | "all";
|
||||||
gasType: "co2" | "co" | "no2" | "o2" | "all"
|
showTimestamp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) => {
|
const useStyles = makeStyles((theme: Theme) => {
|
||||||
|
|
@ -23,7 +24,7 @@ const useStyles = makeStyles((theme: Theme) => {
|
||||||
marginRight: "auto",
|
marginRight: "auto",
|
||||||
margin: theme.spacing(1),
|
margin: theme.spacing(1),
|
||||||
width: theme.spacing(14),
|
width: theme.spacing(14),
|
||||||
height: theme.spacing(6),
|
minHeight: theme.spacing(6),
|
||||||
},
|
},
|
||||||
boxTall: {
|
boxTall: {
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
|
|
@ -33,7 +34,7 @@ const useStyles = makeStyles((theme: Theme) => {
|
||||||
marginRight: "auto",
|
marginRight: "auto",
|
||||||
margin: theme.spacing(1),
|
margin: theme.spacing(1),
|
||||||
width: theme.spacing(14),
|
width: theme.spacing(14),
|
||||||
height: theme.spacing(8.5),
|
minHeight: theme.spacing(8.5),
|
||||||
},
|
},
|
||||||
carouselContainer: {
|
carouselContainer: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|
@ -61,29 +62,33 @@ const useStyles = makeStyles((theme: Theme) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function StatusGas(props: Props) {
|
export default function StatusGas(props: Props) {
|
||||||
const { device, gasType } = props;
|
const { device, gasType, showTimestamp } = props;
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
// console.log(noDust)
|
// console.log(noDust)
|
||||||
let colors: string[] = []
|
let colors: string[] = []
|
||||||
let messages: string[] = []
|
let messages: string[] = []
|
||||||
if (gasType === "all") {
|
if (gasType === "all") {
|
||||||
colors = or(device.status.o2?.overlays.map(overlay => overlay.color), []);
|
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.co2?.overlays?.map(overlay => overlay.color), []));
|
||||||
colors.push(...or(device.status.no2?.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.co?.overlays?.map(overlay => overlay.color), []));
|
||||||
messages = or(device.status.o2?.overlays.map(overlay => overlay.message), []);
|
colors.push(...or(device.status.lel?.overlays?.map(overlay => overlay.color), []));
|
||||||
messages.push(...or(device.status.co2?.overlays.map(overlay => overlay.message), []));
|
colors.push(...or(device.status.h2s?.overlays?.map(overlay => overlay.color), []));
|
||||||
messages.push(...or(device.status.no2?.overlays.map(overlay => overlay.message), []));
|
messages = or(device.status.o2?.overlays?.map(overlay => overlay.message), []);
|
||||||
messages.push(...or(device.status.co?.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 {
|
} else {
|
||||||
colors = or(device.status[gasType]?.overlays.map(overlay => overlay.color), []);
|
colors = or(device.status[gasType]?.overlays?.map(overlay => overlay.color), []);
|
||||||
messages = or(device.status[gasType]?.overlays.map(overlay => overlay.message), []);
|
messages = or(device.status[gasType]?.overlays?.map(overlay => overlay.message), []);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [color, setColor] = useState(colors.length > 0 ? colors[0] : "");
|
const [color, setColor] = useState(colors.length > 0 ? colors[0] : "");
|
||||||
const [, setColorIndex] = useState(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(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
|
|
@ -106,8 +111,7 @@ export default function StatusGas(props: Props) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (gasType === "all") setCurrentIndex((prevIndex) => (prevIndex + 1) % 4);
|
if (gasType === "all") setCurrentIndex((prevIndex) => (prevIndex + 1) % gasTypes.length);
|
||||||
else setCurrentIndex((prevIndex) => (prevIndex + 1) % 4);
|
|
||||||
}, 6000);
|
}, 6000);
|
||||||
|
|
||||||
return () => clearInterval(interval); // Cleanup on unmount
|
return () => clearInterval(interval); // Cleanup on unmount
|
||||||
|
|
@ -188,14 +192,14 @@ export default function StatusGas(props: Props) {
|
||||||
}
|
}
|
||||||
<Grid2 container direction="column">
|
<Grid2 container direction="column">
|
||||||
<Grid2>
|
<Grid2>
|
||||||
{gas !== "o2" ?
|
{(gas === "o2" || gas === "lel") ?
|
||||||
<Typography variant="body2" style={{ color: teal[500]}}>
|
|
||||||
Gas: {device.status[gas]?.ppm.toFixed(1)}ppm
|
|
||||||
</Typography>
|
|
||||||
:
|
|
||||||
<Typography variant="body2" style={{ color: blue[500]}}>
|
<Typography variant="body2" style={{ color: blue[500]}}>
|
||||||
Gas: {(device.status[gas]?.ppm!/10000).toFixed(1)}%
|
Gas: {(device.status[gas]?.ppm!/10000).toFixed(1)}%
|
||||||
</Typography>
|
</Typography>
|
||||||
|
:
|
||||||
|
<Typography variant="body2" style={{ color: teal[500]}}>
|
||||||
|
Gas: {device.status[gas]?.ppm.toFixed(1)}ppm
|
||||||
|
</Typography>
|
||||||
}
|
}
|
||||||
</Grid2>
|
</Grid2>
|
||||||
<Grid2>
|
<Grid2>
|
||||||
|
|
@ -203,6 +207,11 @@ export default function StatusGas(props: Props) {
|
||||||
Volt: {device.status[gas]?.millivolts.toFixed(1)}mV
|
Volt: {device.status[gas]?.millivolts.toFixed(1)}mV
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid2>
|
</Grid2>
|
||||||
|
{showTimestamp && device.status[gas]?.timestamp && (
|
||||||
|
<Grid2>
|
||||||
|
<RelativeTimestamp timestamp={device.status[gas].timestamp} />
|
||||||
|
</Grid2>
|
||||||
|
)}
|
||||||
</Grid2>
|
</Grid2>
|
||||||
</Grid2>
|
</Grid2>
|
||||||
</PulseBox>
|
</PulseBox>
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ import { blue, orange } from "@mui/material/colors";
|
||||||
import { makeStyles } from "@mui/styles";
|
import { makeStyles } from "@mui/styles";
|
||||||
import { Device } from "models";
|
import { Device } from "models";
|
||||||
import PulseBox from "./PulseBox";
|
import PulseBox from "./PulseBox";
|
||||||
|
import RelativeTimestamp from "./RelativeTimestamp";
|
||||||
import { or } from "utils";
|
import { or } from "utils";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
device: Device
|
device: Device;
|
||||||
|
showTimestamp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) => {
|
const useStyles = makeStyles((theme: Theme) => {
|
||||||
|
|
@ -25,7 +27,7 @@ const useStyles = makeStyles((theme: Theme) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function StatusPlenum(props: Props) {
|
export default function StatusPlenum(props: Props) {
|
||||||
const { device } = props;
|
const { device, showTimestamp } = props;
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
const colors = or(device.status.plenum?.overlays.map(overlay => overlay.color), []);
|
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)}%
|
{device.status.plenum?.humidity.toFixed(1)}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid2>
|
</Grid2>
|
||||||
|
{showTimestamp && device.status.plenum?.timestamp && (
|
||||||
|
<Grid2>
|
||||||
|
<RelativeTimestamp timestamp={device.status.plenum.timestamp} />
|
||||||
|
</Grid2>
|
||||||
|
)}
|
||||||
</Grid2>
|
</Grid2>
|
||||||
</PulseBox>
|
</PulseBox>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import { blue, green, orange } from "@mui/material/colors";
|
||||||
import { makeStyles } from "@mui/styles";
|
import { makeStyles } from "@mui/styles";
|
||||||
import { Device } from "models";
|
import { Device } from "models";
|
||||||
import PulseBox from "./PulseBox";
|
import PulseBox from "./PulseBox";
|
||||||
|
import RelativeTimestamp from "./RelativeTimestamp";
|
||||||
import { or } from "utils";
|
import { or } from "utils";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
device: Device
|
device: Device;
|
||||||
noDust?: boolean;
|
noDust?: boolean;
|
||||||
|
showTimestamp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) => {
|
const useStyles = makeStyles((theme: Theme) => {
|
||||||
|
|
@ -50,7 +52,7 @@ const useStyles = makeStyles((theme: Theme) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function StatusSen5x(props: Props) {
|
export default function StatusSen5x(props: Props) {
|
||||||
const { device, noDust } = props;
|
const { device, noDust, showTimestamp } = props;
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
// console.log(noDust)
|
// console.log(noDust)
|
||||||
const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []);
|
const colors = or(device.status.sen5x?.overlays.map(overlay => overlay.color), []);
|
||||||
|
|
@ -200,6 +202,11 @@ export default function StatusSen5x(props: Props) {
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid2>
|
</Grid2>
|
||||||
</Grid2>}
|
</Grid2>}
|
||||||
|
{showTimestamp && device.status.sen5x?.timestamp && (
|
||||||
|
<Grid2>
|
||||||
|
<RelativeTimestamp timestamp={device.status.sen5x.timestamp} />
|
||||||
|
</Grid2>
|
||||||
|
)}
|
||||||
</Grid2>
|
</Grid2>
|
||||||
</PulseBox>
|
</PulseBox>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Gate } from "models/Gate";
|
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 React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useMobile } from "hooks";
|
import { useMobile } from "hooks";
|
||||||
|
|
@ -20,6 +20,7 @@ import { quack } from "protobuf-ts/quack";
|
||||||
import { getTemperatureUnit } from "utils";
|
import { getTemperatureUnit } from "utils";
|
||||||
import { teal } from "@mui/material/colors";
|
import { teal } from "@mui/material/colors";
|
||||||
import { react } from "@babel/types";
|
import { react } from "@babel/types";
|
||||||
|
import { getDeviceStateHelper } from "pbHelpers/DeviceState";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
//gates: Gate[];
|
//gates: Gate[];
|
||||||
|
|
@ -42,6 +43,12 @@ const useStyles = makeStyles((theme: Theme) => ({
|
||||||
gateCard: {
|
gateCard: {
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
paddingLeft: 15
|
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) => {
|
const displayPCAStatus = (state: pond.PCAState) => {
|
||||||
switch(state){
|
switch(state){
|
||||||
case pond.PCAState.PCA_STATE_IN_BOUNDS:
|
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:
|
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:
|
case pond.PCAState.PCA_STATE_OFF:
|
||||||
return <DoNotDisturb />
|
return (
|
||||||
|
<Box display="flex">
|
||||||
|
<DoNotDisturb />
|
||||||
|
<Typography paddingLeft={2}>PCA off</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
default:
|
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) => {
|
const conditionDisplay = (gate: Gate) => {
|
||||||
let display = ""
|
let display = ""
|
||||||
if(gate.status.pcaState === pond.PCAState.PCA_STATE_OFF){
|
if(gate.status.pcaState === pond.PCAState.PCA_STATE_OFF){
|
||||||
display = "Inactive"
|
display = "PCA Off"
|
||||||
} else if (gate.status.pcaState === pond.PCAState.PCA_STATE_UNKNOWN){
|
} else if (gate.status.pcaState === pond.PCAState.PCA_STATE_UNKNOWN){
|
||||||
display = "--"
|
display = "--"
|
||||||
} else { //the pca is currently active calulate the delta temp (outlet - ambient)
|
} else { //the pca is currently active calulate the delta temp (outlet - ambient)
|
||||||
|
|
@ -241,6 +268,19 @@ export default function GateList(props: Props) {
|
||||||
</Box>
|
</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",
|
title: "PCA Status",
|
||||||
render: gate => {
|
render: gate => {
|
||||||
|
|
@ -324,6 +364,7 @@ export default function GateList(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const gutter = (gate: Gate) => {
|
const gutter = (gate: Gate) => {
|
||||||
|
const deviceStateHelper = getDeviceStateHelper(gate.status.pcaDeviceState)
|
||||||
return(
|
return(
|
||||||
<Box>
|
<Box>
|
||||||
<Box padding={2} display="flex" flexDirection="row" justifyContent="space-between">
|
<Box padding={2} display="flex" flexDirection="row" justifyContent="space-between">
|
||||||
|
|
@ -334,6 +375,12 @@ export default function GateList(props: Props) {
|
||||||
<Typography>Delta Temperature:</Typography>
|
<Typography>Delta Temperature:</Typography>
|
||||||
{conditionDisplay(gate)}
|
{conditionDisplay(gate)}
|
||||||
</Box>
|
</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 */}
|
{/* 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">
|
{/* <Box padding={2} display="flex" flexDirection="row" justifyContent="space-between">
|
||||||
<Typography>Estimated Temp:</Typography>
|
<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 ProvisionDevice from "device/ProvisionDevice";
|
||||||
import { pond } from "protobuf-ts/pond";
|
import { pond } from "protobuf-ts/pond";
|
||||||
import { useDeviceAPI, useGlobalState, useGroupAPI, useTeamAPI } from "providers";
|
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 PageContainer from "./PageContainer";
|
||||||
import { useMobile } from "hooks";
|
import { useMobile } from "hooks";
|
||||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
@ -30,6 +32,28 @@ import { cloneDeep } from "lodash";
|
||||||
import LoadingScreen from "app/LoadingScreen";
|
import LoadingScreen from "app/LoadingScreen";
|
||||||
import TeamDialog from "teams/TeamDialog";
|
import TeamDialog from "teams/TeamDialog";
|
||||||
|
|
||||||
|
type TimestampedReading = {
|
||||||
|
timestamp?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimestampMillis = (timestamp?: string): number | undefined => {
|
||||||
|
if (!timestamp) return undefined;
|
||||||
|
const parsed = Date.parse(timestamp);
|
||||||
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickNewestReading = <T extends TimestampedReading>(
|
||||||
|
current: T | null | undefined,
|
||||||
|
incoming: T | null | undefined
|
||||||
|
): T | null | undefined => {
|
||||||
|
const currentTs = getTimestampMillis(current?.timestamp);
|
||||||
|
const incomingTs = getTimestampMillis(incoming?.timestamp);
|
||||||
|
|
||||||
|
if (incomingTs === undefined) return current ?? incoming;
|
||||||
|
if (currentTs === undefined) return incoming ?? current;
|
||||||
|
return incomingTs >= currentTs ? incoming : current;
|
||||||
|
};
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) => {
|
const useStyles = makeStyles((theme: Theme) => {
|
||||||
return ({
|
return ({
|
||||||
buttonBox: {
|
buttonBox: {
|
||||||
|
|
@ -141,6 +165,8 @@ export default function Devices() {
|
||||||
const [hasCo2, setHasCo2] = useState(false)
|
const [hasCo2, setHasCo2] = useState(false)
|
||||||
const [hasNo2, setHasNo2] = useState(false)
|
const [hasNo2, setHasNo2] = useState(false)
|
||||||
const [hasO2, setHasO2] = useState(false)
|
const [hasO2, setHasO2] = useState(false)
|
||||||
|
const [hasLel, setHasLel] = useState(false)
|
||||||
|
const [hasH2S, setHasH2S] = useState(false)
|
||||||
|
|
||||||
const [groupsLoading, setGroupsLoading] = useState(false)
|
const [groupsLoading, setGroupsLoading] = useState(false)
|
||||||
const [groups, setGroups] = useState<Group[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
|
|
@ -188,9 +214,62 @@ export default function Devices() {
|
||||||
localStorage.setItem('groupGas', groupGas.toString());
|
localStorage.setItem('groupGas', groupGas.toString());
|
||||||
}, [groupGas]);
|
}, [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 [preferencesAnchor, setPreferencesAnchor] = useState<any>(null)
|
||||||
|
|
||||||
const [{ as, team }] = useGlobalState()
|
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)
|
||||||
|
const incomingStatus = pond.DeviceStatus.fromObject(status)
|
||||||
|
const currentStatus = d.status ?? pond.DeviceStatus.create()
|
||||||
|
|
||||||
|
// Guard against out-of-order websocket events by keeping whichever
|
||||||
|
// reading has the newest timestamp for each independently-updated sensor.
|
||||||
|
incomingStatus.plenum = pickNewestReading(currentStatus.plenum, incomingStatus.plenum)
|
||||||
|
incomingStatus.sen5x = pickNewestReading(currentStatus.sen5x, incomingStatus.sen5x)
|
||||||
|
incomingStatus.co = pickNewestReading(currentStatus.co, incomingStatus.co)
|
||||||
|
incomingStatus.co2 = pickNewestReading(currentStatus.co2, incomingStatus.co2)
|
||||||
|
incomingStatus.no2 = pickNewestReading(currentStatus.no2, incomingStatus.no2)
|
||||||
|
incomingStatus.o2 = pickNewestReading(currentStatus.o2, incomingStatus.o2)
|
||||||
|
incomingStatus.lel = pickNewestReading(currentStatus.lel, incomingStatus.lel)
|
||||||
|
incomingStatus.h2s = pickNewestReading(currentStatus.h2s, incomingStatus.h2s)
|
||||||
|
|
||||||
|
const currentLastActive = getTimestampMillis(currentStatus.lastActive)
|
||||||
|
const incomingLastActive = getTimestampMillis(incomingStatus.lastActive)
|
||||||
|
if (
|
||||||
|
currentLastActive !== undefined &&
|
||||||
|
incomingLastActive !== undefined &&
|
||||||
|
incomingLastActive < currentLastActive
|
||||||
|
) {
|
||||||
|
incomingStatus.lastActive = currentStatus.lastActive
|
||||||
|
}
|
||||||
|
|
||||||
|
updated.status = incomingStatus
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useDeviceStatusStreams({
|
||||||
|
deviceIds,
|
||||||
|
onStatusUpdate: handleStatusUpdate,
|
||||||
|
token,
|
||||||
|
enabled: devices.length > 0 && !!token,
|
||||||
|
})
|
||||||
|
|
||||||
const updateGroups = (newGroups: Group[]) => {
|
const updateGroups = (newGroups: Group[]) => {
|
||||||
setGroups(newGroups);
|
setGroups(newGroups);
|
||||||
|
|
@ -242,7 +321,7 @@ export default function Devices() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
if (compareTimestamps(device.status.sen5x?.timestamp, oneDayAgo.toISOString()) > 0) {
|
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
|
return true
|
||||||
} else {
|
} else {
|
||||||
// console.log("timestamp fail " + device.settings?.deviceId)
|
// console.log("timestamp fail " + device.settings?.deviceId)
|
||||||
|
|
@ -265,16 +344,34 @@ export default function Devices() {
|
||||||
|
|
||||||
const doesDeviceHaveO2 = (device: pond.Device) => {
|
const doesDeviceHaveO2 = (device: pond.Device) => {
|
||||||
if (device.status?.o2?.ppm && device.status?.o2?.timestamp) {
|
if (device.status?.o2?.ppm && device.status?.o2?.timestamp) {
|
||||||
if (device.status.sen5x?.timestamp) {
|
const now = new Date();
|
||||||
const now = new Date();
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
if (compareTimestamps(device.status.o2?.timestamp, oneDayAgo.toISOString()) > 0) {
|
||||||
if (compareTimestamps(device.status.o2?.timestamp, oneDayAgo.toISOString()) > 0) {
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -455,6 +552,8 @@ export default function Devices() {
|
||||||
let hasCo = false
|
let hasCo = false
|
||||||
let hasCo2 = false
|
let hasCo2 = false
|
||||||
let hasNo2 = false
|
let hasNo2 = false
|
||||||
|
let hasLel = false
|
||||||
|
let hasH2S = false
|
||||||
resp.data.devices.forEach(device => {
|
resp.data.devices.forEach(device => {
|
||||||
setHasPlenums(doesDeviceHavePlenum(device))
|
setHasPlenums(doesDeviceHavePlenum(device))
|
||||||
if (doesDeviceHavePlenum(device)) hasPlenum = true
|
if (doesDeviceHavePlenum(device)) hasPlenum = true
|
||||||
|
|
@ -464,6 +563,8 @@ export default function Devices() {
|
||||||
if (doesDeviceHaveCo(device)) hasCo = true
|
if (doesDeviceHaveCo(device)) hasCo = true
|
||||||
if (doesDeviceHaveCo2(device)) hasCo2 = true
|
if (doesDeviceHaveCo2(device)) hasCo2 = true
|
||||||
if (doesDeviceHaveNo2(device)) hasNo2 = true
|
if (doesDeviceHaveNo2(device)) hasNo2 = true
|
||||||
|
if (doesDeviceHaveLel(device)) hasLel = true
|
||||||
|
if (doesDeviceHaveH2S(device)) hasH2S = true
|
||||||
newDevices.push(Device.create(device))
|
newDevices.push(Device.create(device))
|
||||||
})
|
})
|
||||||
setHasPlenums(hasPlenum)
|
setHasPlenums(hasPlenum)
|
||||||
|
|
@ -472,6 +573,8 @@ export default function Devices() {
|
||||||
setHasCo(hasCo)
|
setHasCo(hasCo)
|
||||||
setHasCo2(hasCo2)
|
setHasCo2(hasCo2)
|
||||||
setHasNo2(hasNo2)
|
setHasNo2(hasNo2)
|
||||||
|
setHasLel(hasLel)
|
||||||
|
setHasH2S(hasH2S)
|
||||||
setDevices(newDevices)
|
setDevices(newDevices)
|
||||||
setTotal(resp.data.total)
|
setTotal(resp.data.total)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
|
|
@ -624,7 +727,7 @@ export default function Devices() {
|
||||||
if (device.status.plenum?.temperature) {
|
if (device.status.plenum?.temperature) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ marginLeft: 1 }}>
|
<Box sx={{ marginLeft: 1 }}>
|
||||||
<StatusPlenum device={device} />
|
<StatusPlenum device={device} showTimestamp={showTimestamp} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -644,7 +747,7 @@ export default function Devices() {
|
||||||
if (device.status.sen5x?.temperature) {
|
if (device.status.sen5x?.temperature) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ marginLeft: 1 }}>
|
<Box sx={{ marginLeft: 1 }}>
|
||||||
<StatusSen5x device={device} noDust={separateDust} />
|
<StatusSen5x device={device} noDust={separateDust} showTimestamp={showTimestamp} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -660,7 +763,7 @@ export default function Devices() {
|
||||||
if (device.status.sen5x?.temperature) {
|
if (device.status.sen5x?.temperature) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ marginLeft: 1 }}>
|
<Box sx={{ marginLeft: 1 }}>
|
||||||
<StatusDust device={device} />
|
<StatusDust device={device} showTimestamp={showTimestamp} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -678,7 +781,7 @@ export default function Devices() {
|
||||||
if (device.status.co) {
|
if (device.status.co) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ marginLeft: 1 }}>
|
<Box sx={{ marginLeft: 1 }}>
|
||||||
<StatusGas device={device} gasType={groupGas ? "all" : "co"}/>
|
<StatusGas device={device} gasType={groupGas ? "all" : "co"} showTimestamp={showTimestamp} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -696,7 +799,7 @@ export default function Devices() {
|
||||||
if (device.status.co2) {
|
if (device.status.co2) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ marginLeft: 1 }}>
|
<Box sx={{ marginLeft: 1 }}>
|
||||||
<StatusGas device={device} gasType={"co2"}/>
|
<StatusGas device={device} gasType={"co2"} showTimestamp={showTimestamp} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -714,7 +817,7 @@ export default function Devices() {
|
||||||
if (device.status.no2) {
|
if (device.status.no2) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ marginLeft: 1 }}>
|
<Box sx={{ marginLeft: 1 }}>
|
||||||
<StatusGas device={device} gasType={"no2"}/>
|
<StatusGas device={device} gasType={"no2"} showTimestamp={showTimestamp} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -732,7 +835,43 @@ export default function Devices() {
|
||||||
if (device.status.o2) {
|
if (device.status.o2) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ marginLeft: 1 }}>
|
<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>
|
</Box>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -872,6 +1011,16 @@ export default function Devices() {
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary={"Group Gas"} />
|
<ListItemText primary={"Group Gas"} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => setShowTimestamp(!showTimestamp)}
|
||||||
|
sx={{ marginLeft: -0.75 }}
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Checkbox checked={showTimestamp} />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={"Show Timestamp"} />
|
||||||
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -959,6 +1108,8 @@ export default function Devices() {
|
||||||
let hasCo = false
|
let hasCo = false
|
||||||
let hasCo2 = false
|
let hasCo2 = false
|
||||||
let hasNo2 = false
|
let hasNo2 = false
|
||||||
|
let hasLel = false
|
||||||
|
let hasH2S = false
|
||||||
resp.data.devices.forEach(device => {
|
resp.data.devices.forEach(device => {
|
||||||
setHasPlenums(doesDeviceHavePlenum(device))
|
setHasPlenums(doesDeviceHavePlenum(device))
|
||||||
if (doesDeviceHavePlenum(device)) hasPlenum = true
|
if (doesDeviceHavePlenum(device)) hasPlenum = true
|
||||||
|
|
@ -968,6 +1119,8 @@ export default function Devices() {
|
||||||
if (doesDeviceHaveCo(device)) hasCo = true
|
if (doesDeviceHaveCo(device)) hasCo = true
|
||||||
if (doesDeviceHaveCo2(device)) hasCo2 = true
|
if (doesDeviceHaveCo2(device)) hasCo2 = true
|
||||||
if (doesDeviceHaveNo2(device)) hasNo2 = true
|
if (doesDeviceHaveNo2(device)) hasNo2 = true
|
||||||
|
if (doesDeviceHaveLel(device)) hasLel = true
|
||||||
|
if (doesDeviceHaveH2S(device)) hasH2S = true
|
||||||
newDevices.push(Device.create(device))
|
newDevices.push(Device.create(device))
|
||||||
})
|
})
|
||||||
setHasPlenums(hasPlenum)
|
setHasPlenums(hasPlenum)
|
||||||
|
|
@ -976,6 +1129,8 @@ export default function Devices() {
|
||||||
setHasCo(hasCo)
|
setHasCo(hasCo)
|
||||||
setHasCo2(hasCo2)
|
setHasCo2(hasCo2)
|
||||||
setHasNo2(hasNo2)
|
setHasNo2(hasNo2)
|
||||||
|
setHasLel(hasLel)
|
||||||
|
setHasH2S(hasH2S)
|
||||||
setDevices(currentRows.concat(newDevices))
|
setDevices(currentRows.concat(newDevices))
|
||||||
}).catch(_err => {
|
}).catch(_err => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,11 @@ export function dragerGasDongle(subtype: number = 0): ComponentTypeExtension {
|
||||||
key: quack.DragerGasDongleSubtype.DRAGER_GAS_DONGLE_SUBTYPE_LEL,
|
key: quack.DragerGasDongleSubtype.DRAGER_GAS_DONGLE_SUBTYPE_LEL,
|
||||||
value: "DRAGER_GAS_DONGLE_SUBTYPE_LEL",
|
value: "DRAGER_GAS_DONGLE_SUBTYPE_LEL",
|
||||||
friendlyName: "Drager Gas 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",
|
friendlyName: "Drager Gas",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue