bin sensors add the bins

This commit is contained in:
Carter 2025-03-13 09:26:47 -06:00
parent c46cb5ce4b
commit b5071dfb40
9 changed files with 7346 additions and 20 deletions

View file

@ -28,7 +28,7 @@ import ObjectTeams from "teams/ObjectTeams";
// import BinDuplication from "./BinDuplication";
import BinsIcon from "products/Bindapt/BinsIcon";
import HelpIcon from "@mui/icons-material/Help";
// import BinSensors from "./BinSensors";
import BinSensors from "./BinSensors";
import { useGlobalState, useSnackbar, useUserAPI } from "providers";
import { useMobile } from "hooks";
import { makeStyles } from "@mui/styles";
@ -239,7 +239,7 @@ export default function BinActions(props: Props) {
setOpenState({ ...openState, settings: false });
}}
/>
{/* <BinSensors
<BinSensors
mode="update"
bin={bin}
canEdit={hasWritePermission}
@ -254,7 +254,7 @@ export default function BinActions(props: Props) {
}
setOpenState({ ...openState, sensors: false });
}}
/> */}
/>
<ShareObject
scope={binScope(key)}
label={label}

View file

@ -0,0 +1,926 @@
import {
Avatar,
Box,
darken,
Divider,
Grid2 as Grid,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
ListSubheader,
Menu,
MenuItem,
Theme,
Typography,
useTheme
} from "@mui/material";
import { Component } from "models";
import { GetComponentIcon } from "pbHelpers/ComponentType";
import { pond, quack } from "protobuf-ts/pond";
import { useBinAPI, useSnackbar, useGlobalState } from "providers";
import React, { useState, useEffect, useCallback } from "react";
import { getThemeType } from "theme";
import { Plenum } from "models/Plenum";
import { Controller } from "models/Controller";
import { GrainCable } from "models/GrainCable";
import { Pressure } from "models/Pressure";
import { Headspace } from "models/Headspace";
import { MoreVert } from "@mui/icons-material";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { Ambient } from "models/Ambient";
import { makeStyles } from "@mui/styles";
const useStyles = makeStyles((theme: Theme) => {
return ({
bgItem: {
background: darken(theme.palette.background.paper, getThemeType() === "light" ? 0.05 : 0.25),
position: "relative",
width: "auto",
borderRadius: theme.spacing(0.5),
display: "flex",
flexDirection: "column",
padding: theme.spacing(0.5)
},
avatar: {
width: theme.spacing(3),
height: theme.spacing(3),
marginTop: "auto",
marginBottom: "auto"
},
avatarIcon: {
background: "transparent",
width: theme.spacing(3),
height: theme.spacing(3),
margin: "auto",
marginBottom: "auto"
},
spacingItem: {
paddingLeft: theme.spacing(0.75),
paddingRight: theme.spacing(1),
display: "flex",
flexDirection: "row",
justifyContent: "center"
},
pointerLeft: {
borderBottom: "1px solid",
borderLeft: "1px solid",
borderColor: theme.palette.text.primary,
width: theme.spacing(5.5),
height: theme.spacing(0.7)
},
pointerRight: {
borderBottom: "1px solid white",
borderLeft: "1px solid white",
borderRight: "1px solid white",
borderColor: theme.palette.text.primary,
width: theme.spacing(5.5),
height: theme.spacing(0.7)
},
spacer: {
paddingLeft: theme.spacing(0.25)
},
smallFont: {
fontSize: "10px"
},
bigSpacer: {
paddingLeft: theme.spacing(3.2)
}
});
});
interface Props {
bin: string;
components: Map<string, Component>;
preferences: Map<string, pond.BinComponentPreferences>;
setPreferences: React.Dispatch<
React.SetStateAction<Map<string, pond.BinComponentPreferences> | undefined>
>;
updateBinStatus: (componentKeys: string[], removed?: boolean) => void;
setComponents: React.Dispatch<React.SetStateAction<Map<string, Component>>>;
}
export default function BinComponentTypes(props: Props) {
const { bin, components, preferences, setPreferences, updateBinStatus } = props;
const classes = useStyles();
const theme = useTheme();
const binAPI = useBinAPI();
const snackbar = useSnackbar();
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 [unassigned, setUnassigned] = useState<Component[]>([]);
const [plenums, setPlenums] = useState<Plenum[]>([]);
const [ambients, setAmbients] = useState<Ambient[]>([]);
const [pressures, setPressures] = useState<Pressure[]>([]);
const [grainCables, setGrainCables] = useState<GrainCable[]>([]);
const [headspace, setHeadspace] = useState<Headspace[]>([]);
const [heaters, setHeaters] = useState<Controller[]>([]);
const [fans, setFans] = useState<Controller[]>([]);
const [lidars, setLidars] = useState<Component[]>([]);
const [headspaceCo2s, setHeadspaceCo2s] = useState<Component[]>([]);
const [tempUnit, setTempUnit] = useState<pond.TemperatureUnit>();
const [pressureUnit, setPressureUnit] = useState<pond.PressureUnit>();
const [selectedComponentKey, setSelectedComponentKey] = useState("");
const [selectedComponentSubtype, setSelectedComponentSubtype] = useState(0);
const [selectedComponentTopNode, setSelectedComponentTopNode] = useState(0);
const [{ user }] = useGlobalState();
useEffect(() => {
if (user.settings.temperatureUnit) {
setTempUnit(user.settings.temperatureUnit);
} else {
setTempUnit(pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS);
}
if (user.settings.pressureUnit) {
setPressureUnit(user.settings.pressureUnit);
} else {
setPressureUnit(pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER);
}
}, [user]);
const selectType = useCallback(
(component: string, type: pond.BinComponent, topNode?: number) => {
setAnchorEl(null);
let p = preferences.get(component);
if (p) {
p.type = type;
p.node = topNode ?? 0;
binAPI
.updateComponentPreferences(bin, component, p)
.then(() => {
snackbar.success("Component preferences updated");
preferences.set(component, p!);
setPreferences(new Map(preferences));
})
.catch(() => {
snackbar.error("Component preference update failed");
});
}
},
[bin, binAPI, preferences, setPreferences, snackbar]
);
useEffect(() => {
var plenums: Plenum[] = [];
var grainCables: GrainCable[] = [];
var heaters: Controller[] = [];
var fans: Controller[] = [];
var pressures: Pressure[] = [];
var headspace: Headspace[] = [];
var lidar: Component[] = [];
var unassigned: Component[] = [];
var ambients: Ambient[] = [];
var headspaceCo2s: Component[] = [];
components.forEach(comp => {
let pref = preferences.get(comp.key());
if (pref) {
if (pref.type) {
if (pref.type === pond.BinComponent.BIN_COMPONENT_PLENUM)
plenums.push(Plenum.create(comp));
if (pref.type === pond.BinComponent.BIN_COMPONENT_AMBIENT)
ambients.push(Ambient.create(comp));
if (pref.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE)
grainCables.push(GrainCable.create(comp));
if (pref.type === pond.BinComponent.BIN_COMPONENT_HEATER)
heaters.push(Controller.create(comp));
if (pref.type === pond.BinComponent.BIN_COMPONENT_FAN) fans.push(Controller.create(comp));
if (pref.type === pond.BinComponent.BIN_COMPONENT_PRESSURE)
pressures.push(Pressure.create(comp));
if (pref.type === pond.BinComponent.BIN_COMPONENT_HEADSPACE) {
if (comp.type() === quack.ComponentType.COMPONENT_TYPE_DHT) {
headspace.push(Headspace.create(comp));
} else if (comp.type() === quack.ComponentType.COMPONENT_TYPE_LIDAR) {
lidar.push(comp);
} else if (comp.type() === quack.ComponentType.COMPONENT_TYPE_CO2) {
headspaceCo2s.push(comp);
}
}
} else {
unassigned.push(comp);
}
} else {
unassigned.push(comp);
}
});
setPlenums(plenums);
setAmbients(ambients);
setGrainCables(grainCables);
setHeaters(heaters);
setFans(fans);
setPressures(pressures);
setHeadspace(headspace);
setLidars(lidar);
setHeadspaceCo2s(headspaceCo2s);
setUnassigned(unassigned);
}, [components, preferences, setPlenums, setGrainCables, setHeaters, selectType, setPressures]);
const getOptions = () => {
let options: JSX.Element[] = [];
if (!componentUnassigned) {
options.push(
<MenuItem
key="remove"
onClick={() => {
selectType(selectedComponentKey, pond.BinComponent.BIN_COMPONENT_UNKNOWN);
updateBinStatus([selectedComponentKey], true);
}}>
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);
updateBinStatus([selectedComponentKey]);
}}>
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 grainCableList = () => {
if (grainCables.length < 1) return null;
return (
<React.Fragment>
<ListSubheader component="div" disableGutters>
Grain Cables
</ListSubheader>
<Divider />
{grainCables.map((cable, index) => {
let cIcon = GetComponentIcon(
cable.settings.type,
cable.settings.subtype,
theme.palette.mode
);
return (
<ListItem key={index} style={{ height: theme.spacing(8) }}>
<ListItemAvatar>
<Avatar
variant="square"
src={cIcon}
alt={cable.name()}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
</ListItemAvatar>
<ListItemText inset={cIcon === undefined}>{cable.name()}</ListItemText>
<ListItemSecondaryAction>
<Grid container direction="row" alignItems="center">
<Grid>
<Box className={classes.bgItem}>
<Box style={{ display: "flex", flexDirection: "row" }}>
<Box>
<Typography variant="body2" color="textSecondary">
{tempUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
? "°F"
: "°C"}
</Typography>
</Box>
<Box display={"flex"} alignItems="center" textAlign="center">
<Typography
variant="body2"
style={{ color: cable.tempColour(), marginLeft: theme.spacing(1.5) }}>
{isFinite(cable.minTemp(tempUnit)) && !isNaN(cable.minTemp(tempUnit))
? cable.minTemp(tempUnit).toFixed(1)
: "--"}
</Typography>
<Typography
variant="body2"
style={{ color: cable.tempColour(), marginLeft: theme.spacing(1.5) }}>
{isFinite(cable.aveTemp(tempUnit)) && !isNaN(cable.aveTemp(tempUnit))
? cable.aveTemp(tempUnit).toFixed(1)
: "--"}
</Typography>
<Typography
variant="body2"
style={{ color: cable.tempColour(), marginLeft: theme.spacing(1.5) }}>
{isFinite(cable.maxTemp(tempUnit)) && !isNaN(cable.maxTemp(tempUnit))
? cable.maxTemp(tempUnit).toFixed(1)
: "--"}
</Typography>
</Box>
</Box>
<Box marginY={0.25}>
<Divider style={{ background: "gold" }} />
</Box>
<Box style={{ display: "flex", flexDirection: "row" }}>
<Box>
<Typography
variant="body2"
color="textSecondary"
style={{ marginLeft: theme.spacing(0.4) }}>
%
</Typography>
</Box>
<Box display={"flex"} alignItems="center" textAlign="center">
<Typography
variant="body2"
style={{ color: cable.humidColour(), marginLeft: theme.spacing(1.5) }}>
{isFinite(cable.minHumidity()) && !isNaN(cable.minHumidity())
? cable.minHumidity().toFixed(1)
: "--"}
</Typography>
<Typography
variant="body2"
style={{ color: cable.humidColour(), marginLeft: theme.spacing(1.5) }}>
{isFinite(cable.aveHumidity()) && !isNaN(cable.aveHumidity())
? cable.aveHumidity().toFixed(1)
: "--"}
</Typography>
<Typography
variant="body2"
style={{ color: cable.humidColour(), marginLeft: theme.spacing(1.5) }}>
{isFinite(cable.maxHumidity()) && !isNaN(cable.maxHumidity())
? cable.maxHumidity().toFixed(1)
: "--"}
</Typography>
</Box>
</Box>
</Box>
</Grid>
<Grid >
<IconButton
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
setComponentUnnassigned(false);
setSelectedComponentType(cable.type());
setSelectedComponentKey(cable.key());
setSelectedComponentTopNode(cable.settings.grainFilledTo);
setAnchorEl(event.currentTarget);
}}>
<MoreVert />
</IconButton>
</Grid>
</Grid>
</ListItemSecondaryAction>
</ListItem>
);
})}
</React.Fragment>
);
};
const controllerList = (heaters: Controller[], fans: Controller[]) => {
if (heaters.length < 1 && fans.length < 1) return null;
let controllers = heaters.concat(fans);
return (
<React.Fragment>
<ListSubheader component="div" disableGutters>
Heaters & Fans
</ListSubheader>
<Divider />
{controllers.map((controller, index) => {
let cIcon = GetComponentIcon(
controller.settings.type,
controller.settings.subtype,
theme.palette.mode
);
return (
<ListItem key={index}>
<ListItemAvatar>
<Avatar
variant="square"
src={cIcon}
alt={controller.name()}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
</ListItemAvatar>
<ListItemText inset={cIcon === undefined}>{controller.name()}</ListItemText>
<ListItemSecondaryAction>
<Grid container direction="row" alignItems="center">
<Grid >
<Box className={classes.bgItem} padding={1}>
<div style={{ display: "flex", flexDirection: "row", margin: "auto" }}>
<Typography variant="body2" style={{ color: controller.color() }}>
{controller.on ? "On" : "Off"}
</Typography>
</div>
</Box>
</Grid>
<Grid >
<IconButton
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
setComponentUnnassigned(false);
setSelectedComponentType(controller.type());
setSelectedComponentSubtype(controller.subType());
setSelectedComponentKey(controller.key());
setAnchorEl(event.currentTarget);
}}>
<MoreVert />
</IconButton>
</Grid>
</Grid>
</ListItemSecondaryAction>
</ListItem>
);
})}
</React.Fragment>
);
};
const pressureList = () => {
if (pressures.length < 1) return null;
return (
<React.Fragment>
<ListSubheader component="div" disableGutters>
Plenum Pressure
</ListSubheader>
<Divider />
{pressures.map((component, index) => {
let cIcon = GetComponentIcon(
component.settings.type,
component.settings.subtype,
theme.palette.mode
);
return (
<ListItem key={index}>
<ListItemAvatar>
<Avatar
variant="square"
src={cIcon}
alt={component.name()}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
</ListItemAvatar>
<ListItemText inset={cIcon === undefined}>{component.name()}</ListItemText>
<ListItemSecondaryAction>
<Grid container direction="row" alignItems="center">
<Grid>
<Box className={classes.bgItem} padding={1}>
<div style={{ display: "flex", flexDirection: "row", margin: "auto" }}>
<Typography variant="body2" style={{ color: Pressure.colour() }}>
{component.getPressureString(pressureUnit)}
</Typography>
</div>
</Box>
</Grid>
<Grid>
<IconButton
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
setComponentUnnassigned(false);
setSelectedComponentType(component.type());
setSelectedComponentKey(component.key());
setAnchorEl(event.currentTarget);
}}>
<MoreVert />
</IconButton>
</Grid>
</Grid>
</ListItemSecondaryAction>
</ListItem>
);
})}
</React.Fragment>
);
};
const plenumList = (plenums: Plenum[]) => {
if (plenums.length < 1) return null;
return (
<React.Fragment>
<ListSubheader component="div" disableGutters>
Plenums
</ListSubheader>
<Divider />
{plenums.map((component, index) => {
let cIcon = GetComponentIcon(
component.settings.type,
component.settings.subtype,
theme.palette.mode
);
return (
<ListItem key={index}>
<ListItemAvatar>
<Avatar
variant="square"
src={cIcon}
alt={component.name()}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
</ListItemAvatar>
<ListItemText inset={cIcon === undefined}>{component.name()}</ListItemText>
<ListItemSecondaryAction>
<Grid container direction="row" alignItems="center">
<Grid>
<Box className={classes.bgItem} padding={1}>
<div style={{ display: "flex", flexDirection: "row" }}>
<Typography variant="body2" style={{ color: Plenum.tempColour() }}>
{component.getTempString(user.settings.temperatureUnit) + ","}
</Typography>
<Typography
variant="body2"
style={{
color: Plenum.humidColour(),
marginLeft: theme.spacing(1)
}}>
{component.getHumidityString()}
</Typography>
</div>
</Box>
</Grid>
<Grid>
<IconButton
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
setComponentUnnassigned(false);
setSelectedComponentType(component.type());
setSelectedComponentKey(component.key());
setAnchorEl(event.currentTarget);
}}>
<MoreVert />
</IconButton>
</Grid>
</Grid>
</ListItemSecondaryAction>
</ListItem>
);
})}
</React.Fragment>
);
};
const ambientList = (ambients: Ambient[]) => {
if (ambients.length < 1) return null;
return (
<React.Fragment>
<ListSubheader component="div" disableGutters>
Ambient
</ListSubheader>
<Divider />
{ambients.map((component, index) => {
let cIcon = GetComponentIcon(
component.settings.type,
component.settings.subtype,
theme.palette.mode
);
return (
<ListItem key={index}>
<ListItemAvatar>
<Avatar
variant="square"
src={cIcon}
alt={component.name()}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
</ListItemAvatar>
<ListItemText inset={cIcon === undefined}>{component.name()}</ListItemText>
<ListItemSecondaryAction>
<Grid container direction="row" alignItems="center">
<Grid>
<Box className={classes.bgItem} padding={1}>
<div style={{ display: "flex", flexDirection: "row" }}>
<Typography variant="body2" style={{ color: Ambient.tempColour() }}>
{component.getTempString(user.settings.temperatureUnit) + ","}
</Typography>
<Typography
variant="body2"
style={{
color: Ambient.humidColour(),
marginLeft: theme.spacing(1)
}}>
{component.getHumidityString()}
</Typography>
</div>
</Box>
</Grid>
<Grid>
<IconButton
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
setComponentUnnassigned(false);
setSelectedComponentType(component.type());
setSelectedComponentKey(component.key());
setAnchorEl(event.currentTarget);
}}>
<MoreVert />
</IconButton>
</Grid>
</Grid>
</ListItemSecondaryAction>
</ListItem>
);
})}
</React.Fragment>
);
};
const formatDistance = (distanceCM: number) => {
if (user.settings.distanceUnit === pond.DistanceUnit.DISTANCE_UNIT_METERS) {
return distanceCM / 100 + "m";
} else if (user.settings.distanceUnit === pond.DistanceUnit.DISTANCE_UNIT_FEET) {
return (distanceCM / 30.48).toFixed(2) + "ft";
}
return distanceCM + "cm";
};
const headspaceList = (sensors: Headspace[], lidars: Component[], headspaceCo2s: Component[]) => {
if (sensors.length < 1 && lidars.length < 1 && headspaceCo2s.length < 1) return null;
let headspaceComponents = lidars.concat(headspaceCo2s);
return (
<React.Fragment>
<ListSubheader component="div" disableGutters>
Headspace
</ListSubheader>
<Divider />
{sensors.map((component, index) => {
let cIcon = GetComponentIcon(
component.settings.type,
component.settings.subtype,
theme.palette.mode
);
return (
<ListItem key={index}>
<ListItemAvatar>
<Avatar
variant="square"
src={cIcon}
alt={component.name()}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
</ListItemAvatar>
<ListItemText inset={cIcon === undefined}>{component.name()}</ListItemText>
<ListItemSecondaryAction>
<Grid container direction="row" alignItems="center">
<Grid >
<Box className={classes.bgItem} padding={1}>
<div style={{ display: "flex", flexDirection: "row" }}>
<Typography variant="body2" style={{ color: Plenum.tempColour() }}>
{component.getTempString(user.settings.temperatureUnit) + ","}
</Typography>
<Typography
variant="body2"
style={{
color: Plenum.humidColour(),
marginLeft: theme.spacing(1)
}}>
{component.getHumidityString()}
</Typography>
</div>
</Box>
</Grid>
<Grid >
<IconButton
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
setComponentUnnassigned(false);
setSelectedComponentType(component.type());
setSelectedComponentKey(component.key());
setAnchorEl(event.currentTarget);
}}>
<MoreVert />
</IconButton>
</Grid>
</Grid>
</ListItemSecondaryAction>
</ListItem>
);
})}
{headspaceComponents.map((component, index) => {
let cIcon = GetComponentIcon(
component.settings.type,
component.settings.subtype,
theme.palette.mode
);
let colour = "white"; //default colour if there are no measurements to get the type from
let dString = "--";
if (
component.status.measurement[0] &&
component.status.measurement[0].values[0] &&
component.status.measurement[0].values[0].values[0]
) {
colour = describeMeasurement(
component.status.measurement[0].type,
component.type(),
component.subType()
).colour();
dString = formatDistance(component.status.measurement[0].values[0].values[0]);
}
return (
<ListItem key={index}>
<ListItemAvatar>
<Avatar
variant="square"
src={cIcon}
alt={component.name()}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
</ListItemAvatar>
<ListItemText inset={cIcon === undefined}>{component.name()}</ListItemText>
<ListItemSecondaryAction>
<Grid container direction="row" alignItems="center">
<Grid >
<Box className={classes.bgItem} padding={1}>
<div style={{ display: "flex", flexDirection: "row" }}>
<Typography
variant="body2"
style={{
color: colour
}}>
{dString}
</Typography>
</div>
</Box>
</Grid>
<Grid >
<IconButton
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
setComponentUnnassigned(false);
setSelectedComponentType(component.type());
setSelectedComponentKey(component.key());
setAnchorEl(event.currentTarget);
}}>
<MoreVert />
</IconButton>
</Grid>
</Grid>
</ListItemSecondaryAction>
</ListItem>
);
})}
</React.Fragment>
);
};
const unassignedList = (sensors: Component[]) => {
if (sensors.length < 1) return null;
return (
<React.Fragment>
<ListSubheader component="div" disableGutters>
Unassigned Components
</ListSubheader>
<Divider />
{sensors.map((component, index) => {
let cIcon = GetComponentIcon(
component.settings.type,
component.settings.subtype,
theme.palette.mode
);
return (
<ListItem key={index}>
<ListItemAvatar>
<Avatar
variant="square"
src={cIcon}
alt={component.name()}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
</ListItemAvatar>
<ListItemText inset={cIcon === undefined}>{component.name()}</ListItemText>
<ListItemSecondaryAction>
<IconButton
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
setComponentUnnassigned(true);
setSelectedComponentType(component.type());
setSelectedComponentSubtype(component.subType());
setSelectedComponentKey(component.key());
setAnchorEl(event.currentTarget);
}}>
<MoreVert />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
);
})}
</React.Fragment>
);
};
const noComponents = () => {
return (
<Box style={{ margin: "auto" }} textAlign="center">
<Typography variant="subtitle1" color="textSecondary">
No attached components.
</Typography>
<Typography variant="subtitle1" color="textSecondary">
Go to settings to assign components from your devices.
</Typography>
</Box>
);
};
if (components.size < 1) return noComponents();
return (
<React.Fragment>
<List
//subheader={<ListSubheader>Bin Components</ListSubheader>}
style={{ padding: theme.spacing(1), width: "100%" }}>
{plenumList(plenums)}
{ambientList(ambients)}
{headspaceList(headspace, lidars, headspaceCo2s)}
{grainCableList()}
{controllerList(heaters, fans)}
{pressureList()}
{unassignedList(unassigned)}
{assignmentMenu()}
</List>
</React.Fragment>
);
}

605
src/bin/BinComponents.tsx Normal file
View file

@ -0,0 +1,605 @@
import {
Autocomplete,
Avatar,
Box,
Button,
CircularProgress,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Grid2 as Grid,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemSecondaryAction,
ListItemText,
ListSubheader,
Tab,
Tabs,
TextField,
Theme,
Tooltip,
Typography,
useTheme
} from "@mui/material";
import { Component, Device } from "models";
import { pond } from "protobuf-ts/pond";
import React, { useCallback, useEffect, useState } from "react";
import { useComponentAPI, useDeviceAPI, useSnackbar } from "hooks";
// import { Autocomplete } from "@material-ui/lab";
import { useBinAPI } from "providers";
import { GetComponentIcon } from "pbHelpers/ComponentType";
import { CheckBox as CheckBoxIcon, CheckBoxOutlineBlank, Remove } from "@mui/icons-material";
import ResponsiveDialog from "common/ResponsiveDialog";
import { quack } from "protobuf-ts/quack";
import { GetProductDefaults } from "products/DeviceProduct";
import DeviceWizard from "device/DeviceWizard";
import { makeStyles } from "@mui/styles";
const useStyles = makeStyles((theme: Theme) => {
return ({
stepper: {
padding: theme.spacing(0.5)
},
secondaryColor: {
color: theme.palette.secondary.main
},
textSecondaryColor: {
color: theme.palette.text.secondary
},
bottomSpacing: {
marginBottom: theme.spacing(1)
},
tabs: {
width: "100%",
boxSizing: "border-box",
flexShrink: 1
},
tabSmall: {
width: "100%",
boxSizing: "border-box",
flexShrink: 1,
minWidth: "48px",
marginTop: 0,
paddingTop: 0
}
})
});
interface Props {
components?: Map<string, Component>;
setComponents?: React.Dispatch<React.SetStateAction<Map<string, Component>>>;
bin: string;
binGrain: pond.Grain;
updateBinStatus?: (componentKeys: string[], removed?: boolean) => void;
}
interface Option {
label: string;
id: number;
icon?: string;
}
export default function BinComponents(props: Props) {
const { components, bin, setComponents, updateBinStatus, binGrain } = props;
const classes = useStyles();
const binAPI = useBinAPI();
const theme = useTheme();
const deviceAPI = useDeviceAPI();
const componentAPI = useComponentAPI();
const snackbar = useSnackbar();
const [devicesLoading, setDevicesLoading] = useState<boolean>(false);
const [componentsLoading, setComponentsLoading] = useState<boolean>(false);
const [deviceOptions, setDeviceOptions] = useState<Option[]>([]);
const [devMap, setDevMap] = useState<Map<number, Device>>(new Map());
const [selectedDevice, setSelectedDevice] = useState<number>(-1);
const [deviceComponents] = useState<Map<number, Component[]>>(new Map<number, Component[]>());
const [activeStep, setActiveStep] = useState(0);
const [componentToRemove, setComponentToRemove] = useState<Component | undefined>(undefined);
const [removeAllDialog, setRemoveAllDialog] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
const [setupAllowed, setSetupAllowed] = useState(false);
const loadDevices = useCallback(() => {
let mounted = true;
setDevicesLoading(true);
deviceAPI
.list(100000, 0, "asc", "name")
.then(response => {
if (mounted) {
let data = response.data.devices ? response.data.devices : [];
let newDevMap: Map<number, Device> = new Map();
let devices: Option[] = data.map(d => {
let device = Device.create(pond.Device.fromObject(d));
newDevMap.set(device.id(), device);
return { id: device.id(), label: device.name() } as Option;
});
setDevMap(newDevMap);
setDeviceOptions(devices);
}
})
.catch(() => {
if (mounted) {
setDeviceOptions([]);
}
})
.finally(() => {
if (mounted) {
setDevicesLoading(false);
}
});
return () => {
mounted = false;
};
}, [deviceAPI]);
useEffect(() => {
loadDevices();
}, [loadDevices]);
const loadComponents = useCallback(
(forceLoad?: boolean) => {
if (selectedDevice < 1) return;
let comps = deviceComponents.get(selectedDevice);
if (comps === undefined || forceLoad) {
comps = [];
setComponentsLoading(true);
componentAPI
.list(selectedDevice, false, [selectedDevice.toString()], ["device"], true)
.then(resp => {
let d = pond.ListComponentsResponse.fromObject(resp.data);
d.components.forEach(comp => {
// Don't show modems
if (comp.settings?.type === quack.ComponentType.COMPONENT_TYPE_MODEM) return;
let c = Component.create(comp);
if (c.permissions.includes(pond.Permission.PERMISSION_WRITE)) {
setSetupAllowed(true);
}
comps!.push(c);
});
if (comps) deviceComponents.set(selectedDevice, comps);
})
.catch(err => {
snackbar.error(err);
})
.finally(() => {
setComponentsLoading(false);
});
}
},
[selectedDevice, componentAPI, deviceComponents, snackbar]
);
useEffect(() => {
loadComponents();
}, [loadComponents]);
// useEffect(() => {
// if (selectedDevice < 1) return;
// let comps = deviceComponents.get(selectedDevice);
// if (comps === undefined) {
// comps = [];
// setComponentsLoading(true);
// componentAPI
// .list(selectedDevice, false, [selectedDevice.toString()], ["device"], true)
// .then(resp => {
// let d = pond.ListComponentsResponse.fromObject(resp.data);
// d.components.forEach(comp => {
// // Don't show modems
// if (comp.settings?.type === quack.ComponentType.COMPONENT_TYPE_MODEM) return;
// let c = Component.create(comp)
// if(c.permissions.includes(pond.Permission.PERMISSION_WRITE)){
// setSetupAllowed(true)
// }
// comps!.push(c);
// });
// if (comps) deviceComponents.set(selectedDevice, comps);
// })
// .catch(err => {
// snackbar.error(err);
// })
// .finally(() => {
// setComponentsLoading(false);
// });
// }
// }, [selectedDevice, componentAPI, deviceComponents, snackbar]);
const removeComponent = (component: string) => {
binAPI.removeComponent(bin, component).then(() => {
if (components && setComponents) {
if (components.delete(component)) {
let newComponents = new Map(components);
setComponents(newComponents);
}
}
snackbar.info("Component removed from bin");
});
setComponentToRemove(undefined);
};
const removeComponentConfirmation = () => {
return (
<ResponsiveDialog
fullScreen={false}
open={componentToRemove !== undefined}
onClose={() => setComponentToRemove(undefined)}>
<DialogTitle>Remove {componentToRemove?.name()}?</DialogTitle>
<DialogContent>
<DialogContentText>
This will remove {componentToRemove?.name()} from this bin. If you don't have direct
access to this component or the device it is attached to, you will not be able to add it
back.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setComponentToRemove(undefined)} color="primary">
Cancel
</Button>
<Button onClick={() => removeComponent(componentToRemove!.key())} color="primary">
Remove
</Button>
</DialogActions>
</ResponsiveDialog>
);
};
const removeAllConfirmation = () => {
return (
<ResponsiveDialog
fullScreen={false}
open={removeAllDialog}
onClose={() => setRemoveAllDialog(false)}>
<DialogTitle>Remove All Components?</DialogTitle>
<DialogContent>
<DialogContentText>
This will remove All attached components from this bin. If you don't have direct access
to these components or the devices they are attached to, you will not be able to add
them back.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setRemoveAllDialog(false)} color="primary">
Cancel
</Button>
<Button onClick={() => removeAll()} color="primary">
Remove
</Button>
</DialogActions>
</ResponsiveDialog>
);
};
const determinePreset = (comp: Component, devProduct?: pond.DeviceProduct) => {
let preset = pond.BinComponent.BIN_COMPONENT_UNKNOWN;
if (devProduct) {
let defaults = GetProductDefaults(devProduct);
let plenumAddresses: string[] | undefined = defaults?.get("plenum");
let headspaceAddresses: string[] | undefined = defaults?.get("headspace");
let pressureAddresses: string[] | undefined = defaults?.get("pressure");
let heatersFansAddresses: string[] | undefined = defaults?.get("heatersFans");
if (plenumAddresses && plenumAddresses.includes(comp.locationString())) {
preset = pond.BinComponent.BIN_COMPONENT_PLENUM;
} else if (pressureAddresses && pressureAddresses.includes(comp.locationString())) {
preset = pond.BinComponent.BIN_COMPONENT_PRESSURE;
} else if (headspaceAddresses && headspaceAddresses.includes(comp.locationString())) {
preset = pond.BinComponent.BIN_COMPONENT_HEADSPACE;
} else if (heatersFansAddresses && heatersFansAddresses.includes(comp.locationString())) {
switch (comp.subType()) {
case quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_HEATER:
preset = pond.BinComponent.BIN_COMPONENT_HEATER;
break;
case quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_AERATION_FAN:
case quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_EXHAUST_FAN:
preset = pond.BinComponent.BIN_COMPONENT_FAN;
break;
}
} else if (comp.type() === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE) {
//grain cables should be treated as cables by default no matter what the address is
preset = pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE;
} // else if ("is a pressure cable"){}
}
return preset;
};
const toggleComponent = (device: number, component: Component, checked: boolean) => {
if (checked) {
let devObj = devMap.get(device);
let preset = determinePreset(component, devObj?.settings.product);
let preferences = pond.BinComponentPreferences.create({
type: preset,
node: component.settings.grainFilledTo ?? 0
});
binAPI.addComponent(bin, device, component.key(), preferences).then(resp => {
if (components && setComponents) {
if (components.set(component.key(), component)) {
let newComponents = new Map(components);
setComponents(newComponents);
}
}
//if a grain cable was added to the bin update the components grain type to match the bin
if (preferences.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE) {
let settings = component.settings;
settings.grainType = binGrain;
settings.defaultMutations = [pond.Mutator.MUTATOR_EMC];
componentAPI
.update(device, settings)
.then(resp => {
snackbar.info("Component added to bin, and updated cables grain type");
})
.catch(err => {
snackbar.info("Component added to bin, but failed to update grain type on cable");
});
} else {
snackbar.info("Component added to bin");
}
});
} else {
binAPI.removeComponent(bin, component.key()).then(resp => {
if (components && setComponents) {
if (components.delete(component.key())) {
let newComponents = new Map(components);
setComponents(newComponents);
}
}
snackbar.info("Component removed from bin");
});
}
};
const removeAll = () => {
binAPI.removeAllComponents(bin).then(resp => {
if (setComponents) {
setComponents(new Map());
}
if (components && updateBinStatus) {
updateBinStatus(Array.from(components.keys()), true);
}
snackbar.info("All components removed from bin");
setRemoveAllDialog(false);
});
};
const deviceComponentsDisplay = (device: number) => {
if (deviceComponents.get(device) && deviceComponents.get(device)!.length < 1) {
return (
<Typography
variant="subtitle2"
style={{
margin: theme.spacing(2),
display: "flex",
justifyContent: "center"
}}>
No Sensors
</Typography>
);
}
return (
<React.Fragment>
{deviceComponents.get(device)?.map((comp, index) => {
let cIcon = GetComponentIcon(
comp.settings.type,
comp.settings.subtype,
theme.palette.mode
);
let share = comp.permissions.includes(pond.Permission.PERMISSION_SHARE);
return (
<React.Fragment key={index}>
<Tooltip
title={
!share
? "User or team does not have share permissions on this device"
: "Share component with bin"
}
key={index}>
<div>
<ListItemButton
disabled={!share}
onClick={() =>
toggleComponent(device, comp, components?.get(comp.key()) === undefined)
}>
{cIcon && (
<ListItemAvatar>
<Avatar
variant="square"
src={cIcon}
alt={comp.name()}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
</ListItemAvatar>
)}
<ListItemText inset={cIcon === undefined}>
<Grid container direction="row" justifyContent="space-between">
<Grid >{comp.name()}</Grid>
<Grid >
{components?.get(comp.key()) !== undefined ? (
<CheckBoxIcon />
) : (
<CheckBoxOutlineBlank />
)}
</Grid>
</Grid>
</ListItemText>
</ListItemButton>
</div>
</Tooltip>
<Divider />
</React.Fragment>
);
})}
</React.Fragment>
);
};
const wizardDialog = () => {
let device = devMap.get(selectedDevice);
let components = deviceComponents.get(selectedDevice);
if (device && components) {
return (
<ResponsiveDialog
open={wizardOpen}
onClose={() => {
setWizardOpen(false);
}}>
<DeviceWizard
device={device}
components={components}
refreshCallback={() => {
loadComponents(true);
}}
/>
</ResponsiveDialog>
);
}
return;
};
const componentsByDevice = () => {
return (
<React.Fragment>
<Typography variant="subtitle2" align="center" color="primary" gutterBottom>
Bin Sensors
</Typography>
<Box marginY={1}>
<Autocomplete
disablePortal
options={deviceOptions}
getOptionLabel={option => option.label || ""}
onChange={(_, newValue) => {
setSelectedDevice(newValue ? newValue.id : -1);
}}
renderInput={params => <TextField {...params} variant="outlined" label="Device" />}
loading={devicesLoading}
/>
{selectedDevice < 0 ? (
<Typography
variant="subtitle2"
style={{
margin: theme.spacing(3),
display: "flex",
justifyContent: "center"
}}>
Select a device
</Typography>
) : componentsLoading ? (
<CircularProgress />
) : (
<React.Fragment>
{setupAllowed && (
<Button
variant="contained"
color="primary"
onClick={() => {
setWizardOpen(true);
}}
style={{ marginTop: 15 }}>
Device Setup
</Button>
)}
<List
disablePadding
subheader={
deviceComponents?.get(selectedDevice) && (
<React.Fragment>
<ListSubheader component="div" disableGutters>
Choose Sensors
</ListSubheader>
<Divider />
</React.Fragment>
)
}>
{deviceComponentsDisplay(selectedDevice)}
</List>
</React.Fragment>
)}
</Box>
</React.Fragment>
);
};
const attachedComponents = () => {
if (components === undefined) return;
let comps = [...components.values()];
return (
<React.Fragment>
<Button
onClick={() => {
setRemoveAllDialog(true);
}}>
Remove All Components
</Button>
<List>
<ListSubheader component="div" disableGutters>
This Bin's Sensors
</ListSubheader>
<Divider />
{comps.length < 1 && (
<Typography
variant="subtitle2"
style={{
margin: theme.spacing(2),
display: "flex",
justifyContent: "center"
}}>
No Sensors
</Typography>
)}
{comps.map((component, index) => {
let cIcon = GetComponentIcon(
component.settings.type,
component.settings.subtype,
theme.palette.mode
);
return (
<React.Fragment key={index}>
<ListItem>
<ListItemAvatar>
<Avatar
variant="square"
src={cIcon}
alt={component.name()}
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
/>
</ListItemAvatar>
<ListItemText inset={cIcon === undefined}>{component.name()}</ListItemText>
<ListItemSecondaryAction>
<IconButton onClick={() => setComponentToRemove(component)}>
<Remove />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
<Divider />
</React.Fragment>
);
})}
</List>
</React.Fragment>
);
};
return (
<React.Fragment>
<Tabs
value={activeStep}
onChange={(_, value) => setActiveStep(value)}
indicatorColor="primary"
textColor="primary"
variant="fullWidth"
aria-label="bin tabs"
classes={{ root: classes.tabSmall }}>
<Tab label={"Your Devices"} className={classes.tabSmall} />
<Tab label={"Attached"} className={classes.tabSmall} />
</Tabs>
<Box width="100%" marginTop={activeStep === 0 ? 2 : 1}>
{activeStep === 0 ? componentsByDevice() : attachedComponents()}
</Box>
{removeComponentConfirmation()}
{removeAllConfirmation()}
{wizardDialog()}
</React.Fragment>
);
}

145
src/bin/BinSensors.tsx Normal file
View file

@ -0,0 +1,145 @@
import {
AppBar,
Box,
Button,
DialogActions,
DialogTitle,
Grid2 as Grid,
IconButton,
Toolbar,
Typography
} from "@mui/material";
import { Close } from "@mui/icons-material";
import ResponsiveDialog from "common/ResponsiveDialog";
import { useMobile } from "hooks";
import { Bin, Component } from "models";
import { pond } from "protobuf-ts/pond";
import React, { useEffect, useState } from "react";
import BinComponents from "./BinComponents";
type Mode = "add" | "update" | "remove";
interface Props {
open: boolean;
onClose: (refresh?: boolean) => void;
mode?: Mode;
bin?: Bin;
canEdit: boolean;
openedBinYard?: string;
userID: string;
coords?: { longitude: number; latitude: number };
binYards?: pond.BinYardSettings[];
components?: Map<string, Component>;
setComponents?: React.Dispatch<React.SetStateAction<Map<string, Component>>>;
updateBinStatus?: (componentKeys: string[], removed?: boolean) => void;
}
export default function BinSensors(props: Props) {
const isMobile = useMobile();
const {
open,
bin,
onClose,
mode,
openedBinYard,
components,
setComponents,
updateBinStatus
} = props;
const [initialized, setInitialized] = useState(false);
useEffect(() => {
if (open) {
setInitialized(true);
} else {
setInitialized(false);
}
}, [bin, open, mode, openedBinYard]);
const close = (refresh?: boolean) => {
onClose(refresh);
};
const titleText = () => {
return "Add Sensors to " + (bin ? bin.name() : "Bin");
};
const title = () => {
if (isMobile) {
return (
<AppBar position="relative">
<Toolbar>
<IconButton edge="start" color="inherit" onClick={() => close()} aria-label="close">
<Close />
</IconButton>
<Typography variant="h6" style={{ flex: 1 }}>
{titleText()}
</Typography>
</Toolbar>
</AppBar>
);
}
return (
<React.Fragment>
<DialogTitle>{titleText()}</DialogTitle>
</React.Fragment>
);
};
const componentsForm = () => {
if (bin) {
return (
<Box padding={1}>
<BinComponents
components={components ? components : undefined}
setComponents={setComponents}
bin={bin.key()}
binGrain={bin.grain()}
updateBinStatus={updateBinStatus}
/>
</Box>
);
}
return null;
};
const actions = () => {
return (
<DialogActions>
<Grid container justifyContent="space-between" direction="row">
<Grid size={{ xs: 7 }} container justifyContent="flex-end">
{!isMobile && (
<React.Fragment>
<Button onClick={() => close()} aria-label="Cancel">
Done
</Button>
</React.Fragment>
)}
</Grid>
</Grid>
</DialogActions>
);
};
if (!open || !initialized) {
return null;
}
return (
<ResponsiveDialog
maxWidth={"xs"}
fullWidth
fullScreen={isMobile}
open={open}
scroll="paper"
onClose={() => {
onClose();
}}>
{title()}
{componentsForm()}
{actions()}
</ResponsiveDialog>
);
}

View file

@ -156,8 +156,6 @@ export default function BinTour() {
return steps;
};
console.log(user.status.finishedBinIntro)
// if this intro has been done, return null
if (user.status.finishedBinIntro.length > 1) return null;