Merge branch 'staging_environment'

This commit is contained in:
Carter 2025-11-25 10:31:48 -06:00
commit bdddcfc103
10 changed files with 1504 additions and 262 deletions

View file

@ -494,7 +494,6 @@ export default function BinSettings(props: Props) {
//if the users preferences are in feet convert the distance to cm otherwise it was entered as cm so use that
form.inventory.lidarDropCm = getDistanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET ? lidarDropDistance * 30.48 : lidarDropDistance
}
console.log(form)
binAPI
.updateBin(bin.key(), form, as)
.then(response => {

View file

@ -3,14 +3,13 @@ import {
Box,
Button,
Card,
CircularProgress,
darken,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Grid2 as Grid,
IconButton,
InputAdornment,
lighten,
Link,
Skeleton,
@ -18,8 +17,6 @@ import {
Switch,
TextField,
Theme,
ToggleButton,
ToggleButtonGroup,
Typography,
useTheme,
} from "@mui/material";
@ -30,8 +27,8 @@ import HumidityIcon from "component/HumidityIcon";
import TemperatureIcon from "component/TemperatureIcon";
import GrainDescriber, { GrainOptions, ToGrainOption } from "grain/GrainDescriber";
import useViewport from "hooks/useViewport";
import { cloneDeep, round } from "lodash";
import { Bin, Component, Device, Interaction } from "models";
import { round } from "lodash";
import { Bin, Component, Device } from "models";
import { GetComponentIcon } from "pbHelpers/ComponentType";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { useMobile } from "hooks";
@ -48,6 +45,7 @@ import { useBinAPI } from "providers/pond/binAPI";
import BindaptIcon from "products/Bindapt/BindaptIcon";
import {
AccessTime,
ArrowForwardIos,
CheckCircleOutline,
Error,
InfoOutlined,
@ -56,7 +54,6 @@ import {
TrendingUp,
Warning
} from "@mui/icons-material";
import DevicePresetsFromPicker from "device/DevicePresetsFromPicker";
import { Plenum } from "models/Plenum";
import { Pressure } from "models/Pressure";
import { GrainCable } from "models/GrainCable";
@ -70,8 +67,9 @@ import { Ambient } from "models/Ambient";
import { ExtractMoisture } from "grain";
import SearchSelect, { Option } from "common/SearchSelect";
import Edit from "@mui/icons-material/Edit";
import { makeStyles, styled } from "@mui/styles";
import { makeStyles } from "@mui/styles";
import ButtonGroup from "common/ButtonGroup";
import ModeChangeDialog from "./conditioning/modeChangeDialog";
const useStyles = makeStyles((theme: Theme) => {
return ({
@ -172,18 +170,21 @@ interface GrainConditions {
emcTrend: number;
}
interface CombinedPlenum {
tempHumidity?: Plenum,
pressure?: Pressure
}
interface Props {
bin: Bin;
loading: boolean;
components: Map<string, Component>;
componentDevices?: Map<string, number>;
devices: Device[];
plenum?: Plenum;
plenums?: Plenum[];
ambient?: Ambient;
cables?: GrainCable[];
pressure?: Pressure;
interactions?: Interaction[];
//changeBinMode: (binMode: pond.BinMode) => boolean;
pressures?: Pressure[];
refresh: (showSnack?: boolean) => void;
afterUpdate?(): void;
preferences?: Map<string, pond.BinComponentPreferences>;
@ -195,11 +196,9 @@ interface Props {
export default function BinVisualizer(props: Props) {
const {
bin,
//changeBinMode,
//changeGrainAmount,
plenum,
plenums,
ambient,
pressure,
pressures,
loading,
cables,
components,
@ -207,7 +206,6 @@ export default function BinVisualizer(props: Props) {
preferences,
componentDevices,
permissions,
//interactions,
refresh,
updateComponentCallback,
binPresets
@ -233,10 +231,10 @@ export default function BinVisualizer(props: Props) {
).colour();
const pressColour = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE).colour();
const emcColour = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour();
const [showInputMoisture, setShowInputMoisture] = useState<boolean>(false);
const [moistureInput, setMoistureInput] = useState<string>("");
const [tempInput, setTempInput] = useState<string>("");
const [outdoorHumidityInput, setOutdoorHumidityInput] = useState<string>("");
// const [showInputMoisture, setShowInputMoisture] = useState<boolean>(false);
const [targetMoisture, setTargetMoisture] = useState(0);
const [outdoorTemp, setOutdoorTemp] = useState(0);
const [outdoorHumidity, setOutdoorHumidity] = useState(0);
const [modeTime, setModeTime] = useState<Moment>(moment());
const [grainUpdate, setGrainUpdate] = useState<boolean>(false);
const [openStorageTime, setOpenStorageTime] = useState(false);
@ -249,7 +247,7 @@ export default function BinVisualizer(props: Props) {
const [cfmLowOpen, setCFMLowOpen] = useState(false);
const [cfmHighOpen, setCFMHighOpen] = useState(false);
const [{ user }] = useGlobalState();
const [newPreset, setNewPreset] = useState<pond.BinMode>(pond.BinMode.BIN_MODE_NONE);
// const [newPreset, setNewPreset] = useState<pond.BinMode>(pond.BinMode.BIN_MODE_NONE);
const [newBinMode, setNewBinMode] = useState<pond.BinMode>(pond.BinMode.BIN_MODE_NONE);
const [selectedCable, setSelectedCable] = useState<GrainCable>();
const [openNodeDialog, setOpenNodeDialog] = useState(false);
@ -282,65 +280,47 @@ export default function BinVisualizer(props: Props) {
const [storageDate, setStorageDate] = useState(moment().format("YYYY-MM-DD"));
const [storageTime, setStorageTime] = useState(moment().format("HH:mm"));
// const StyledToggle = styled(ToggleButton)(({ theme }: { theme: Theme }) => ({
// root: {
// backgroundColor: "transparent",
// overflow: "visible",
// content: "content-box",
// "&$selected": {
// backgroundColor: "gold",
// color: "black",
// borderRadius: 24,
// fontWeight: "bold"
// },
// "&$selected:hover": {
// backgroundColor: "rgb(255, 255, 0)",
// color: "black",
// borderRadius: 24
// }
// },
// selected: {}
// }));
const [activePlenum, setActivePlenum] = useState<CombinedPlenum | undefined>()
const [activePlenumIndex, setActivePlenumIndex] = useState(0)
const [combinedPlenums, setCombinedPlenums] = useState<CombinedPlenum[]>([])
// const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }: { theme: Theme }) => ({
// grouped: {
// margin: theme.spacing(-0.5),
// border: "none",
// padding: theme.spacing(1),
// "&:not(:first-child):not(:last-child)": {
// borderRadius: 24,
// marginRight: theme.spacing(0.5),
// marginLeft: theme.spacing(0.5)
// },
// "&:first-child": {
// borderRadius: 24,
// marginLeft: theme.spacing(0.25)
// },
// "&:last-child": {
// borderRadius: 24,
// marginRight: theme.spacing(0.25)
// }
// },
// root: {
// backgroundColor: darken(
// theme.palette.background.paper,
// getThemeType() === "light" ? 0.05 : 0.25
// ),
// borderRadius: 24,
// content: "border-box"
// }
// }));
const [openModeChange, setOpenModeChange] = useState(false)
const [modeChangeInProgress, setModeChangeInProgress] = useState(false)
useEffect(() => {
setModeTime(moment(bin.status.lastModeChange));
}, [bin.status.lastModeChange]);
useEffect(()=>{
//match the plenums and pressures based on their location (address)
let combinedPlenums: CombinedPlenum[] = []
if (plenums) {
plenums.forEach(plenum => {
let newCombinedPlenum: CombinedPlenum = {
tempHumidity: plenum
}
if (pressures) {
pressures.forEach(pressure => {
if(plenum.location().address === pressure.location().address){
newCombinedPlenum.pressure = pressure
}
})
}
combinedPlenums.push(newCombinedPlenum)
})
}
if (combinedPlenums.length > 0){
setActivePlenum(combinedPlenums[0])
}
setCombinedPlenums(combinedPlenums)
},[plenums, pressures])
useEffect(() => {
setIsCustomInventory(bin.storage() !== pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN);
setStorageType(bin.storage());
setNewPreset(pond.BinMode.BIN_MODE_NONE);
// setNewPreset(pond.BinMode.BIN_MODE_NONE);
if (bin.settings.inventory) {
setMoistureInput(bin.settings.inventory.initialMoisture.toString());
setTargetMoisture(bin.settings.inventory.initialMoisture);
setCustomTypeName(bin.settings.inventory.customTypeName);
setGrainType(bin.settings.inventory.grainType);
setGrainOption(ToGrainOption(bin.settings.inventory.grainType));
@ -353,9 +333,9 @@ export default function BinVisualizer(props: Props) {
if (user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
t = CtoF(t);
}
setTempInput(t.toString());
setOutdoorTemp(t);
}
if (bin.settings) setOutdoorHumidityInput(bin.settings.outdoorHumidity.toString());
if (bin.settings) setOutdoorHumidity(bin.settings.outdoorHumidity);
}, [bin, user]);
useEffect(() => {
@ -391,20 +371,6 @@ export default function BinVisualizer(props: Props) {
humids.push(...filteredNodes.humids)
emcs.push(...filteredNodes.moistures)
}
// let tempClone = cloneDeep(cable.temperatures).reverse();
// let humClone = cloneDeep(cable.humidities).reverse();
// let emcClone = cloneDeep(cable.grainMoistures).reverse();
//add the cable data to the proper arrays
// if (cable.topNode > 0) {
// temps.push(...tempClone.splice(0, cable.topNode));
// humids.push(...humClone.splice(0, cable.topNode));
// emcs.push(...emcClone.splice(0, cable.topNode));
// } else {
// //if the cable has no fill set (top node) then use all of the nodes
// temps = tempClone;
// humids = humClone;
// emcs = emcClone;
// }
//add the trend data to the proper arrays if the top node is set
let cableTrendData = binTrend.cableTrend[cable.key()];
@ -418,7 +384,6 @@ export default function BinVisualizer(props: Props) {
//determine which node is the coldest so that the data displayed is for the same node
let lowTempIndex =
filteredNodes.temps.indexOf(Math.min(...filteredNodes.temps)) === -1 ? 0 : filteredNodes.temps.indexOf(Math.min(...filteredNodes.temps));
console.log(lowTempIndex)
lowNodeConditions = {
tempC: filteredNodes.temps[lowTempIndex],
humidity: filteredNodes.humids[lowTempIndex],
@ -798,7 +763,7 @@ export default function BinVisualizer(props: Props) {
setIsCustomInventory(bin.storage() !== pond.BinStorage.BIN_STORAGE_SUPPORTED_GRAIN);
setStorageType(bin.storage());
if (bin.settings.inventory) {
setMoistureInput(bin.settings.inventory.initialMoisture.toString());
setTargetMoisture(bin.settings.inventory.initialMoisture);
setCustomTypeName(bin.settings.inventory.customTypeName);
setGrainType(bin.settings.inventory.grainType);
setGrainOption(ToGrainOption(bin.settings.inventory.grainType));
@ -1094,14 +1059,28 @@ export default function BinVisualizer(props: Props) {
const plenumOverview = () => {
return (
<Box style={{ position: "absolute", bottom: 5, width: "100%" }}>
<Box display="flex" flexDirection="row" alignItems="center">
<Typography style={{ fontSize: isMobile ? "0.85rem" : "1.0rem", fontWeight: 650 }}>
Plenum
Plenum {plenums && plenums?.length > 1 ? activePlenumIndex+1 : ""}
</Typography>
{plenums && plenums.length > 1 &&
<IconButton sx={{background: darken(theme.palette.background.paper, 0.1), marginLeft: 1}} size="small" onClick={()=>{
let newIndex = activePlenumIndex + 1
if(newIndex > combinedPlenums.length - 1){
newIndex = 0
}
setActivePlenum(combinedPlenums[newIndex])
setActivePlenumIndex(newIndex)
}}>
<ArrowForwardIos sx={{ fontSize: 15}}/>
</IconButton>
}
</Box>
<Box
className={classes.displayBoxBinLeft}
style={{ marginRight: isMobile ? -70 : -90 }}
position="relative">
{plenum ? (
{activePlenum?.tempHumidity ? (
<React.Fragment>
<Grid
container
@ -1129,7 +1108,7 @@ export default function BinVisualizer(props: Props) {
fontWeight: 650,
color: tempColour
}}>
{plenum.getTempString(user.settings.temperatureUnit)}
{activePlenum.tempHumidity.getTempString(user.settings.temperatureUnit)}
</Typography>
</Grid>
<Grid size={{ xs: 5 }}>
@ -1141,7 +1120,7 @@ export default function BinVisualizer(props: Props) {
fontWeight: 650,
color: humidColour
}}>
{plenum.getHumidityString()}
{activePlenum.tempHumidity.getHumidityString()}
</Typography>
</Grid>
</Grid>
@ -1262,7 +1241,7 @@ export default function BinVisualizer(props: Props) {
style={{ height: isMobile ? 20 : 25, width: isMobile ? 20 : 25 }}
/>
</Grid>
{pressure ? (
{activePlenum?.pressure ? (
<React.Fragment>
<Grid size={{ xs: 5 }}>
<Typography
@ -1273,7 +1252,7 @@ export default function BinVisualizer(props: Props) {
fontWeight: 650,
color: pressColour
}}>
{pressure.getPressureString(user.settings.pressureUnit)}
{activePlenum.pressure.getPressureString(user.settings.pressureUnit)}
</Typography>
</Grid>
{/* <Grid item xs={5}>
@ -1600,7 +1579,7 @@ export default function BinVisualizer(props: Props) {
<Typography style={{ fontSize: isMobile ? "0.85rem" : "1.0rem", fontWeight: 650 }}>
Fan Performance
</Typography>
{pressure && pressure.fanId === 0 && !bin.settings.fan?.type && bin.fanID() === 0 && (
{activePlenum?.pressure && activePlenum.pressure.fanId === 0 && !bin.settings.fan?.type && bin.fanID() === 0 && (
<Typography variant="subtitle2" color="textPrimary" style={{ fontWeight: 600 }}>
No fans found to calculate CFM
</Typography>
@ -1642,8 +1621,8 @@ export default function BinVisualizer(props: Props) {
<Typography align="center" style={{ fontWeight: 650, color: emcColour }}>
{ExtractMoisture(
bin.grain(),
plenum?.temperature ?? 0,
plenum?.humidity ?? 0
activePlenum?.tempHumidity?.temperature ?? 0,
activePlenum?.tempHumidity?.humidity ?? 0
).toFixed(2)}
%
</Typography>
@ -1745,7 +1724,11 @@ export default function BinVisualizer(props: Props) {
<Typography variant="subtitle1" style={{ fontWeight: 800 }}>
Bin Mode
</Typography>
{modeChangeInProgress &&
<CircularProgress color="primary" size={25}/>
}
<ButtonGroup
disableAll={modeChangeInProgress}
buttons={[
{
title: "Storage",
@ -1762,48 +1745,14 @@ export default function BinVisualizer(props: Props) {
{
title: bin.settings.inventory && bin.settings.inventory?.initialMoisture < bin.settings.inventory?.targetMoisture ? "Hydrating" : "Drying",
function: () => {
setShowInputMoisture(true)
// setShowInputMoisture(true)
setModeConditioning() //will set it to either drying or hydrating based on the initial and target moisture
}
}
]}
toggledButtons={determineToggle(mode)}
toggle
/>
{/* <StyledToggleButtonGroup
id="tour-bin-mode"
value={mode}
exclusive
size="small"
aria-label="Bin Mode">
<StyledToggle
value={pond.BinMode.BIN_MODE_STORAGE}
aria-label="Storage Mode"
onClick={setModeStorage}>
Storage
</StyledToggle>
<StyledToggle
onClick={setModeCooldown}
value={pond.BinMode.BIN_MODE_COOLDOWN}
aria-label="Off">
Cooldown
</StyledToggle>
{bin.settings.inventory &&
bin.settings.inventory?.initialMoisture < bin.settings.inventory?.targetMoisture ? (
<StyledToggle
onClick={() => setShowInputMoisture(true)}
value={pond.BinMode.BIN_MODE_HYDRATING}
aria-label="Hydrating Mode">
Hydrating
</StyledToggle>
) : (
<StyledToggle
onClick={() => setShowInputMoisture(true)}
value={pond.BinMode.BIN_MODE_DRYING}
aria-label="Drying Mode">
Drying
</StyledToggle>
)}
</StyledToggleButtonGroup> */}
</Box>
);
};
@ -1972,106 +1921,32 @@ export default function BinVisualizer(props: Props) {
};
const setModeStorage = () => {
setNewPreset(pond.BinMode.BIN_MODE_STORAGE);
// setNewPreset(pond.BinMode.BIN_MODE_STORAGE);
setNewBinMode(pond.BinMode.BIN_MODE_STORAGE);
setOpenModeChange(true)
};
const setModeCooldown = () => {
if (bin.settings.mode === pond.BinMode.BIN_MODE_COOLDOWN) {
return;
}
setNewPreset(pond.BinMode.BIN_MODE_COOLDOWN);
// setNewPreset(pond.BinMode.BIN_MODE_COOLDOWN);
setNewBinMode(pond.BinMode.BIN_MODE_COOLDOWN);
setOpenModeChange(true)
};
const setModeDrying = () => {
const setModeConditioning = () => {
if (
bin.settings.inventory &&
bin.settings.inventory?.initialMoisture < bin.settings.inventory?.targetMoisture
) {
setNewPreset(pond.BinMode.BIN_MODE_HYDRATING);
// setNewPreset(pond.BinMode.BIN_MODE_HYDRATING);
setNewBinMode(pond.BinMode.BIN_MODE_HYDRATING);
} else {
setNewPreset(pond.BinMode.BIN_MODE_DRYING);
// setNewPreset(pond.BinMode.BIN_MODE_DRYING);
setNewBinMode(pond.BinMode.BIN_MODE_DRYING);
}
};
const closeMoistureDialog = () => {
setNewPreset(0);
setNewBinMode(bin.settings.mode);
setShowInputMoisture(false);
};
const moistureDialog = () => {
return (
<ResponsiveDialog open={showInputMoisture} onClose={closeMoistureDialog}>
<DialogTitle>Input new grain moisture</DialogTitle>
<DialogContent>
<DialogContentText style={{ paddingBottom: theme.spacing(0) }}>
Updating the current grain moisture will calibrate the estimate for more accurate
results. The outdoor temperature will have some effect on the estimate as well; consider
updating with drastic changes in the weather or using a predicted average.
</DialogContentText>
<TextField
label={"Grain Moisture"}
value={moistureInput}
onChange={event => setMoistureInput(event.target.value)}
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>
}}
style={{ marginTop: theme.spacing(2) }}
fullWidth
variant="outlined"
/>
<Typography variant="body1" style={{ marginTop: theme.spacing(2) }}>
Weather
</Typography>
<TextField
label={"Outdoor Temperature"}
value={tempInput}
onChange={event => setTempInput(event.target.value)}
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
? "°F"
: "℃"}
</InputAdornment>
)
}}
style={{ marginTop: theme.spacing(2) }}
fullWidth
variant="outlined"
/>
<TextField
label={"Outdoor Humidity"}
value={outdoorHumidityInput}
onChange={event => setOutdoorHumidityInput(event.target.value)}
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>
}}
style={{ marginTop: theme.spacing(2) }}
fullWidth
variant="outlined"
/>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setNewPreset(0);
setNewBinMode(bin.settings.mode);
setShowInputMoisture(false);
}}
color="primary">
Cancel
</Button>
<Button onClick={setModeDrying} color="primary">
Update
</Button>
</DialogActions>
</ResponsiveDialog>
);
setOpenModeChange(true)
};
if (loading) {
@ -2083,7 +1958,7 @@ export default function BinVisualizer(props: Props) {
b.settings.storage = storageType;
if (b.settings.inventory) {
if (b.settings.inventory.initialMoisture)
b.settings.inventory.initialMoisture = Number(moistureInput);
b.settings.inventory.initialMoisture = targetMoisture;
//update all the grain stuff
b.settings.inventory.grainType = grainType;
b.settings.inventory.customTypeName = customTypeName;
@ -2094,14 +1969,14 @@ export default function BinVisualizer(props: Props) {
}
if (b.settings.outdoorTemp !== undefined) {
let t = Number(tempInput);
let t = outdoorTemp;
if (user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
t = FtoC(Number(tempInput));
t = FtoC(outdoorTemp);
}
b.settings.outdoorTemp = t;
}
if (b.settings.outdoorHumidity !== undefined)
b.settings.outdoorHumidity = Number(outdoorHumidityInput);
b.settings.outdoorHumidity = outdoorHumidity;
if (b.settings.mode != newBinMode){
if(b.settings.inventory){
@ -2117,25 +1992,43 @@ export default function BinVisualizer(props: Props) {
return (
<Card raised className={classes.cardContent}>
{moistureDialog()}
{cfmLowDialog()}
{cfmHighDialog()}
{changeGrain()}
{storageTimeDialog()}
<DevicePresetsFromPicker
preferences={preferences}
<ModeChangeDialog
binKey={bin.key()}
devices={devices}
refreshCallback={success => {
setShowInputMoisture(false);
setNewPreset(0)
if (success) updateBin();
}}
parentPreset={newPreset}
binMode={newBinMode}
grain={bin.settings.inventory?.grainType}
devices={devices}
open={openModeChange}
binCables={cables}
compDevMap={componentDevices}
presets={binPresets}
compDevMap={componentDevices}
preferences={preferences}
onClose={(refresh) => {
setOpenModeChange(false)
if(refresh) updateBin();
}}
defaultTargetMoisture={bin.settings.inventory?.initialMoisture}
// defaultOutdoorTemp={get this from any ambient sensors}
// defaultOutdoorHumidity={get this from any ambient sensors}
changeTargetMoisture={newMoisture => {
setTargetMoisture(newMoisture)
}}
changeOutdoorTemp={newTemp => {
setOutdoorTemp(newTemp)
}}
changeOutdoorHumidity={newHumidity => {
setOutdoorHumidity(newHumidity)
}}
startChange={() => {
setModeChangeInProgress(true)
}}
changeComplete={() => {
setModeChangeInProgress(false)
}}
/>
<Box paddingRight={1}>
{binMode()}
@ -2148,7 +2041,7 @@ export default function BinVisualizer(props: Props) {
</Grid>
<Grid size={1.5} >{controls()}</Grid>
</Grid>
{plenum && pressure && fanPerformance()}
{plenums && pressures && fanPerformance()}
{ambient && ambientDisplay()}
{(bin.status.fans.length > 0 || bin.status.heaters.length > 0) && controllerDisplay()}
{dryingEstimate()}

View file

@ -0,0 +1,90 @@
import { Typography } from "@mui/material"
import { Ambient } from "models/Ambient"
import { Controller } from "models/Controller"
import { Plenum } from "models/Plenum"
import React, { useState } from "react"
import PickComponentSet, { ComponentSet } from "./pickComponentSet"
import { cloneDeep } from "lodash"
import { DevicePreset } from "models/DevicePreset"
import { pond } from "protobuf-ts/pond"
interface Props {
plenums: Plenum[]
ambients: Ambient[]
heaters: Controller[]
fans: Controller[]
binMode: pond.BinMode
presets?: DevicePreset[]
updateSets: (sets: ComponentSet[]) => void
}
export default function ConditioningSelector(props: Props){
const {plenums, ambients, heaters, fans, updateSets, presets, binMode} = props
const [currentSets, setCurrentSets] = useState<ComponentSet[]>([])
const sensorIndex = (key: string) => {
let index = -1
currentSets.forEach((set, i) => {
if(set.sensor.key() === key){
index = i
}
})
return index
}
const setUpdate = (key: string, set?: ComponentSet) => {
let sets = cloneDeep(currentSets)
let index = sensorIndex(key)
//if the set is defined, add it to the existing sets as long as it doesn't already exist
if(set){
//check if there is a set that has a sensor with the key
if(index !== -1){
sets[index] = set //replace it with the new set
}else{//if the set was not found ie is new
sets.push(set)//add the new set
}
}else{ //if the set is undefined
if (index !== -1){
sets.splice(index, 1)//remove it from the sets
}
//no else is needed because if it doesn't exist nothing need be done
}
//once the sets are finished being changed pas that up to the parent via the prop function
setCurrentSets(sets)
updateSets(sets)
}
return (
<React.Fragment>
{(plenums.length > 0 || ambients.length > 0) &&
<Typography>Select the sensors you would like to add an interaction to</Typography>
}
{plenums.map(plenum => {
return (
<PickComponentSet
key={plenum.key()}
sensor={plenum}
heaters={heaters}
fans={fans}
binMode={binMode}
presets={presets}
updateSet={setUpdate}
/>
)})}
{ambients.map(ambient => {
return (
<PickComponentSet
key={ambient.key()}
sensor={ambient}
heaters={heaters}
fans={fans}
binMode={binMode}
presets={presets}
updateSet={setUpdate}
/>
)})}
</React.Fragment>
)
}

View file

@ -0,0 +1,846 @@
import { Autocomplete, Box, Button, DialogActions, DialogContent, DialogContentText, DialogTitle, TextField, Stepper, Step, StepLabel, InputAdornment, Typography, useTheme } from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog";
import { useComponentAPI, useInteractionsAPI } from "hooks";
import { Component, Device, Interaction } from "models";
import { Ambient } from "models/Ambient";
import { Controller } from "models/Controller";
import { DevicePreset } from "models/DevicePreset";
import { GrainCable } from "models/GrainCable";
import { Plenum } from "models/Plenum";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
import { useGlobalState } from "providers";
import React from "react";
import { useEffect, useState } from "react";
import ConditioningSelector from "./conditioningSelector";
import CableTopNodeSummary from "bin/CableTopNodeSummary";
import { ComponentSet } from "./pickComponentSet";
import { sameComponentID } from "pbHelpers/Component";
import moment from "moment";
import { lowerCase } from "lodash";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { fahrenheitToCelsius, getTemperatureUnit } from "utils";
import { GetGrainExtensionMap } from "grain";
import { PromiseProgress, Stage, Step as PromiseStep } from "common/PromiseProgress";
interface Props {
open: boolean
onClose: (refresh: boolean) => void
binKey: string;
devices: Device[];
binMode: pond.BinMode;
grain?: pond.Grain;
preferences?: Map<string, pond.BinComponentPreferences>;
binCables?: GrainCable[];
compDevMap?: Map<string, number>;
presets?: DevicePreset[];
startChange: () => void;
changeComplete: () => void;
defaultTargetMoisture?: number;
changeTargetMoisture?: (newTarget: number) => void
defaultOutdoorTemp?: number;
changeOutdoorTemp?: (newTemp: number) => void
defaultOutdoorHumidity?: number;
changeOutdoorHumidity?: (newHumidity: number) => void
}
interface Option {
label: string;
device: Device;
icon?: string;
}
interface DialogStep {
label: string;
completed?: boolean;
}
export default function ModeChangeDialog(props: Props){
const {
open,
onClose,
binMode,
devices,
binKey,
preferences,
binCables,
compDevMap,
grain,
startChange,
changeComplete,
defaultTargetMoisture,
defaultOutdoorTemp,
defaultOutdoorHumidity,
changeTargetMoisture,
changeOutdoorTemp,
changeOutdoorHumidity,
presets
} = props
const [{as}] = useGlobalState()
const [componentSets, setComponentSets] = useState<ComponentSet[]>([])
const interactionAPI = useInteractionsAPI()
const componentAPI = useComponentAPI();
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>()
const [existingInteractions, setExistingInteractions] = useState<Interaction[]>([])
const [loading, setLoading] = useState(false)
const [options, setOptions] = useState<Option[]>([])
const [deviceComponents, setDeviceComponents] = useState<Map<string, Component[]>>(
new Map<string, Component[]>()
);
const [plenums, setPlenums] = useState<Plenum[]>([])
const [ambients, setAmbients] = useState<Ambient[]>([])
const [heaters, setHeaters] = useState<Controller[]>([])
const [fans, setFans] = useState<Controller[]>([])
const grainExtensionMap = GetGrainExtensionMap();
const [conflictingSetInteractions, setConflictingSetInteractions] = useState<Interaction[]>([])
const [interactionsToAdd, setInteractionsToAdd] = useState<pond.MultiInteractionSettings>()
const [promiseStages, setPromiseStages] = useState<Stage[]>([])
const [dialogSteps, setDialogSteps] = useState<DialogStep[]>([])
const [currentStep, setCurrentStep] = useState(0)
const theme = useTheme()
//the state variables for the moisture
const [moistureInput, setMoistureInput] = useState<string>("")
const [outdoorTempInput, setOutdoorTempInput] = useState<string>("")
const [outdoorHumidityInput, setOutdoorHumidityInput] = useState<string>("")
const [deviceOption, setDeviceOption] = useState<Option>()
useEffect(()=>{
setMoistureInput(defaultTargetMoisture?.toFixed(2) ?? "0")
setOutdoorTempInput(defaultOutdoorTemp?.toFixed(2) ?? "0")
setOutdoorHumidityInput(defaultOutdoorHumidity?.toFixed(2) ?? "0")
},[defaultTargetMoisture, defaultOutdoorTemp, defaultOutdoorHumidity])
//get the interactions and components for the device when it is selected from the dropdown
useEffect(()=>{
if (!binKey) return;
if (!selectedDevice) return
let comps = deviceComponents.get(selectedDevice.id().toString());
if (!comps) {
comps = [];
setLoading(true);
let interactionPromise = interactionAPI.listInteractionsByDevice(selectedDevice.id(), undefined, as);
let componentPromise = componentAPI.list(selectedDevice.id(), undefined, [binKey], ["bin"], true, as);
let dComponents = new Map<string, Component[]>();
Promise.all([componentPromise, interactionPromise])
.then(([compResp, interactionResp]) => {
let d = pond.ListComponentsResponse.fromObject(compResp.data);
d.components.forEach(comp => {
let c = Component.create(comp);
let deviceNumber = compResp.data.componentDevices[c.key()];
comps!.push(c);
let newDComponents = dComponents.get(deviceNumber.toString());
if (newDComponents === undefined) newDComponents = [];
newDComponents.push(c);
dComponents.set(deviceNumber.toString(), newDComponents);
});
setDeviceComponents(dComponents);
setExistingInteractions(interactionResp);
})
.finally(() => {
setLoading(false);
});
}
},[selectedDevice, binKey, componentAPI, interactionAPI, deviceComponents, as])
//sort the components according to the bin preferences
useEffect(()=>{
if (loading) return;
if (!selectedDevice) return;
if (!deviceComponents.get(selectedDevice.id().toString())) return;
var plenums: Plenum[] = [];
var ambients: Ambient[] = [];
//var grainCables: GrainCable[] = [];
var heaters: Controller[] = [];
var fans: Controller[] = [];
deviceComponents.get(selectedDevice.id().toString())!.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) {
let heater = Controller.create(comp);
heater.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_OFF;
heaters.push(heater);
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_FAN) {
let fan = Controller.create(comp)
fan.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_OFF;
fans.push(fan);
}
}
}
});
setPlenums(plenums);
setAmbients(ambients);
setHeaters(heaters);
setFans(fans);
}, [loading, setPlenums, setHeaters, deviceComponents, selectedDevice, preferences]);
useEffect(() => {
let o: Option[] = [];
devices.forEach(device => {
let newOption: Option = {device: device, label: device.name()}
if(deviceOption === undefined) {
setDeviceOption(newOption)
setSelectedDevice(device)
}
o.push(newOption);
});
setOptions(o);
}, [devices, setOptions]);
//use effect here to determine the steps based on the bin mode
useEffect(() => {
let steps: DialogStep[] = []
switch(binMode){
case pond.BinMode.BIN_MODE_STORAGE:
steps.push({
label: "Storage Settings"
})
break;
case pond.BinMode.BIN_MODE_COOLDOWN:
steps.push({
label: "Set Components"
})
steps.push({
label: "Progress"
})
break;
case pond.BinMode.BIN_MODE_DRYING:
case pond.BinMode.BIN_MODE_HYDRATING:
steps.push({
label: "Weather Conditions"
})
steps.push({
label: "Set Components"
})
steps.push({
label: "Progress"
})
}
setDialogSteps(steps)
},[binMode])
const buildHeaterInteraction = (
sensor: Plenum | Ambient,
heater: Controller,
customPreset?: DevicePreset
): pond.InteractionSettings => {
let interaction = pond.InteractionSettings.create({
source: sensor.location(),
sink: heater.location(),
schedule: pond.InteractionSchedule.create({
timeOfDayStart: "00:00",
timeOfDayEnd: "24:00",
timezone: moment.tz.guess(),
weekdays: moment.weekdays().map(d => lowerCase(d))
}),
notifications: pond.InteractionNotifications.create({
reports: true
}),
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE,
value: 1
})
});
let temp = 0;
let hum = 0;
//if a preset was selected use its temp/hum values
if (customPreset !== undefined) {
temp = customPreset.temp();
hum = customPreset.hum();
} else {
//otherwise use default values
switch(binMode){
case pond.BinMode.BIN_MODE_COOLDOWN:
temp = 10
hum = 45
break;
case pond.BinMode.BIN_MODE_DRYING:
if(grain){
//get the values using the grain type
let ext = grainExtensionMap.get(grain)
if (ext) {
temp = ext.setTempC
hum = ext.targetMC
}
}else{//otherwise use the default drying interaction
temp = 40
hum = 20
}
}
}
let conditions = [];
let humidityCondition = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN,
value: Math.round(
describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
sensor.settings.type,
sensor.settings.subtype
).toStored(hum)
)
});
conditions.push(humidityCondition);
//since the measurement describers function to stored does a conversion into celsius if the users pref is fahrenheit for temperature we need to convert the preset value into fahrenheit
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
temp = Math.round((temp * (9 / 5) + 32) * 100) / 100;
}
let tempVal = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
sensor.settings.type,
sensor.settings.subtype
).toStored(temp);
let tempCondition = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: Math.round(tempVal) //and then round the converted value since interaction conditions wont take floats
});
conditions.push(tempCondition);
interaction.conditions = conditions;
//the the heater output to auto so that when the components are update it uses the new mode
heater.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_AUTO
return interaction;
};
const buildFanInteraction = (
sensor: Plenum | Ambient,
fan: Controller,
tempComparison: "greater" | "less",
humidityComparison: "greater" | "less",
customPreset?: DevicePreset
) => {
let interaction = pond.InteractionSettings.create({
source: sensor.location(),
sink: fan.location(),
schedule: pond.InteractionSchedule.create({
timeOfDayStart: "00:00",
timeOfDayEnd: "24:00",
timezone: moment.tz.guess(),
weekdays: moment.weekdays().map(d => lowerCase(d))
}),
notifications: pond.InteractionNotifications.create({
reports: true
}),
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE,
value: 1
})
});
let tempPreset = 0;
let humidityPreset = 0;
//if a custom preset was selected use it
if (customPreset !== undefined) {
tempPreset = customPreset.temp();
humidityPreset = customPreset.hum();
} else {
//otherwise use one of the defaults
switch (binMode) {
case pond.BinMode.BIN_MODE_COOLDOWN:
tempPreset = 15;
humidityPreset = 60;
break;
case pond.BinMode.BIN_MODE_DRYING:
if(grain){
//get the values using the grain type
let ext = grainExtensionMap.get(grain)
if (ext) {
tempPreset = ext.setTempC
humidityPreset = ext.targetMC
}
}else{//otherwise use the default drying interaction
tempPreset = 40
humidityPreset = 20
}
break;
case pond.BinMode.BIN_MODE_HYDRATING:
tempPreset = 25;
humidityPreset = 60;
break;
}
}
let conditions = [];
let fanConditionOne = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
comparison:
humidityComparison === "greater"
? quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN
: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
sensor.settings.type,
sensor.settings.subtype
).toStored(humidityPreset)
});
conditions.push(fanConditionOne);
//since the measurement describers function toStored does a conversion into celsius for temperature if the users pref is fahrenheit we need to convert the preset value into fahrenheit
if (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
tempPreset = Math.round((tempPreset * (9 / 5) + 32) * 100) / 100;
}
let tempVal = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
sensor.settings.type,
sensor.settings.subtype
).toStored(tempPreset);
let fanConditionTwo = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
comparison:
tempComparison === "greater"
? quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN
: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN,
value: Math.round(tempVal) //and then round the converted value since the interaction does not take decimals
});
conditions.push(fanConditionTwo);
interaction.conditions = conditions;
//set the output mode to auto in the fan so that when the components are updated it uses the new mode
fan.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_AUTO
return interaction;
};
//this is what will use the conflicting interactions
const buildPromises = () => {
//if there is no device selected or there are no interactions to add, do nothing
if(!selectedDevice || !interactionsToAdd) return
//build the stages to pass into the PromiseProgress
//stage one is to remove conflicting interactions
let stages: Stage[] = []
let stage1: Stage = {
title: "Remove Conflicting Interactions",
steps: []
}
conflictingSetInteractions.forEach(i => {
let newStep: PromiseStep = {
title: "Removing Interaction",
promise: interactionAPI.removeInteraction(selectedDevice.id(), i.key(), as)
}
stage1.steps.push(newStep);
});
stages.push(stage1)
//stage two is to add the new interactions
let stage2: Stage = {
title: "Add New Interactions",
steps: [
{
title: "Adding Interactions",
promise: interactionAPI.addMultiInteractions(selectedDevice.id(), interactionsToAdd)
}
]
}
stages.push(stage2)
//stage three is to update the controller components
let stage3: Stage = {
title: "Update Controllers",
steps: []
}
componentSets.forEach(set => {
set.controllers.forEach(controller => {
let newStep: PromiseStep = {
title: "Updating " + controller.name(),
promise: componentAPI.update(selectedDevice.id(), controller.settings)
}
stage3.steps.push(newStep)
})
})
stages.push(stage3)
//set those stages to a state variable
setPromiseStages(stages)
}
//this function just takes in the sets as they change and creates the list of conflicting interactions and the settings to create the new ones and returns them
const createInteractions = (sets: ComponentSet[]) => {
if(!selectedDevice) return undefined //if there is no device that was selected, do nothing
let multiInteractionSettings: pond.MultiInteractionSettings = pond.MultiInteractionSettings.create()
let conflictingInteractions: Interaction[] = []
let linkedComponents = deviceComponents.get(selectedDevice.id().toString());
//loop through the sets
sets.forEach((set) => {
//loop through the controllers
set.controllers.forEach(controller => {
let customPreset = set.selectedPresets.get(controller.key())
//filter the conflicting interactions for that controller
let c = existingInteractions.filter(i => {
let conflicting = false;
if (linkedComponents) {
linkedComponents.forEach(comp => {
if (sameComponentID(comp.location(), i.settings.sink)) {
conflicting = true;
}
});
}
return conflicting;
});
conflictingInteractions = conflictingInteractions.concat(c)
switch(binMode){
case pond.BinMode.BIN_MODE_COOLDOWN:
// if the controller is a heater create heater interaction
if(controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_HEATER){
multiInteractionSettings.interactions.push(buildHeaterInteraction(set.sensor, controller, customPreset))
}else if(controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_AERATION_FAN ||
controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_EXHAUST_FAN){
// if the controller is a fan create a fan interaction
multiInteractionSettings.interactions.push(buildFanInteraction(set.sensor, controller, "less", "less", customPreset))
}
break;
case pond.BinMode.BIN_MODE_DRYING:
// if the controller is a heater create heater interaction
if(controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_HEATER){
multiInteractionSettings.interactions.push(buildHeaterInteraction(set.sensor, controller, customPreset))
}else if(controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_AERATION_FAN ||
controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_EXHAUST_FAN){// if the controller is a fan
//if it is a combination set using a heater as well just turn the fan on
if(set.combo){
controller.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_ON
}else{
//otherwise make a fan interaction
multiInteractionSettings.interactions.push(buildFanInteraction(set.sensor, controller, "greater", "less", customPreset))
}
}
break;
case pond.BinMode.BIN_MODE_HYDRATING:
//check to make sure the controller is a fan, you cant hydrate with a heater, if the controller is a heater then it will not add an interaction
if(controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_AERATION_FAN ||
controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_EXHAUST_FAN){
//create fan interaction
multiInteractionSettings.interactions.push(buildFanInteraction(set.sensor, controller, "less", "greater", customPreset))
}
break;
}
})
})
setComponentSets([...sets])
return {
conflicting: conflictingInteractions,
toAdd: multiInteractionSettings
}
}
const deviceSelector = () => {
return (
<Autocomplete
disablePortal
options={options}
value={deviceOption}
fullWidth
getOptionLabel={option => option.label || ""}
onChange={(_, newValue) => {
if(newValue){
setSelectedDevice(newValue.device);
}
}}
renderInput={params => <TextField {...params} variant="outlined" label="Device" />}
/>
)
}
const modeDescription = () => {
switch(binMode){
case pond.BinMode.BIN_MODE_DRYING:
return "Drying"
case pond.BinMode.BIN_MODE_HYDRATING:
return "Hydrating"
case pond.BinMode.BIN_MODE_COOLDOWN:
return "Cooldown"
case pond.BinMode.BIN_MODE_STORAGE:
return "Storage"
default:
return ""
}
}
const close = (refresh: boolean) => {
//clear state data
setCurrentStep(0)
setInteractionsToAdd(undefined)
setPromiseStages([])
onClose(refresh)
}
//this step will only be used for drying and hydrating
const moistureStep = () => {
return (
<Box>
<DialogContentText style={{ paddingBottom: theme.spacing(0) }}>
Updating the current grain moisture will calibrate the estimate for more accurate
results. The outdoor temperature will have some effect on the estimate as well; consider
updating with drastic changes in the weather or using a predicted average.
</DialogContentText>
<TextField
label={"Grain Moisture"}
value={moistureInput}
error={isNaN(parseFloat(moistureInput))}
helperText={isNaN(parseFloat(moistureInput)) ? "Must be a valid number" : ""}
onChange={event => {
setMoistureInput(event.target.value)
if(changeTargetMoisture && !isNaN(parseFloat(event.target.value))){
changeTargetMoisture(parseFloat(event.target.value))
}
}}
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>
}}
style={{ marginTop: theme.spacing(2) }}
fullWidth
variant="outlined"
/>
<Typography variant="body1" style={{ marginTop: theme.spacing(2) }}>
Weather
</Typography>
<TextField
label={"Outdoor Temperature"}
value={outdoorTempInput}
error={isNaN(parseFloat(outdoorTempInput))}
helperText={isNaN(parseFloat(outdoorTempInput)) ? "Must be a valid number" : ""}
onChange={event => {
setOutdoorTempInput(event.target.value)
if(changeOutdoorTemp && !isNaN(parseFloat(event.target.value))){
//if the users prefs are F will need to convert it to C when sending it back
let val = parseFloat(event.target.value)
if(getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT){
val = fahrenheitToCelsius(val)
}
changeOutdoorTemp(val)
}
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
{getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT
? "°F"
: "℃"}
</InputAdornment>
)
}}
style={{ marginTop: theme.spacing(2) }}
fullWidth
variant="outlined"
/>
<TextField
label={"Outdoor Humidity"}
value={outdoorHumidityInput}
error={isNaN(parseFloat(outdoorHumidityInput))}
helperText={isNaN(parseFloat(outdoorHumidityInput)) ? "Must be a valid number" : ""}
onChange={event => {
setOutdoorHumidityInput(event.target.value)
if(changeOutdoorHumidity && !isNaN(parseFloat(event.target.value))){
changeOutdoorHumidity(parseFloat(event.target.value))
}
}}
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>
}}
style={{ marginTop: theme.spacing(2) }}
fullWidth
variant="outlined"
/>
</Box>
)
};
const selectDeviceStep = () => {
switch(binMode){
case pond.BinMode.BIN_MODE_STORAGE:
return (
<Box>
{binCables && binCables?.length > 0 && compDevMap && (
<Box marginBottom={2}>
<CableTopNodeSummary
binKey={binKey}
cables={binCables}
componentDevMap={compDevMap}
/>
</Box>
)}
<DialogContentText>
Select device to turn off controllers and end conditioning. Any interactions will be
left alone. Heaters/Fans will be turned off.
</DialogContentText>
{deviceSelector()}
</Box>
)
case pond.BinMode.BIN_MODE_DRYING:
case pond.BinMode.BIN_MODE_COOLDOWN:
case pond.BinMode.BIN_MODE_HYDRATING:
return (
<Box>
<DialogContentText>
{modeDescription()} control is handled by interactions between multiple components on the
same device. Please choose which device and components will handle this bin's{" "}
{modeDescription()}.
</DialogContentText>
{deviceSelector()}
<ConditioningSelector
plenums={plenums}
ambients={ambients}
fans={fans}
heaters={heaters}
presets={presets}
binMode={binMode}
updateSets={(sets) => {
//update the component sets that will be used for the interactions
//when the sets change it will create the interactions and any conflicting ones that need to be removed will be put into a list
//then when the submit button gets clicked it will use the conflicting list and toAdd to remove conflicting interactions and add the new ones
let i = createInteractions(sets)
if(i !== undefined){
setConflictingSetInteractions(i.conflicting)
setInteractionsToAdd(i.toAdd)
}
}}
/>
</Box>
)
}
}
const progressStep = () => {
return (
<Box>
<PromiseProgress
stages={promiseStages}
automaticStart
failFast
onStart={() => {
startChange()
}}
onComplete={()=>{
close(true)
setCurrentStep(0)//reset the stepper
changeComplete()//change the boolean preventing more mode changes
}}
/>
</Box>
)
}
const stepper = () => {
return (
<Stepper nonLinear activeStep={currentStep} style={{ width: "100%", marginBottom: theme.spacing(2) }}>
{dialogSteps.map((dialogStep, i) => {
const labelProps: {
optional?: React.ReactNode;
} = {};
return (
<Step key={i} completed={dialogStep.completed}>
<StepLabel {...labelProps}>{dialogStep.label}</StepLabel>
</Step>
);
})}
</Stepper>
);
}
const actions = () => {
let buttons: JSX.Element[] = []
//this button should be on every step of all of them
buttons.push(
<Button key={"close"} onClick={() => {close(false)}}>Close</Button>
)
// this button should appear if the step they are on is not 0 and not the last step
if(currentStep !== 0 && currentStep !== dialogSteps.length-1){
buttons.push(
<Button key={"back"} onClick={()=>{setCurrentStep(currentStep-1)}}>Back</Button>
)
}
// this button should appear if the step they are on is less than the last step -1
if(currentStep < dialogSteps.length-2){
buttons.push(
<Button key={"next"} onClick={() => {setCurrentStep(currentStep+1)}}>Next</Button>
)
}
if(((binMode === pond.BinMode.BIN_MODE_STORAGE || binMode === pond.BinMode.BIN_MODE_COOLDOWN) && currentStep === 0) ||
(binMode === pond.BinMode.BIN_MODE_DRYING || binMode === pond.BinMode.BIN_MODE_HYDRATING) && currentStep === 1){
buttons.push(
<Button key={"confirm"} onClick={() => {
//if the bin mode is storage, just update the controllers for the selected device and close
if(binMode === pond.BinMode.BIN_MODE_STORAGE){
close(true)
if (selectedDevice) {
//update the controllers to be off for the selected device
heaters.forEach(heater => {
componentAPI
.update(selectedDevice.id(), heater.settings, undefined, undefined, as)
.then()
.catch();
});
fans.forEach(fan => {
componentAPI
.update(selectedDevice.id(), fan.settings, undefined, undefined, as)
.then()
.catch();
});
}
}else{//if it is cooldown, drying, or hydrating, it will need to go to the progress step and initiate all of the promises
buildPromises()
setCurrentStep(dialogSteps.length-1)//set the stepper to the progress step
}
}}>Confirm</Button>
)
}
return buttons
}
const stepperContent = (step: number) => {
switch(binMode){
case pond.BinMode.BIN_MODE_STORAGE:
return selectDeviceStep();
case pond.BinMode.BIN_MODE_COOLDOWN:
switch (step) {
case 1:
return progressStep();
default:
return selectDeviceStep();
}
case pond.BinMode.BIN_MODE_DRYING:
case pond.BinMode.BIN_MODE_HYDRATING:
switch (step) {
case 1:
return selectDeviceStep();
case 2:
return progressStep();
default:
return moistureStep();
}
}
};
return (
<React.Fragment>
<ResponsiveDialog open={open} onClose={() => {close(false)}}>
<DialogTitle>
{binMode === pond.BinMode.BIN_MODE_STORAGE ? "Entering Storage Mode" : "Choose Device for " + modeDescription() + " Method"}
</DialogTitle>
<DialogContent>
{stepper()}
{stepperContent(currentStep)}
</DialogContent>
<DialogActions>
{actions()}
</DialogActions>
</ResponsiveDialog>
</React.Fragment>
)
}

View file

@ -0,0 +1,258 @@
import { CheckBox, ExpandMore } from "@mui/icons-material";
import { Accordion, AccordionDetails, AccordionSummary, Autocomplete, Box, Checkbox, FormControlLabel, Grid2, TextField, Typography } from "@mui/material";
import { cloneDeep } from "lodash";
import { Ambient } from "models/Ambient";
import { Controller } from "models/Controller";
import { DevicePreset } from "models/DevicePreset";
import { Plenum } from "models/Plenum";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
import React, { useEffect, useState } from "react";
export interface ComponentSet {
sensor: Plenum | Ambient,
controllers: Controller[]
selectedPresets: Map<string, DevicePreset>
combo?: boolean //if the set contains both a heater and a fan
}
interface Option {
label: string;
id: number;
icon?: string;
}
interface Props {
sensor: Plenum | Ambient
binMode: pond.BinMode
heaters?: Controller[]
fans?: Controller[]
presets?: DevicePreset[]
updateSet: (sensorKey: string, componentSet?: ComponentSet) => void
}
export default function PickComponentSet(props: Props) {
const {sensor, heaters, fans, updateSet, presets, binMode} = props
const [sensorSelected, setSensorSelected] = useState(false)
const [currentSet, setCurrentSet] = useState<ComponentSet | undefined>()
useEffect(()=>{
if (presets){
let presetOptions: Option[] = [];
presets.forEach((devicePreset, i) => {
//add the preset to the possible options
if (devicePreset.favorite()) {
//if it is favorited add it to the front of the array
presetOptions.unshift({
label: devicePreset.name,
id: i
});
} else {
//otherwise add it to the end
presetOptions.push({
label: devicePreset.name,
id: i
});
}
});
}
},[presets])
const getOptionsForController = (controllerType: pond.ControllerType) => {
let validOps: Option[] = []
if(presets){
presets.forEach((devicePreset, i) => {
if(devicePreset.controllerType() === controllerType && devicePreset.compareToBinMode(binMode)){
//add the preset to the possible options
if (devicePreset.favorite()) {
//if it is favorited add it to the front of the array
validOps.unshift({
label: devicePreset.name,
id: i
});
} else {
//otherwise add it to the end
validOps.push({
label: devicePreset.name,
id: i
});
}
}
})
}
return validOps
}
const controllerIndex = (key: string) => {
if(!currentSet) return -1
let index = -1
currentSet.controllers.forEach((cont, i) => {
if(cont.key() === key){
index = i
}
})
return index
}
const comboCheck = (set: ComponentSet) => {
let heaterFound = false
let fanFound = false
set.controllers.forEach(controller => {
if(controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_HEATER){
heaterFound = true
}
if(controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_AERATION_FAN ||
controller.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_EXHAUST_FAN){
fanFound = true
}
})
return heaterFound && fanFound
}
return (
<Box>
<FormControlLabel
control={
<Checkbox
value={sensorSelected}
checked={sensorSelected}
onChange={(_, checked) => {
setSensorSelected(checked)
let newSet: ComponentSet | undefined
if(checked){
newSet = {
sensor: sensor,
controllers: [],
selectedPresets: new Map<string, DevicePreset>()
}
}
setCurrentSet(newSet)
updateSet(sensor.key(), newSet)
}}
/>
}
label={sensor.name()}
/>
{sensorSelected && currentSet &&
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>Controllers Selected: {currentSet.controllers.length}</AccordionSummary>
<AccordionDetails>
{heaters && heaters.length > 0 && binMode !== pond.BinMode.BIN_MODE_HYDRATING &&
<React.Fragment>
<Typography>Heaters</Typography>
{heaters.map(heater => (
<Grid2 key={heater.key()} container justifyContent="space-between" direction="row" alignContent="center" alignItems="center">
<Grid2 size={{xs: 12, sm: 6}}>
<FormControlLabel
control={
<Checkbox
onChange={(_, checked) => {
let set = cloneDeep(currentSet)
if(checked){//if checked need to add it to the controllers
set.controllers.push(heater)
}else{//will need to make sure it is not in the controllers
let index = controllerIndex(heater.key())
if (index !== -1){
set.controllers.splice(index, 1)
}
}
set.combo = comboCheck(set)
setCurrentSet(set)
updateSet(sensor.key(), set)
}}
/>
}
label={heater.name()}
/>
</Grid2>
<Grid2 size={{xs: 12, sm: 6}}>
{presets && getOptionsForController(pond.ControllerType.CONTROLLER_TYPE_HEATER).length > 0 &&
<Autocomplete
disablePortal
options={getOptionsForController(pond.ControllerType.CONTROLLER_TYPE_HEATER)}
fullWidth
getOptionLabel={option => option.label || ""}
onChange={(_, newValue) => {
if (newValue) {
let presetId = newValue.id;
if (presets && presets[presetId]) {
//setSelectedPreset(presets[presetId]);
let set = cloneDeep(currentSet)
set.selectedPresets.set(heater.key(), presets[presetId])
set.combo = comboCheck(set)
setCurrentSet(set)
updateSet(sensor.key(), set)
}
}
}}
renderInput={params => <TextField {...params} variant="outlined" label="Preset" />}
/>
}
</Grid2>
</Grid2>
))}
</React.Fragment>
}
{fans && fans.length > 0 &&
<React.Fragment>
<Typography>Fans</Typography>
{fans.map(fan => (
<Grid2 key={fan.key()} container justifyContent="space-between" direction="row" alignContent="center" alignItems="center">
<Grid2 size={{xs: 12, sm: 6}}>
<FormControlLabel
control={
<Checkbox
onChange={(_, checked) => {
let set = cloneDeep(currentSet)
if(checked){//if checked need to add it to the controllers
set.controllers.push(fan)
}else{//will need to make sure it is not in the controllers
let index = controllerIndex(fan.key())
if (index !== -1){
set.controllers.splice(index, 1)
}
}
set.combo = comboCheck(set)
setCurrentSet(set)
updateSet(sensor.key(), set)
}}
/>
}
label={fan.name()}
/>
</Grid2>
<Grid2 size={{xs: 12, sm: 6}}>
{presets && getOptionsForController(pond.ControllerType.CONTROLLER_TYPE_FAN).length > 0 &&
<Autocomplete
disablePortal
options={getOptionsForController(pond.ControllerType.CONTROLLER_TYPE_FAN)}
fullWidth
getOptionLabel={option => option.label || ""}
onChange={(_, newValue) => {
if (newValue) {
let presetId = newValue.id;
if (presets && presets[presetId]) {
//setSelectedPreset(presets[presetId]);
let set = cloneDeep(currentSet)
set.selectedPresets.set(fan.key(), presets[presetId])
set.combo = comboCheck(set)
setCurrentSet(set)
updateSet(sensor.key(), set)
}
}
}}
renderInput={params => <TextField {...params} variant="outlined" label="Preset" />}
/>
}
</Grid2>
</Grid2>
))}
</React.Fragment>
}
</AccordionDetails>
</Accordion>
}
</Box>
)
}

View file

@ -19,6 +19,10 @@ export interface ButtonData {
* The function to run when the button is clicked or toggled on when toggle is true
*/
function: () => void
/**
* whether the button is disabled or not
*/
disabled?: boolean
}
interface Props {
@ -49,6 +53,10 @@ interface Props {
* Numerical value for the size of the text for buttons that dont use the icon
*/
textSize?: number
/**
* When true will disable all of the buttons in the group, will be overridded by individual buttons disabled state in the button data
*/
disableAll?: boolean
}
const useStyles = makeStyles((theme: Theme) => {
@ -88,7 +96,7 @@ const useStyles = makeStyles((theme: Theme) => {
})
export default function ButtonGroup(props: Props){
const { buttons, toggle, toggledButtons, multiToggle, textSize } = props
const { buttons, toggle, toggledButtons, multiToggle, textSize, disableAll } = props
const classes = useStyles()
const [currentToggle, setCurrentToggle] = useState<number[]>([])
@ -135,6 +143,7 @@ export default function ButtonGroup(props: Props){
<Box className={classes.container} display="flex" flexDirection="row">
{buttons.map((b, i) => (
<Button
disabled={disableAll ?? b.disabled}
key={i}
className={checkToggled(i) ? classes.buttonToggled : classes.button}
onClick={() => {

View file

@ -0,0 +1,140 @@
import { AxiosResponse } from "axios"
import { Box, Button, CircularProgress, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material"
import { useEffect, useState } from "react"
import { CheckCircle, Error, Pending } from "@mui/icons-material"
import React from "react"
export interface Stage {
title: string
steps: Step[]
}
export interface Step {
title: string
promise: Promise<AxiosResponse>
onComplete?: () => void
onError?: () => void
}
interface Props {
stages: Stage[]
automaticStart: boolean
failFast?: boolean
onStart?: () => void
onComplete?: () => void
}
enum progress {
Waiting = 1,
InProgress = 2,
Complete = 3,
Failed = 4
}
export function PromiseProgress(props: Props){
const {stages, failFast, onStart, onComplete, automaticStart} = props
const [completion, setCompletion] = useState<Map<string, progress>>(new Map())
const execute = () => {
let completionMap: Map<string, progress> = new Map()
stages.forEach((stage, i) => {
stage.steps.forEach((_, k) => {
completionMap.set(i + "-" + k, progress.Waiting)
})
})
setCompletion(completionMap)
const runStages = async () => {
let progMap = new Map(completionMap)
for (const [i, stage] of stages.entries()) {
// For each stage, map the steps into their own promise chains
const stepPromises = stage.steps.map((step, k) => {
// mark step as in progress
progMap.set(i+"-"+k, progress.InProgress);
setCompletion(new Map(progMap));
return step.promise
.then(() => {
// mark step as completed
progMap.set(i+"-"+k, progress.Complete);
setCompletion(new Map(progMap));
if (step.onComplete) step.onComplete();
})
.catch(err => {
// mark step as failed
progMap.set(i+"-"+k, progress.Failed);
setCompletion(new Map(progMap));
if(failFast){
throw err; // this rejects this step promise
}
})
});
// Wait for all steps in the current stage to finish
// (they run in parallel)
await Promise.all(stepPromises);
}
};
if(onStart){
onStart()
}
runStages().catch(err => {
console.error("Execution stopped due to error:", err);
}).finally(() => {
if(onComplete) onComplete()
});
}
useEffect(() => {
if(automaticStart){
execute()
}
},[automaticStart, execute])
const getCompletion = (completion?: progress) => {
switch(completion){
case progress.InProgress:
return <CircularProgress />
case progress.Complete:
return <CheckCircle />
case progress.Failed:
return <Error />
default://default will run if it is waiting or there was no completion
return <Pending />
}
}
const displayStage = (stage: Stage, number: number) => {
return (
<Box key={number}>
<Typography>Stage {stage.title}</Typography>
{stage.steps.map((step, i) => (
<Box key={number+"-"+i} display="flex" flexDirection="row" justifyContent="space-between" margin={2} alignContent="center" alignItems="center">
<Typography>{step.title}</Typography>
{getCompletion(completion.get(number + "-" + i))}
</Box>
))}
</Box>
)
}
return (
<React.Fragment>
<DialogTitle>
Progress
</DialogTitle>
<DialogContent>
{stages.map((stage, number) => displayStage(stage, number))}
</DialogContent>
<DialogActions>
{!automaticStart &&
<Button onClick={() => {
execute()
}}>
Start
</Button>
}
</DialogActions>
</React.Fragment>
)
}

View file

@ -357,7 +357,7 @@ export default function InteractionSettings(props: Props) {
const addCondition = () => {
let updatedInteraction = Interaction.clone(interaction);
let updatedRawConditionValues = rawConditionValues;
if (numConditions < 2 && interaction.settings.source) {
if (numConditions < device.maxConditions() && interaction.settings.source) {
let condition = pond.InteractionCondition.create();
condition.measurementType = getMeasurements(
interaction.settings.source.type
@ -957,29 +957,16 @@ export default function InteractionSettings(props: Props) {
</TextField>
</Grid>
<Grid size={{ xs: 2, sm: 1 }}>
{index === 0 ? (
<Tooltip title="Add another condition">
<IconButton
color="primary"
disabled={numConditions > 1 || !canEdit}
aria-label="Add another condition"
onClick={addCondition}
className={classNames(classes.greenButton, classes.noPadding)}>
<AddIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip title="Remove this condition">
<IconButton
color="default"
disabled={!canEdit}
disabled={numConditions === 1 ||!canEdit}
aria-label="Remove condition"
onClick={() => removeCondition(index)}
className={classNames(classes.redButton, classes.noPadding)}>
<RemoveIcon />
</IconButton>
</Tooltip>
)}
</Grid>
</Grid>
{multiNodeSource() && !sensor && (
@ -1076,6 +1063,16 @@ export default function InteractionSettings(props: Props) {
) : (
<FormHelperText>You must select a source before adding conditions</FormHelperText>
)}
<Tooltip title="Add another condition">
<IconButton
color="primary"
disabled={numConditions >= device.maxConditions() || !canEdit}
aria-label="Add another condition"
onClick={addCondition}
className={classNames(classes.greenButton, classes.noPadding)}>
<AddIcon />
</IconButton>
</Tooltip>
</FormControl>
);
};

View file

@ -154,6 +154,18 @@ export class Device {
}
}
public maxConditions(): number {
let max = 2
switch (this.settings.platform) {
case pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR_BLACK:
case pond.DevicePlatform.DEVICE_PLATFORM_V2_WIFI_BLUE:
case pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR_BLUE:
case pond.DevicePlatform.DEVICE_PLATFORM_V2_ETHERNET_BLUE:
max = this.status.firmwareVersion >= "2.1.9" ? 4 : 2;
}
return max
}
public featureSupported(feature: string): boolean {
let versions = featureVersions.get(feature);
if (!versions) {

View file

@ -689,8 +689,8 @@ export default function Bin(props: Props) {
const overview = () => {
return (
<BinVisualizerV2
plenum={plenums[0]}
pressure={pressures[0]}
plenums={plenums}
pressures={pressures}
ambient={ambients[0]}
cables={grainCables}
bin={bin}
@ -698,8 +698,6 @@ export default function Bin(props: Props) {
components={components}
componentDevices={componentDevices}
devices={devices}
interactions={interactions}
//changeBinMode={changeBinMode}
afterUpdate={refresh}
refresh={refresh}
preferences={preferences}