updated the colldown mode, fixed a bug where the bin mode on the page was not changing in the dropdown after completing the mode change and some UI tweaks like text and descriptions

This commit is contained in:
csawatzky 2026-07-14 13:21:53 -06:00
parent e91d2f6005
commit 1ac937e7db
9 changed files with 500 additions and 101 deletions

View file

@ -145,6 +145,8 @@ export default function Bin3dView(props: Props) {
<Canvas style={{position: "absolute", inset: 0}}>
<OrbitCameraControls
clampVerticalRotation
autoRotate
autoRotateSpeed={0.5}
initialRadius={initialCameraRadius}
maxRadius={initialCameraRadius * 2}
onReset={fn => { resetCameraFn.current = fn; }}

View file

@ -367,6 +367,9 @@ export default function bin3dVisualizer(props: Props){
newMode={newMode}
open={openModeChange}
onClose={()=>{setOpenModeChange(false)}}
modeUpdated={(newMode) => {
setBinMode(newMode)
}}
/>
<Box position={"relative"} height={600}>
<Bin3dView

View file

@ -171,10 +171,12 @@ export default function BinModeController(props: Props) {
<DialogContent>
<PromiseProgress
stages={promiseStages}
description="These are the changes that will occur in order to change your bin mode with the given options. Press start to begin."
description="These are the changes that will occur in order to change your bin mode with the given options.
It will allow the device complete control of the selected components. Your grain is still your responsibility.
Press start to begin."
failFast
onStart={() => {
//this is just a function that is rin as soo as the start button is clicked it can be used to disable things while the change is in progress if we want to
//this is just a function that is rin as soon as the start button is clicked it can be used to disable things while the change is in progress if we want to
}}
onComplete={()=>{
closeDialog()
@ -218,13 +220,25 @@ export default function BinModeController(props: Props) {
}}
/>
case pond.BinMode.BIN_MODE_COOLDOWN:
return <CooldownMode />
return <CooldownMode
grain={bin.settings.inventory?.grainType}
customGrain={bin.settings.inventory?.customGrain ?? undefined}
devices={devices}
binPrefs={binPrefs}
deviceComponents={deviceComponents}
cancel={closeDialog}
confirm={(deviceData) => {
buildStages(deviceData)
setShowProgress(true)
}}
/>
default:
return <StorageMode
devices={devices}
binPrefs={binPrefs}
bin={bin}
deviceComponents={deviceComponents}
cancel={closeDialog}
confirm={(deviceData)=>{
buildStages(deviceData)
setShowProgress(true)

View file

@ -1,9 +1,394 @@
import { Box } from "@mui/material";
import { Box, Theme, Stepper, Step, StepLabel, Typography, Button, Autocomplete, TextField, DialogTitle, DialogContent, DialogActions, CircularProgress } from "@mui/material";
import { makeStyles } from "@mui/styles";
import ConditioningSelector from "bin/conditioning/conditioningSelector";
import { Component, Device, Interaction } from "models";
import { Ambient } from "models/Ambient";
import { Controller } from "models/Controller";
import { Plenum } from "models/Plenum";
import { pond, quack } from "protobuf-ts/pond";
import { useEffect, useState } from "react";
import { ComponentSet } from "bin/conditioning/pickComponentSet"
import { componentIDToString, sameComponentID } from "pbHelpers/Component";
import moment from "moment";
import { lowerCase } from "lodash";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { useGlobalState, useInteractionsAPI } from "providers";
import ConditionDisplay from "./conditionDisplay";
import React from "react";
import { DeviceChangeData } from "./BinModeController";
import { GrainCable } from "models/GrainCable";
const useStyles = makeStyles((theme: Theme) => {
return ({
stepper: {
padding: theme.spacing(0.5)
},
})
});
interface Step {
label: string;
completed?: boolean;
}
interface Option {
label: string;
device: Device;
icon?: string;
}
interface Props {
devices: Device[]
deviceComponents: Map<number, Component[]>
binPrefs?: Map<string, pond.BinComponentPreferences>
grain?: pond.Grain
customGrain?: pond.GrainSettings
cancel: () => void
confirm: (deviceData: DeviceChangeData[]) => void
}
const steps = [{label: "Device"}, {label: "Interaction"}]
//this is our simple drying mode the way it currently works, just building the dialog to be a little more trnasparent and customizable as to what the interaction is doing
export default function CooldownMode(props: Props){
const {devices, deviceComponents, binPrefs, grain, customGrain, cancel, confirm} = props
const classes = useStyles()
const [{user}] = useGlobalState()
const interactionAPI = useInteractionsAPI()
const [options, setOptions] = useState<Option[]>([])
const [deviceOption, setDeviceOption] = useState<Option>({device: Device.create(), label: "Select Device"})
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>()
const [plenums, setPlenums] = useState<Plenum[]>([])
const [ambients, setAmbients] = useState<Ambient[]>([])
const [fans, setFans] = useState<Controller[]>([])
const [cables, setCables] = useState<GrainCable[]>([])
const [currentStep, setCurrentStep] = useState(0)
const [interactionLoading,setInteractionsLoading] = useState(false)
// possibly all 4 of these will need to be passed up, at least the conflicting, toAdd, and sets will
const [componentSets, setComponentSets] = useState<ComponentSet[]>([])
const [existingInteractions, setExistingInteractions] = useState<Interaction[]>([])
const [conflictingInteractions, setConflictingInteractions] = useState<Interaction[]>([])
const [interactionsToAdd, setInteractionsToAdd] = useState<pond.MultiInteractionSettings>()
const [sourceMap, setSourceMap] = useState<Map<string, Component>>(new Map());
const [sinkMap, setSinkMap] = useState<Map<string, Component>>(new Map());
//sort the components according to the bin preferences
useEffect(()=>{
if (!selectedDevice) return;
if (!deviceComponents.get(selectedDevice.id())) return;
var plenums: Plenum[] = [];
var ambients: Ambient[] = [];
var grainCables: GrainCable[] = [];
var fans: Controller[] = [];
var sinkMap: Map<string, Component> = new Map()
var sourceMap: Map<string, Component> = new Map()
deviceComponents.get(selectedDevice.id())!.forEach(comp => {
let pref = binPrefs?.get(comp.key());
if (pref) {
if (pref.type) {
if (pref.type === pond.BinComponent.BIN_COMPONENT_PLENUM){
plenums.push(Plenum.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_AMBIENT){
ambients.push(Ambient.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE){
grainCables.push(GrainCable.create(comp));
sourceMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_FAN) {
let fan = Controller.create(comp)
fan.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_OFF;
fans.push(fan);
sinkMap.set(comp.locationString(), comp)
}
}
}
});
setPlenums(plenums);
setAmbients(ambients);
setFans(fans);
setSourceMap(sourceMap)
setSinkMap(sinkMap)
setCables(grainCables)
//also load the interactions for the selected device so that we can find any conflicting ones
setInteractionsLoading(true)
interactionAPI.listInteractionsByDevice(selectedDevice.id()).then(resp => {
setExistingInteractions(resp)
}).finally(() => {
setInteractionsLoading(false)
})
}, [deviceComponents, selectedDevice, binPrefs]);
useEffect(() => {
let o: Option[] = [];
devices.forEach((device, i) => {
let newOption: Option = {device: device, label: device.name()}
if(i === 0) {
setDeviceOption(newOption)
setSelectedDevice(device)
}
o.push(newOption);
});
setOptions(o);
}, [devices, setOptions]);
const buildFanInteraction = (
sensor: Plenum | Ambient,
fan: Controller,
) => {
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 tempDefault = 15;
let conditions = [];
//may not actually need a condition on the humidity for cooldown
// let humidityDefault = 60;
// 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,
// undefined,
// user
// ).toStored(humidityDefault)
// });
// 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 (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
tempDefault = Math.round((tempDefault * (9 / 5) + 32) * 100) / 100;
}
let tempVal = describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
sensor.settings.type,
sensor.settings.subtype,
undefined,
user
).toStored(tempDefault);
let fanConditionTwo = 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 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 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());
//loop through the sets to find interactions to remove as well as set the new ones
sets.forEach((set) => {
//loop through the controllers
set.controllers.forEach(controller => {
//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)
multiInteractionSettings.interactions.push(buildFanInteraction(set.sensor, controller))
})
})
cables.forEach(cable => {
console.log(cable.name())
let c = existingInteractions.filter(i => {
if (sameComponentID(cable.location(), i.settings.source) && !i.settings.sink && i.settings.notifications?.notify) {
return true
}
});
conflictingInteractions = conflictingInteractions.concat(c)
})
setComponentSets([...sets])
return {
conflicting: conflictingInteractions,
toAdd: multiInteractionSettings
}
}
const stepper = () => {
return (
<Stepper
activeStep={currentStep}
alternativeLabel
classes={{
root: classes.stepper
}}>
{steps.map((s, i) => (
<Step key={i}>
<StepLabel>{s.label}</StepLabel>
</Step>
))}
</Stepper>
);
}
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 deviceStep = () => {
return (
<Box>
<Typography>Select the device to condition the bin</Typography>
{deviceSelector()}
{interactionLoading ? <CircularProgress /> :
<ConditioningSelector
plenums={plenums}
ambients={ambients}
fans={fans}
heaters={[]}
//presets={presets}
binMode={pond.BinMode.BIN_MODE_COOLDOWN}
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){
setConflictingInteractions(i.conflicting)
setInteractionsToAdd(i.toAdd)
}
}}
/>
}
</Box>
)
}
//this step will use the conflicting interactions and interactions to add to display them and allow users to make changes
const interactionStep = () => {
return (
<Box>
<Typography>Interactions</Typography>
{interactionsToAdd?.interactions.map((interaction, index) => {
let temp = Interaction.create()
temp.settings = interaction
let sink = sinkMap.get(componentIDToString(interaction.sink))
let source = sourceMap.get(componentIDToString(interaction.source))
if(sink && source && selectedDevice){
return (
<ConditionDisplay
key={index}
grain={grain}
customGrain={customGrain}
interaction={temp}
device={selectedDevice}
sink={sink}
source={source}
changeConditions={(newSettings) => {
console.log(newSettings)
}}
/>
)
}
})}
</Box>
)
}
const stepperContent = () => {
switch(currentStep){
case 1:
return interactionStep()
default:
return deviceStep()
}
}
const actions = () => {
return (
<React.Fragment>
{/* close - tells the parent to close the dialog without doing anything, always visible */}
<Button onClick={cancel}>Close</Button>
{/* back - goes back to the previous step, hidden on the forst step */}
{currentStep !== 0 && <Button onClick={() => {setCurrentStep(currentStep-1)}}>Back</Button>}
{/* next - goes to the next step, hidden on the last step */}
{currentStep !== steps.length - 1 && <Button onClick={() => {setCurrentStep(currentStep+1)}}>Next</Button>}
{/* confirm - tells the parent to build the stages using the data, only visible on the last step */}
{currentStep === steps.length - 1 && interactionsToAdd && selectedDevice && <Button onClick={() => {confirm([{ toRemove: conflictingInteractions, deviceId: selectedDevice.id(), toAdd: interactionsToAdd, controllers: componentSets.flatMap(set => set.controllers)}])}}>Confirm</Button>}
</React.Fragment>
)
}
export default function CooldownMode(){
return (
<Box>
</Box>
<React.Fragment>
<DialogTitle>Bin Cooldown</DialogTitle>
<DialogContent>
{stepper()}
{stepperContent()}
</DialogContent>
<DialogActions>
{actions()}
</DialogActions>
</React.Fragment>
)
}

View file

@ -437,7 +437,7 @@ export default function DryingMode(props: Props){
return (
<Box>
<Typography>Devices</Typography>
<Typography>Select the device to condition the bin</Typography>
{deviceSelector()}
{interactionLoading ? <CircularProgress /> :
<ConditioningSelector

View file

@ -1,4 +1,4 @@
import { Box, Theme, Stepper, Step, StepLabel, Typography, RadioGroup, Radio, FormControlLabel, Button, Autocomplete, TextField, DialogTitle, DialogContent, DialogActions, CircularProgress } from "@mui/material";
import { Box, Theme, Stepper, Step, StepLabel, Typography, Button, Autocomplete, TextField, DialogTitle, DialogContent, DialogActions, CircularProgress } from "@mui/material";
import { makeStyles } from "@mui/styles";
import ConditioningSelector from "bin/conditioning/conditioningSelector";
import { Component, Device, Interaction } from "models";
@ -49,7 +49,7 @@ interface Props {
confirm: (deviceData: DeviceChangeData[]) => void
}
const steps = [{label: "Style"}, {label: "Device"}, {label: "Interaction"}]
const steps = [{label: "Device"}, {label: "Interaction"}]
//this is our simple drying mode the way it currently works, just building the dialog to be a little more trnasparent and customizable as to what the interaction is doing
export default function DryingMode(props: Props){
@ -59,7 +59,7 @@ export default function DryingMode(props: Props){
const [{user}] = useGlobalState()
const interactionAPI = useInteractionsAPI()
const [options, setOptions] = useState<Option[]>([])
const [deviceOption, setDeviceOption] = useState<Option>()
const [deviceOption, setDeviceOption] = useState<Option>({device: Device.create(), label: "Select Device"})
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>()
const [plenums, setPlenums] = useState<Plenum[]>([])
const [ambients, setAmbients] = useState<Ambient[]>([])
@ -134,9 +134,9 @@ export default function DryingMode(props: Props){
useEffect(() => {
let o: Option[] = [];
devices.forEach(device => {
devices.forEach((device, i) => {
let newOption: Option = {device: device, label: device.name()}
if(deviceOption === undefined) {
if(i === 0) {
setDeviceOption(newOption)
setSelectedDevice(device)
}
@ -148,8 +148,6 @@ export default function DryingMode(props: Props){
const buildFanInteraction = (
sensor: Plenum | Ambient,
fan: Controller,
tempComparison: "greater" | "less",
humidityComparison: "greater" | "less",
) => {
let interaction = pond.InteractionSettings.create({
source: sensor.location(),
@ -176,10 +174,7 @@ export default function DryingMode(props: Props){
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,
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN,
value: describeMeasurement(
quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
sensor.settings.type,
@ -204,10 +199,7 @@ export default function DryingMode(props: Props){
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,
comparison: 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);
@ -242,7 +234,7 @@ export default function DryingMode(props: Props){
});
conflictingInteractions = conflictingInteractions.concat(c)
multiInteractionSettings.interactions.push(buildFanInteraction(set.sensor, controller, "greater", "less"))
multiInteractionSettings.interactions.push(buildFanInteraction(set.sensor, controller))
})
})
cables.forEach(cable => {
@ -302,7 +294,7 @@ export default function DryingMode(props: Props){
return (
<Box>
<Typography>Devices</Typography>
<Typography>Select the device to condition the bin</Typography>
{deviceSelector()}
{interactionLoading ? <CircularProgress /> :
<ConditioningSelector

View file

@ -22,6 +22,7 @@ interface Props {
bin: Bin
binPrefs?: Map<string, pond.BinComponentPreferences>
confirm: (deviceData: DeviceChangeData[]) => void
cancel: () => void
}
/**
@ -30,7 +31,7 @@ interface Props {
* @returns
*/
export default function StorageMode(props: Props){
const {devices, deviceComponents, bin, binPrefs, confirm} = props
const {devices, deviceComponents, bin, binPrefs, confirm, cancel} = props
const interactionsAPI = useInteractionsAPI()
const [data, setData] = useState<DeviceChangeData[]>([])
const [loading, setLoading] = useState(false)
@ -169,8 +170,10 @@ export default function StorageMode(props: Props){
<DialogTitle>Storage Mode</DialogTitle>
<DialogContent>
{/* could have the explanation of what changing to storage mode does here */}
Setting the bin to storage mode will remove any interactions for controllers connected to this bin and will set alerts on the cables that dont have any alerts set.
</DialogContent>
<DialogActions>
<Button onClick={() => cancel}>Cancel</Button>
<Button onClick={()=>{
confirm(data)
}} disabled={loading || loadErr}>Confirm</Button>

View file

@ -22,7 +22,6 @@ import {
import React, { useEffect, useMemo, useState } from "react";
import { avg, fahrenheitToCelsius } from "utils";
import { makeStyles } from "@mui/styles";
import { Mark } from "@mui/material/Slider/useSlider.types";
const useStyles = makeStyles((theme: Theme) => {
return ({
@ -299,30 +298,7 @@ import {
changeConditions(interaction.settings);
}
};
const customMark = (val: string, arrowColor: string) => {
return (
<Box className={classes.markContainer}>
<Grid container direction="column" alignContent="center" alignItems="center" spacing={1}>
<Grid item>
<Typography style={{ color: "white", fontSize: 15, fontWeight: 650, lineHeight: 1 }}>
{val}
</Typography>
</Grid>
<Grid item>
<Box className={classes.arrowDown} style={{ borderTop: "10px solid " + arrowColor }} />
</Grid>
</Grid>
</Box>
);
};
// Renders a single condition's slider. Pulled out so both the grouped
// (paired) rendering path and the fallback flat rendering path can share it.
// onRemove/removeTooltip, when provided, render a small remove icon inline
// with this specific slider -- used when two pairs exist, so each row's
// control is unambiguous about which single condition it sits on, even
// though clicking it removes that condition's whole paired counterpart too.
const renderConditionSlider = (
condition: pond.InteractionCondition,
key: React.Key,
@ -330,16 +306,6 @@ import {
removeTooltip?: string
) => {
let describer = describeMeasurement(condition.measurementType, source?.type(), source.subType(), undefined, user);
let labelTail = describer.unit();
let marks: Mark[] = [];
let mark = sliderMarks.get(condition.measurementType);
if (mark !== undefined) {
marks.push({
label: customMark(describer.toDisplay(mark) + labelTail, describer.colour()),
value: describer.toDisplay(mark)
});
}
return (
<Grid key={key} container item xs={12} alignItems="center" wrap="nowrap">
@ -351,9 +317,6 @@ import {
track: classes.sliderTrack,
thumb: classes.sliderThumb,
valueLabel: classes.sliderValLabel,
marked: classes.sliderMarked,
mark: classes.sliderMark,
markLabel: classes.sliderMarkLabel
}}
step={0.1}
valueLabelDisplay="on"
@ -369,7 +332,6 @@ import {
return value.toFixed(1) + "%";
}
}}
marks={marks}
min={describer.min()}
max={describer.max()}
value={sliderVals.get(condition) ?? describer.min()}