1032 lines
No EOL
45 KiB
TypeScript
1032 lines
No EOL
45 KiB
TypeScript
import { AccessTime, MoreVert } from "@mui/icons-material";
|
|
import { Avatar, Box, Button, Card, Checkbox, FormControlLabel, Grid2, IconButton, Menu, MenuItem, Typography, useTheme } from "@mui/material";
|
|
import BinSensorCard from "bin/BinSensorCard";
|
|
import { GetDefaultDateRange } from "common/time/DateRange";
|
|
import { ExtractMoisture } from "grain";
|
|
import { useMobile, useThemeType } from "hooks";
|
|
import { Bin, Component } from "models";
|
|
import { Ambient } from "models/Ambient";
|
|
import { CO2 } from "models/CO2";
|
|
import { Controller } from "models/Controller";
|
|
import { GrainCable } from "models/GrainCable";
|
|
import { Headspace } from "models/Headspace";
|
|
import { Plenum } from "models/Plenum";
|
|
import { Pressure } from "models/Pressure";
|
|
import moment, { Moment } from "moment";
|
|
import { GetComponentIcon } from "pbHelpers/ComponentType";
|
|
import { pond } from "protobuf-ts/pond";
|
|
import { quack } from "protobuf-ts/quack";
|
|
import { useBinAPI, useComponentAPI, useGlobalState, useSnackbar } from "providers";
|
|
import React, { useEffect, useState } from "react";
|
|
import { avg } from "utils";
|
|
import BinSensorGraph from "./graphs/BinSensorGraph";
|
|
import TimeBar from "common/time/TimeBar";
|
|
|
|
interface Props {
|
|
bin: Bin
|
|
components: Map<string, Component>;
|
|
componentDevices: Map<string, number>;
|
|
preferences?: Map<string, pond.BinComponentPreferences>;
|
|
setPreferences: React.Dispatch<
|
|
React.SetStateAction<Map<string, pond.BinComponentPreferences> | undefined>
|
|
>;
|
|
|
|
}
|
|
|
|
export default function BinSensorsDisplay(props: Props){
|
|
const {bin, components, componentDevices, preferences, setPreferences} = props
|
|
const themeType = useThemeType()
|
|
const [{user, as}] = useGlobalState()
|
|
const binAPI = useBinAPI()
|
|
const componentAPI = useComponentAPI()
|
|
const theme = useTheme()
|
|
const snackbar = useSnackbar()
|
|
const [cables, setCables] = useState<GrainCable[]>([])
|
|
const [plenums, setPlenums] = useState<Plenum[]>([])
|
|
const [ambient, setAmbient] = useState<Ambient[]>([])
|
|
const [pressures, setPressures] = useState<Pressure[]>([])
|
|
const [fans, setFans] = useState<Controller[]>([])
|
|
const [heaters, setHeaters] = useState<Controller[]>([])
|
|
//the above component preferences all have only components that are one type of sensor, however headspace has components of three different sensors T/H, CO2, and Lidar
|
|
const [headspaceTH, setHeadspaceTH] = useState<Headspace[]>([]) //the components in the headspace that measure temp/humidity
|
|
const [headspaceCO2, setHeadspaceCO2] = useState<CO2[]>([]) //the CO2 components in the headspace
|
|
const [headspaceLidar, setHeadspaceLidar] = useState<Component[]>([]) //the lidar components in the headspace
|
|
|
|
const [unassignedComponents, setUnassignedComponents] = useState<Component[]>([])
|
|
|
|
//using counts so i dont have to have to check multiple arrays for sensors and controllers
|
|
const [sensorCount, setSensorCount] = useState(0)
|
|
const [controllerCount, setControllerCount] = useState(0)
|
|
|
|
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
|
//only used to determine what preferences are options for the selected component ie. DHT can be a plenum or a headspace
|
|
const [selectedComponentType, setSelectedComponentType] = useState<
|
|
quack.ComponentType | undefined
|
|
>(undefined);
|
|
const [componentUnassigned, setComponentUnnassigned] = useState(false);
|
|
const [selectedComponentKey, setSelectedComponentKey] = useState("");
|
|
const [selectedComponentSubtype, setSelectedComponentSubtype] = useState(0);
|
|
const [selectedComponentTopNode, setSelectedComponentTopNode] = useState(0);
|
|
const [showGraphs, setShowGraphs] = useState(false);
|
|
const defaultDateRange = GetDefaultDateRange();
|
|
const [startDate, setStartDate] = useState<Moment>(defaultDateRange.start);
|
|
const [endDate, setEndDate] = useState<Moment>(defaultDateRange.end);
|
|
const [filterNodes, setFilterNodes] = useState(true)
|
|
const isMobile = useMobile()
|
|
|
|
//organize the components into the correct 'positions' using the bins preferences
|
|
useEffect(()=>{
|
|
let unassigned: Component[] = []
|
|
let cables: GrainCable[] = []
|
|
let plenums: Plenum[] = []
|
|
let ambients: Ambient[] = []
|
|
let pressures: Pressure[] = []
|
|
let fans: Controller[] = []
|
|
let heaters: Controller[] = []
|
|
let headspaceTH: Headspace[] = []
|
|
let headspaceCO2: CO2[] = []
|
|
let headspaceLidar: Component[] = []
|
|
let sensorCount = 0
|
|
let controllerCount = 0
|
|
|
|
components.forEach(comp => {
|
|
if(preferences){
|
|
let pref = preferences.get(comp.key())
|
|
switch(pref?.type){
|
|
case pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE:
|
|
cables.push(GrainCable.create(comp))
|
|
sensorCount++
|
|
break;
|
|
case pond.BinComponent.BIN_COMPONENT_PLENUM:
|
|
plenums.push(Plenum.create(comp))
|
|
sensorCount++
|
|
break;
|
|
case pond.BinComponent.BIN_COMPONENT_AMBIENT:
|
|
ambients.push(Ambient.create(comp))
|
|
sensorCount++
|
|
break;
|
|
case pond.BinComponent.BIN_COMPONENT_PRESSURE:
|
|
pressures.push(Pressure.create(comp))
|
|
sensorCount++
|
|
break;
|
|
case pond.BinComponent.BIN_COMPONENT_FAN:
|
|
fans.push(Controller.create(comp))
|
|
controllerCount++
|
|
break;
|
|
case pond.BinComponent.BIN_COMPONENT_HEATER:
|
|
heaters.push(Controller.create(comp))
|
|
controllerCount++
|
|
break;
|
|
case pond.BinComponent.BIN_COMPONENT_HEADSPACE:
|
|
if (comp.type() === quack.ComponentType.COMPONENT_TYPE_DHT) {
|
|
headspaceTH.push(Headspace.create(comp));
|
|
} else if (comp.type() === quack.ComponentType.COMPONENT_TYPE_LIDAR) {
|
|
headspaceLidar.push(comp);
|
|
} else if (comp.type() === quack.ComponentType.COMPONENT_TYPE_CO2) {
|
|
let coComp = CO2.create(comp)
|
|
headspaceCO2.push(coComp);
|
|
}
|
|
sensorCount++
|
|
break;
|
|
default:
|
|
unassigned.push(comp)
|
|
}
|
|
}else{
|
|
unassigned.push(comp)
|
|
}
|
|
})
|
|
// console.log(cables)
|
|
// console.log(unassigned)
|
|
setSensorCount(sensorCount)
|
|
setControllerCount(controllerCount)
|
|
setCables(cables)
|
|
setPlenums(plenums)
|
|
setPressures(pressures)
|
|
setAmbient(ambients)
|
|
setFans(fans)
|
|
setHeaters(heaters)
|
|
setHeadspaceTH(headspaceTH)
|
|
setHeadspaceCO2(headspaceCO2)
|
|
setHeadspaceLidar(headspaceLidar)
|
|
setUnassignedComponents(unassigned)
|
|
},[components, preferences])
|
|
|
|
const selectType = (component: string, type: pond.BinComponent, topNode?: number) => {
|
|
setAnchorEl(null);
|
|
if (!preferences) return
|
|
let p = preferences.get(component);
|
|
if (p) {
|
|
p.type = type;
|
|
p.node = topNode ?? 0;
|
|
binAPI
|
|
.updateComponentPreferences(bin.key(), component, p, as)
|
|
.then(() => {
|
|
snackbar.success("Component preferences updated");
|
|
preferences.set(component, p!);
|
|
setPreferences(new Map(preferences));
|
|
})
|
|
.catch(() => {
|
|
snackbar.error("Component preference update failed");
|
|
});
|
|
}
|
|
};
|
|
|
|
const getOptions = () => {
|
|
let options: JSX.Element[] = [];
|
|
|
|
if (!componentUnassigned) {
|
|
options.push(
|
|
<MenuItem
|
|
key="remove"
|
|
onClick={() => {
|
|
selectType(selectedComponentKey, pond.BinComponent.BIN_COMPONENT_UNKNOWN);
|
|
}}>
|
|
Remove
|
|
</MenuItem>
|
|
);
|
|
}
|
|
if (
|
|
selectedComponentType === quack.ComponentType.COMPONENT_TYPE_DHT ||
|
|
selectedComponentType === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE
|
|
) {
|
|
options.push(
|
|
<MenuItem
|
|
key="ambientOption"
|
|
onClick={() => {
|
|
selectType(selectedComponentKey, pond.BinComponent.BIN_COMPONENT_AMBIENT);
|
|
}}>
|
|
Ambient
|
|
</MenuItem>
|
|
);
|
|
options.push(
|
|
<MenuItem
|
|
key="plenumOption"
|
|
onClick={() => {
|
|
selectType(selectedComponentKey, pond.BinComponent.BIN_COMPONENT_PLENUM);
|
|
}}>
|
|
Plenum
|
|
</MenuItem>
|
|
);
|
|
}
|
|
if (
|
|
selectedComponentType === quack.ComponentType.COMPONENT_TYPE_DHT ||
|
|
selectedComponentType === quack.ComponentType.COMPONENT_TYPE_LIDAR ||
|
|
selectedComponentType === quack.ComponentType.COMPONENT_TYPE_CO2
|
|
) {
|
|
options.push(
|
|
<MenuItem
|
|
key="headspaceOption"
|
|
onClick={() => {
|
|
selectType(selectedComponentKey, pond.BinComponent.BIN_COMPONENT_HEADSPACE);
|
|
}}>
|
|
Headspace
|
|
</MenuItem>
|
|
);
|
|
}
|
|
if (
|
|
selectedComponentType === quack.ComponentType.COMPONENT_TYPE_PRESSURE ||
|
|
selectedComponentType === quack.ComponentType.COMPONENT_TYPE_PRESSURE_CABLE
|
|
) {
|
|
options.push(
|
|
<MenuItem
|
|
key="pressureOption"
|
|
onClick={() => {
|
|
selectType(selectedComponentKey, pond.BinComponent.BIN_COMPONENT_PRESSURE);
|
|
}}>
|
|
Plenum Pressure
|
|
</MenuItem>
|
|
);
|
|
}
|
|
if (selectedComponentType === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE) {
|
|
options.push(
|
|
<MenuItem
|
|
key="cableOption"
|
|
onClick={() => {
|
|
selectType(
|
|
selectedComponentKey,
|
|
pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE,
|
|
selectedComponentTopNode
|
|
);
|
|
}}>
|
|
Grain Cable
|
|
</MenuItem>
|
|
);
|
|
}
|
|
if (selectedComponentType === quack.ComponentType.COMPONENT_TYPE_BOOLEAN_OUTPUT) {
|
|
options.push(
|
|
<MenuItem
|
|
key="controlOption"
|
|
onClick={() => {
|
|
if (
|
|
selectedComponentSubtype === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_HEATER
|
|
) {
|
|
selectType(selectedComponentKey, pond.BinComponent.BIN_COMPONENT_HEATER);
|
|
} else if (
|
|
selectedComponentSubtype ===
|
|
quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_AERATION_FAN
|
|
) {
|
|
selectType(selectedComponentKey, pond.BinComponent.BIN_COMPONENT_FAN);
|
|
}
|
|
}}>
|
|
Heaters & Fans
|
|
</MenuItem>
|
|
);
|
|
}
|
|
|
|
if (options.length === 0) {
|
|
return <MenuItem>No Available Options</MenuItem>;
|
|
} else {
|
|
return options;
|
|
}
|
|
};
|
|
|
|
const assignmentMenu = () => {
|
|
return (
|
|
<Menu
|
|
id="groupMenu"
|
|
anchorEl={anchorEl}
|
|
open={Boolean(anchorEl)}
|
|
onClose={() => setAnchorEl(null)}
|
|
keepMounted
|
|
disableAutoFocusItem>
|
|
{getOptions()}
|
|
</Menu>
|
|
);
|
|
};
|
|
|
|
const cableCard = (cable: GrainCable) => {
|
|
const component = cable.asComponent()
|
|
let icon = GetComponentIcon(component.type(), component.subType(), themeType)
|
|
let unit = user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "°F" : "°C"
|
|
|
|
const hasHumidity = cable.aveHumidity() !== 0
|
|
const hasMoisture = cable.grainMoistures.length > 0
|
|
|
|
const rows: { label: string; min: string; avg: string; max: string }[] = [
|
|
{
|
|
label: `Temp ${unit}`,
|
|
min: cable.minTemp(user.tempUnit(), filterNodes).toFixed(2),
|
|
avg: cable.aveTemp(user.tempUnit(), filterNodes).toFixed(2),
|
|
max: cable.maxTemp(user.tempUnit(), filterNodes).toFixed(2),
|
|
},
|
|
...(hasHumidity ? [{
|
|
label: "RH %",
|
|
min: cable.minHumidity(filterNodes).toFixed(2),
|
|
avg: cable.aveHumidity(filterNodes).toFixed(2),
|
|
max: cable.maxHumidity(filterNodes).toFixed(2),
|
|
}] : []),
|
|
...(hasMoisture ? [{
|
|
label: "EMC %",
|
|
min: cable.minMoisture(filterNodes).toFixed(2),
|
|
avg: cable.aveMoisture(filterNodes).toFixed(2),
|
|
max: cable.maxMoisture(filterNodes).toFixed(2),
|
|
}] : []),
|
|
]
|
|
|
|
return (
|
|
<BinSensorCard
|
|
icon={icon}
|
|
lastReading={cable.lastReading}
|
|
name={cable.name()}
|
|
tag={"Cable"}
|
|
cableRows={rows}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(cable.type());
|
|
setSelectedComponentKey(cable.key());
|
|
setSelectedComponentTopNode(cable.settings.grainFilledTo);
|
|
setAnchorEl(event.currentTarget);
|
|
}}/>
|
|
)
|
|
}
|
|
|
|
const tempHumidCard = (sensor: Plenum | Ambient | Headspace, showEMC?: boolean) => {
|
|
let icon = GetComponentIcon(sensor.settings.type, sensor.settings.subtype, themeType)
|
|
let prefDisplay = ""
|
|
switch (preferences?.get(sensor.key())?.type){
|
|
case pond.BinComponent.BIN_COMPONENT_PLENUM:
|
|
prefDisplay = "Plenum"
|
|
break;
|
|
case pond.BinComponent.BIN_COMPONENT_AMBIENT:
|
|
prefDisplay = "Ambient"
|
|
break;
|
|
case pond.BinComponent.BIN_COMPONENT_HEADSPACE:
|
|
prefDisplay = "Headspace"
|
|
}
|
|
|
|
let rows: {label: string, data: string}[] = [
|
|
// build temperature row data
|
|
{
|
|
label: "Temp " + (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? "°F" : "°C"),
|
|
data: (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? sensor.temperature * (9 / 5) + 32 : sensor.temperature).toFixed(2)
|
|
},
|
|
// build humidity row
|
|
{
|
|
label: "RH %",
|
|
data: sensor.humidity.toFixed(2)
|
|
}
|
|
]
|
|
// build moisture row assuming the bin has a grain type to use to calculatee the moisture
|
|
if(showEMC && bin.grain() !== pond.Grain.GRAIN_INVALID && bin.grain() !== pond.Grain.GRAIN_NONE){
|
|
let emc = ExtractMoisture(
|
|
bin.grain(),
|
|
sensor.temperature,
|
|
sensor.humidity,
|
|
bin.customGrain()
|
|
)
|
|
rows.push(
|
|
{
|
|
label: "EMC %",
|
|
data: emc.toFixed(2)
|
|
}
|
|
)
|
|
}
|
|
|
|
return (
|
|
<BinSensorCard
|
|
lastReading={sensor.lastReading}
|
|
name={sensor.name()}
|
|
tag={prefDisplay}
|
|
icon={icon}
|
|
rows={rows}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(sensor.type());
|
|
setSelectedComponentKey(sensor.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const pressureCard = (pressure: Pressure) => {
|
|
let icon = GetComponentIcon(pressure.settings.type, pressure.settings.subtype, themeType)
|
|
let rows: {label: string, data: string}[] = [
|
|
{
|
|
label: "Pressure " + (user.pressureUnit() === pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER ? "iwg" : "kPa"),
|
|
data: (user.pressureUnit() === pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER ? pressure.pascals/249 : pressure.pascals/1000).toFixed(2)
|
|
}
|
|
]
|
|
|
|
if(pressure.airflow){
|
|
rows.push({
|
|
label: "Airflow CFM",
|
|
data: pressure.airflow.toFixed(2)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<BinSensorCard
|
|
lastReading={pressure.lastReading}
|
|
name={pressure.name()}
|
|
tag={"Pressure"}
|
|
icon={icon}
|
|
rows={rows}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(pressure.type());
|
|
setSelectedComponentKey(pressure.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
const co2Card = (sensor: CO2) => {
|
|
let icon = GetComponentIcon(sensor.settings.type, sensor.settings.subtype, themeType)
|
|
let rows: {label: string, data: string}[] = [
|
|
{
|
|
label: "CO2 ppm",
|
|
data: sensor.ppm.toFixed(2)
|
|
}
|
|
]
|
|
|
|
return (
|
|
<BinSensorCard
|
|
lastReading={sensor.lastReading}
|
|
name={sensor.name()}
|
|
tag={"Headspace"}
|
|
icon={icon}
|
|
rows={rows}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(sensor.type());
|
|
setSelectedComponentKey(sensor.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const lidarCard = (sensor: Component) => {
|
|
let icon = GetComponentIcon(sensor.settings.type, sensor.settings.subtype, themeType)
|
|
let rows: {label: string, data: string}[] = []
|
|
let lastReading = moment.now().toString()
|
|
sensor.lastMeasurement.forEach(unitMeasurement => {
|
|
let label = ""
|
|
let value = 0
|
|
if(sensor.type() === quack.ComponentType.COMPONENT_TYPE_LIDAR){
|
|
if(unitMeasurement.type === quack.MeasurementType.MEASUREMENT_TYPE_DISTANCE_CM){
|
|
label = "Lidar " + (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? " ft" : " M")
|
|
if(unitMeasurement.timestamps.length > 0){
|
|
lastReading = unitMeasurement.timestamps[0]
|
|
}
|
|
if (unitMeasurement.values.length > 0){
|
|
//distance is stored in cm so needs to be converted
|
|
value = avg(unitMeasurement.values[0].values)
|
|
value = user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? value/30.48 : value/100
|
|
}
|
|
|
|
}
|
|
}
|
|
rows.push({label: label, data: value.toFixed(2)})
|
|
})
|
|
|
|
return (
|
|
<BinSensorCard
|
|
lastReading={lastReading}
|
|
name={sensor.name()}
|
|
tag={"Headspace"}
|
|
icon={icon}
|
|
rows={rows}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(sensor.type());
|
|
setSelectedComponentKey(sensor.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
|
|
)
|
|
}
|
|
|
|
const controllerCard = (controller: Controller) => {
|
|
const component = controller.asComponent()
|
|
let icon = GetComponentIcon(component.type(), component.subType(), themeType)
|
|
const isStale = moment().diff(moment(controller.lastReading), "hours") > 6
|
|
const isAuto = controller.mode === quack.OutputMode.OUTPUT_MODE_AUTO
|
|
const isOn = controller.mode === quack.OutputMode.OUTPUT_MODE_ON
|
|
const isOff = controller.mode === quack.OutputMode.OUTPUT_MODE_OFF
|
|
|
|
const prefType = preferences?.get(controller.key())?.type
|
|
const tag = prefType === pond.BinComponent.BIN_COMPONENT_FAN ? "Fan" : "Heater"
|
|
|
|
const segStyle = (active: boolean, activeColor?: string): React.CSSProperties => ({
|
|
flex: 1,
|
|
padding: "5px 0",
|
|
fontSize: 11,
|
|
fontWeight: 500,
|
|
textAlign: "center",
|
|
cursor: "pointer",
|
|
borderRadius: 0,
|
|
backgroundColor: active ? (activeColor ?? theme.palette.success.main) : "transparent",
|
|
color: active ? "#fff" : theme.palette.text.secondary,
|
|
border: "none",
|
|
transition: "background-color 0.15s",
|
|
})
|
|
|
|
return (
|
|
<Card raised sx={{ height: "100%" }}>
|
|
<Box padding={1.5} height="100%" display="flex" flexDirection="column">
|
|
|
|
<Box display="flex" justifyContent="space-between" alignItems="center" marginBottom={1}>
|
|
<Box display="flex" alignItems="center" gap={1}>
|
|
<Avatar
|
|
variant="square"
|
|
src={icon}
|
|
alt={controller.name() + " icon"}
|
|
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
|
|
/>
|
|
<Typography fontWeight={500}>{controller.name()}</Typography>
|
|
<Typography variant="caption" color="textSecondary">- {tag}</Typography>
|
|
</Box>
|
|
<IconButton
|
|
size="small"
|
|
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
setComponentUnnassigned(false)
|
|
setSelectedComponentType(controller.type())
|
|
setSelectedComponentKey(controller.key())
|
|
setAnchorEl(event.currentTarget)
|
|
}}
|
|
>
|
|
<MoreVert fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<Box display="flex" justifyContent="space-between" alignItems="center" marginBottom={1.5}>
|
|
<Typography variant="caption" color="textSecondary">Current state</Typography>
|
|
<Box display="flex" alignItems="center" gap={0.5}>
|
|
<Box
|
|
sx={{
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: "50%",
|
|
backgroundColor: controller.on
|
|
? theme.palette.success.main
|
|
: theme.palette.text.disabled,
|
|
}}
|
|
/>
|
|
<Typography
|
|
variant="caption"
|
|
fontWeight={500}
|
|
color={controller.on ? "success.main" : "text.secondary"}
|
|
>
|
|
{controller.on ? "Running" : isAuto ? "Standby" : "Off"}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box
|
|
display="flex"
|
|
sx={{
|
|
border: `0.5px solid ${theme.palette.divider}`,
|
|
borderRadius: 1,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{[
|
|
{ label: "Auto", mode: quack.OutputMode.OUTPUT_MODE_AUTO, active: isAuto },
|
|
{ label: "On", mode: quack.OutputMode.OUTPUT_MODE_ON, active: isOn },
|
|
{ label: "Off", mode: quack.OutputMode.OUTPUT_MODE_OFF, active: isOff },
|
|
].map(({ label, mode, active }, i) => (
|
|
<Box
|
|
key={label}
|
|
component="button"
|
|
onClick={() => {
|
|
//this will update the components settings here to change the mode
|
|
console.log("changing mode: " + mode)
|
|
controller.settings.defaultOutputState = mode
|
|
let device = componentDevices.get(controller.key())
|
|
if(device){
|
|
componentAPI.update(device, controller.settings).then(resp => {
|
|
controller.mode = mode // ← update the field the UI reads
|
|
|
|
if (prefType === pond.BinComponent.BIN_COMPONENT_FAN) {
|
|
setFans(prev => prev.map(f => f.key() === controller.key() ? controller : f))
|
|
} else {
|
|
setHeaters(prev => prev.map(h => h.key() === controller.key() ? controller : h))
|
|
}
|
|
}).catch(err => {
|
|
console.log("there was a problem updating the controller")
|
|
})
|
|
}
|
|
|
|
}}
|
|
sx={{
|
|
...segStyle(active, label === "Off" ? theme.palette.action.selected : undefined),
|
|
borderLeft: i > 0 ? `0.5px solid ${theme.palette.divider}` : "none",
|
|
}}
|
|
>
|
|
{label}
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
<Box display="flex" alignItems="center" gap={0.5} marginTop="auto" paddingTop={1}>
|
|
<AccessTime sx={{ fontSize: 12, color: isStale ? "warning.main" : "text.disabled" }} />
|
|
<Typography variant="caption" color={isStale ? "warning.main" : "text.secondary"}>
|
|
{moment(controller.lastReading).fromNow()}
|
|
</Typography>
|
|
</Box>
|
|
|
|
</Box>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const unassignedComponentCard = (component: Component) => {
|
|
let icon = GetComponentIcon(component.type(), component.subType(), themeType)
|
|
const lastReading = component.lastMeasurement.length > 0
|
|
? component.lastMeasurement[0].timestamps[0] ?? ""
|
|
: ""
|
|
const isStale = moment().diff(moment(lastReading), "hours") > 6
|
|
|
|
return (
|
|
<Card raised sx={{ height: "100%" }}>
|
|
<Box padding={1.5} height="100%" display="flex" flexDirection="column">
|
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
|
<Box display="flex" alignItems="center" gap={1}>
|
|
<Avatar
|
|
variant="square"
|
|
src={icon}
|
|
alt={component.name() + " icon"}
|
|
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
|
|
/>
|
|
<Typography fontWeight={500}>{component.name()}</Typography>
|
|
</Box>
|
|
<IconButton
|
|
size="small"
|
|
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
setComponentUnnassigned(true)
|
|
setSelectedComponentType(component.type())
|
|
setSelectedComponentKey(component.key())
|
|
setSelectedComponentSubtype(component.subType())
|
|
setSelectedComponentTopNode(0)
|
|
setAnchorEl(event.currentTarget)
|
|
}}
|
|
>
|
|
<MoreVert fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<Box display="flex" alignItems="center" gap={0.5} marginTop="auto">
|
|
<AccessTime sx={{ fontSize: 12, color: isStale ? "warning.main" : "text.disabled" }} />
|
|
<Typography variant="caption" color={isStale ? "warning.main" : "text.secondary"}>
|
|
{lastReading ? moment(lastReading).fromNow() : "No readings yet"}
|
|
</Typography>
|
|
</Box>
|
|
|
|
</Box>
|
|
</Card>
|
|
)
|
|
}
|
|
const updateDateRange = (newStartDate: any, newEndDate: any) => {
|
|
let range = GetDefaultDateRange();
|
|
range.start = newStartDate;
|
|
range.end = newEndDate;
|
|
setStartDate(newStartDate);
|
|
setEndDate(newEndDate);
|
|
};
|
|
|
|
const graphControls = () => {
|
|
return (
|
|
<Box display="flex" gap={2} alignItems="center">
|
|
<Button variant="contained" color="primary" onClick={() => {
|
|
setShowGraphs(!showGraphs)
|
|
}}>
|
|
{showGraphs ? "Show Cards" : "Show Graphs"}
|
|
</Button>
|
|
{showGraphs && !isMobile &&
|
|
<TimeBar updateDateRange={updateDateRange} startDate={startDate} endDate={endDate} />
|
|
}
|
|
{cables.length > 0 &&
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={!filterNodes}
|
|
onChange={(e, checked) => {
|
|
setFilterNodes(!filterNodes)
|
|
}}
|
|
/>
|
|
}
|
|
label={<Typography>Show All Nodes
|
|
</Typography>}
|
|
/>
|
|
}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Box padding={1}>
|
|
{assignmentMenu()}
|
|
{sensorCount > 0 &&
|
|
<Box>
|
|
<Box display="flex" gap={2} alignItems="center" marginBottom={1}>
|
|
<Typography sx={{fontWeight: 650, fontSize: 25}}>Sensors</Typography>
|
|
{graphControls()}
|
|
</Box>
|
|
{showGraphs && isMobile &&
|
|
<TimeBar updateDateRange={updateDateRange} startDate={startDate} endDate={endDate} />
|
|
}
|
|
<Grid2 container spacing={2}>
|
|
{plenums.map(plenum => {
|
|
let device = componentDevices.get(plenum.key())
|
|
if(showGraphs && device){
|
|
let icon = GetComponentIcon(plenum.type(), plenum.subType(), themeType)
|
|
return(
|
|
<Grid2 key={plenum.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
<BinSensorGraph
|
|
icon={icon}
|
|
title={plenum.name()}
|
|
tag={"Plenum"}
|
|
device={device}
|
|
component={components.get(plenum.key())!}
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(plenum.type());
|
|
setSelectedComponentKey(plenum.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
</Grid2>
|
|
)
|
|
}
|
|
return(
|
|
<Grid2 key={plenum.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
{tempHumidCard(plenum)}
|
|
</Grid2>
|
|
)
|
|
}
|
|
)}
|
|
{pressures.map(pressure => {
|
|
let device = componentDevices.get(pressure.key())
|
|
if(showGraphs && device){
|
|
let icon = GetComponentIcon(pressure.type(), pressure.subType(), themeType)
|
|
return(
|
|
<Grid2 key={pressure.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
<BinSensorGraph
|
|
icon={icon}
|
|
title={pressure.name()}
|
|
tag={"Pressure"}
|
|
device={device}
|
|
component={components.get(pressure.key())!}
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(pressure.type());
|
|
setSelectedComponentKey(pressure.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
</Grid2>
|
|
)
|
|
}
|
|
return(
|
|
<Grid2 key={pressure.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
{pressureCard(pressure)}
|
|
</Grid2>
|
|
)
|
|
})}
|
|
{ambient.map(ambient => {
|
|
let device = componentDevices.get(ambient.key())
|
|
if(showGraphs && device){
|
|
let icon = GetComponentIcon(ambient.type(), ambient.subType(), themeType)
|
|
return(
|
|
<Grid2 key={ambient.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
<BinSensorGraph
|
|
icon={icon}
|
|
title={ambient.name()}
|
|
tag={"Ambient"}
|
|
device={device}
|
|
component={components.get(ambient.key())!}
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(ambient.type());
|
|
setSelectedComponentKey(ambient.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
</Grid2>
|
|
)
|
|
}
|
|
return(
|
|
<Grid2 key={ambient.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
{tempHumidCard(ambient)}
|
|
</Grid2>
|
|
)
|
|
}
|
|
)}
|
|
{headspaceTH.map(h => {
|
|
let device = componentDevices.get(h.key())
|
|
if(showGraphs && device){
|
|
let icon = GetComponentIcon(h.type(), h.subType(), themeType)
|
|
return(
|
|
<Grid2 key={h.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
<BinSensorGraph
|
|
icon={icon}
|
|
title={h.name()}
|
|
tag={"Headspace"}
|
|
device={device}
|
|
component={components.get(h.key())!}
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(h.type());
|
|
setSelectedComponentKey(h.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
</Grid2>
|
|
)
|
|
}
|
|
return(
|
|
<Grid2 key={h.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
{tempHumidCard(h)}
|
|
</Grid2>
|
|
)
|
|
})}
|
|
|
|
{headspaceCO2.map(co2 => {
|
|
let device = componentDevices.get(co2.key())
|
|
if(showGraphs && device){
|
|
let icon = GetComponentIcon(co2.type(), co2.subType(), themeType)
|
|
return(
|
|
<Grid2 key={co2.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
<BinSensorGraph
|
|
icon={icon}
|
|
title={co2.name()}
|
|
tag={"Headspace"}
|
|
device={device}
|
|
component={components.get(co2.key())!}
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(co2.type());
|
|
setSelectedComponentKey(co2.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
</Grid2>
|
|
)
|
|
}
|
|
return(
|
|
<Grid2 key={co2.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
{co2Card(co2)}
|
|
</Grid2>
|
|
)
|
|
})}
|
|
{headspaceLidar.map(lidar => {
|
|
let device = componentDevices.get(lidar.key())
|
|
if(showGraphs && device){
|
|
let icon = GetComponentIcon(lidar.type(), lidar.subType(), themeType)
|
|
return(
|
|
<Grid2 key={lidar.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
<BinSensorGraph
|
|
icon={icon}
|
|
title={lidar.name()}
|
|
tag={"Headspace"}
|
|
device={device}
|
|
component={components.get(lidar.key())!}
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(lidar.type());
|
|
setSelectedComponentKey(lidar.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
</Grid2>
|
|
)
|
|
}
|
|
return (
|
|
<Grid2 key={lidar.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
{lidarCard(lidar)}
|
|
</Grid2>
|
|
)
|
|
})}
|
|
{cables.map(cable => {
|
|
let device = componentDevices.get(cable.key())
|
|
if(showGraphs && device){
|
|
let icon = GetComponentIcon(cable.type(), cable.subType(), themeType)
|
|
return(
|
|
<Grid2 key={cable.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
<BinSensorGraph
|
|
icon={icon}
|
|
title={cable.name()}
|
|
tag={"Cable Averages"}
|
|
device={device}
|
|
component={components.get(cable.key())!}
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
allNodes={!filterNodes}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(cable.type());
|
|
setSelectedComponentKey(cable.key());
|
|
setSelectedComponentTopNode(cable.settings.grainFilledTo);
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
</Grid2>
|
|
)
|
|
}
|
|
return(
|
|
<Grid2 key={cable.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
{cableCard(cable)}
|
|
</Grid2>
|
|
)
|
|
})}
|
|
</Grid2>
|
|
</Box>
|
|
}
|
|
|
|
{controllerCount > 0 &&
|
|
<Box>
|
|
<Typography sx={{fontWeight: 650, fontSize: 25}}>Controllers</Typography>
|
|
<Grid2 container spacing={2}>
|
|
{heaters.map(heater => {
|
|
let device = componentDevices.get(heater.key())
|
|
if(showGraphs && device){
|
|
let icon = GetComponentIcon(heater.type(), heater.subType(), themeType)
|
|
return(
|
|
<Grid2 key={heater.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
<BinSensorGraph
|
|
device={device}
|
|
icon={icon}
|
|
title={heater.name()}
|
|
tag={"Heater"}
|
|
component={components.get(heater.key())!}
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(heater.type());
|
|
setSelectedComponentKey(heater.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
</Grid2>
|
|
)
|
|
}
|
|
return (
|
|
<Grid2 key={heater.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
{controllerCard(heater)}
|
|
</Grid2>
|
|
)
|
|
})}
|
|
{fans.map(fan => {
|
|
let device = componentDevices.get(fan.key())
|
|
if(showGraphs && device){
|
|
let icon = GetComponentIcon(fan.type(), fan.subType(), themeType)
|
|
return(
|
|
<Grid2 key={fan.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
<BinSensorGraph
|
|
device={device}
|
|
icon={icon}
|
|
title={fan.name()}
|
|
tag={"Fan"}
|
|
component={components.get(fan.key())!}
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onMenuClick={(event) => {
|
|
setComponentUnnassigned(false);
|
|
setSelectedComponentType(fan.type());
|
|
setSelectedComponentKey(fan.key());
|
|
setAnchorEl(event.currentTarget);
|
|
}}
|
|
/>
|
|
</Grid2>
|
|
)
|
|
}
|
|
return (
|
|
<Grid2 key={fan.key()} size={{xs: 12, sm: 6, md: 4, lg: 3}}>
|
|
{controllerCard(fan)}
|
|
</Grid2>
|
|
)
|
|
})}
|
|
</Grid2>
|
|
</Box>
|
|
}
|
|
{unassignedComponents.length > 0 &&
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 650, fontSize: 25 }}>Unassigned Components</Typography>
|
|
<Grid2 container spacing={2}>
|
|
{unassignedComponents.map(comp => (
|
|
<Grid2 key={comp.key()} size={{ xs: 6, sm: 6, md: 4, lg: 3 }}>
|
|
{unassignedComponentCard(comp)}
|
|
</Grid2>
|
|
))}
|
|
</Grid2>
|
|
</Box>
|
|
}
|
|
</Box>
|
|
)
|
|
} |