Compare commits

...
Sign in to create a new pull request.

13 commits

Author SHA1 Message Date
csawatzky
fcbd0fd360 building up the framework for the three phase fan interaction 2026-07-17 13:37:56 -06:00
csawatzky
26dfd0db01 added the switch in the interaction setting to enable/disable interactions, also set the framework for the feature in the device model but all the firmware version are 'N/A' for now until i know what version this feature is in 2026-07-17 09:49:06 -06:00
csawatzky
7ac469f9db added a dialog to cinfirm the mode change for the controller 2026-07-14 15:47:35 -06:00
csawatzky
1ac937e7db 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 2026-07-14 13:21:53 -06:00
csawatzky
e91d2f6005 re-building the condition display to support adding extra conditions to the interaction for drying and passing the device in to the condition display so that it can use its max conditions function to determine if the extra conditions can be added 2026-07-13 16:19:17 -06:00
csawatzky
aa346a6a5a set up the bones for the humidity mode 2026-07-10 15:27:47 -06:00
csawatzky
208a657528 finished the functional part of storage as well as modified the drying dialog to remove notification interactions from the cables 2026-07-10 11:59:42 -06:00
csawatzky
2c6799bcc1 working on storage building the stuff to send up 2026-07-09 10:21:24 -06:00
csawatzky
7e6b41273b defined device change data, and started working on storage 2026-07-08 10:57:23 -06:00
csawatzky
bced38d454 nailed down the overall structure for the new bin modes now 2026-07-06 16:41:46 -06:00
csawatzky
19eb2a7faf proto update 2026-06-29 15:30:25 -06:00
csawatzky
179e9abbd9 Merge branch 'staging_environment' into bin_modes_v2 2026-06-29 15:29:43 -06:00
csawatzky
6d1c9431f2 setting up the framework for rebuilding the bin modes 2026-06-18 10:54:14 -06:00
24 changed files with 2778 additions and 109 deletions

4
package-lock.json generated
View file

@ -46,7 +46,7 @@
"mui-tel-input": "^7.0.0",
"notistack": "^3.0.1",
"openweathermap-ts": "^1.2.10",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bin_modes_v2",
"query-string": "^9.2.1",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
@ -11752,7 +11752,7 @@
},
"node_modules/protobuf-ts": {
"version": "1.0.0",
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#100126cc7f3cdf18f68af6372e5db4113db012b7",
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#8a8ec79e9cc72bb770856c9e8e66f5294141da66",
"dependencies": {
"protobufjs": "^6.8.8"
}

View file

@ -59,7 +59,7 @@
"mui-tel-input": "^7.0.0",
"notistack": "^3.0.1",
"openweathermap-ts": "^1.2.10",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging",
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bin_modes_v2",
"query-string": "^9.2.1",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",

View file

@ -1,6 +1,5 @@
import React from "react";
import { useThree } from "@react-three/fiber";
import { useEffect, useRef } from "react";
import { useThree, useFrame } from "@react-three/fiber";
import { useCallback, useEffect, useRef } from "react";
import { Vector2, Vector3 } from "three";
interface CameraTarget {
@ -45,6 +44,18 @@ interface Props {
* Wire it by passing a ref setter: onReset={fn => resetFn.current = fn}
*/
onReset?: (resetFn: () => void) => void;
/**
* Automatically spin the camera around the target on mount.
* Any user interaction (drag, pan, zoom, pinch) stops it permanently
* until the camera is reset (reset restarts it if this is still true).
* @default false
*/
autoRotate?: boolean;
/**
* Auto-rotate speed in radians per second.
* @default 0.3
*/
autoRotateSpeed?: number;
}
export default function OrbitCameraControls(props: Props) {
@ -58,6 +69,8 @@ export default function OrbitCameraControls(props: Props) {
initialRadius,
maxRadius,
onReset,
autoRotate = false,
autoRotateSpeed = 0.3,
} = props;
const { camera, gl } = useThree();
@ -75,6 +88,9 @@ export default function OrbitCameraControls(props: Props) {
const panCameraPosition = useRef(new Vector3());
const lastPinchDist = useRef(0);
// Auto-rotate: active until the user does *anything* (drag/pan/zoom/pinch)
const autoRotateActive = useRef(autoRotate);
// Spherical coords
const spherical = useRef({
radius: initialRadius ?? 10,
@ -82,14 +98,9 @@ export default function OrbitCameraControls(props: Props) {
phi: Math.PI / 2, // vertical angle
});
useEffect(() => {
// Sync radius whenever initialRadius prop changes (e.g. after bin data loads)
spherical.current.radius = initialRadius ?? 10;
const canvas = gl.domElement;
canvas.addEventListener("contextmenu", e => e.preventDefault());
const updateCamera = () => {
// Pulled out of the effect so both the DOM-event effect and the
// useFrame auto-rotate loop below can call it.
const updateCamera = useCallback(() => {
const { radius, theta, phi } = spherical.current;
const x = radius * Math.sin(phi) * Math.sin(theta);
@ -110,7 +121,22 @@ export default function OrbitCameraControls(props: Props) {
// Shift the projected image without affecting orbit
const { width, height } = gl.domElement.getBoundingClientRect();
(camera as any).setViewOffset(width, height, viewOffset ?? 0, 0, width, height);
};
}, [camera, gl, viewOffset]);
// Drives the auto-spin every frame while it's active. Runs inside the
// R3F render loop, so it's cheap and stays in sync with everything else.
useFrame((_, delta) => {
if (!autoRotateActive.current) return;
spherical.current.theta += autoRotateSpeed * delta;
updateCamera();
});
useEffect(() => {
// Sync radius whenever initialRadius prop changes (e.g. after bin data loads)
spherical.current.radius = initialRadius ?? 10;
const canvas = gl.domElement;
canvas.addEventListener("contextmenu", e => e.preventDefault());
// Hand the reset function to the caller so they can trigger it
// (e.g. from a button in CameraOverlay) without needing an external ref.
@ -124,15 +150,25 @@ export default function OrbitCameraControls(props: Props) {
y: (target?.y ?? 0) + (offset?.y ?? 0),
z: (target?.z ?? 0) + (offset?.z ?? 0),
};
// Resuming auto-rotate on reset feels natural — remove this
// line if you'd rather it stay stopped once the user has
// touched the camera.
autoRotateActive.current = autoRotate;
updateCamera();
});
}
updateCamera();
const stopAutoRotate = () => {
autoRotateActive.current = false;
};
const handleMouseDown = (e: MouseEvent) => {
if (e.target !== canvas) return;
stopAutoRotate();
lastPos.current = { x: e.clientX, y: e.clientY };
panCameraPosition.current.copy(camera.position);
@ -213,6 +249,7 @@ export default function OrbitCameraControls(props: Props) {
if (e.target !== canvas) return;
e.preventDefault();
stopAutoRotate();
spherical.current.radius += e.deltaY * 0.01;
spherical.current.radius = Math.max(
@ -226,6 +263,7 @@ export default function OrbitCameraControls(props: Props) {
const handleTouchStart = (e: TouchEvent) => {
if (e.target !== canvas) return;
e.preventDefault();
stopAutoRotate();
if (e.touches.length === 1) {
isDragging.current = true;
@ -323,7 +361,7 @@ export default function OrbitCameraControls(props: Props) {
canvas.removeEventListener("touchmove", handleTouchMove);
canvas.removeEventListener("touchend", handleTouchEnd);
};
}, [camera, gl, target, clampVerticalRotation, minPhi, maxPhi, initialRadius, maxRadius]);
}, [camera, gl, target, offset, clampVerticalRotation, minPhi, maxPhi, initialRadius, maxRadius, onReset, autoRotate, updateCamera]);
return null;
}

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

@ -85,10 +85,14 @@ interface Props {
sink: Component;
grain?: pond.Grain;
customGrain?: pond.GrainSettings
/**
* if true, the button to update the interaction will be hidden because it will be assumed that the parent will handle the interaction
*/
parentUpdate?: boolean
}
export default function BinConditioningInteraction(props: Props) {
const { interaction, source, sink, grain, deviceId, customGrain } = props;
const { interaction, source, sink, grain, deviceId, customGrain, parentUpdate} = props;
const [sliderVals, setSliderVals] = useState<Map<quack.MeasurementType, number>>(new Map());
const [sliderMarks, setSliderMarks] = useState<Map<quack.MeasurementType, number>>(new Map());
//this is the emc value calculated from the interactions temp and humidity conditions
@ -238,6 +242,7 @@ export default function BinConditioningInteraction(props: Props) {
max={describer.max()}
value={sliderVals.get(condition.measurementType) ?? describer.min()}
onChange={(_, val) => {
//note that changing it here like this is what is changing it in the interaction itself
condition.value = Math.round(describer.toStored(val as number));
let sliders = cloneDeep(sliderVals);
sliders.set(condition.measurementType, val as number);
@ -249,6 +254,7 @@ export default function BinConditioningInteraction(props: Props) {
);
})}
<Grid item xs={12}>
{!parentUpdate &&
<Button
variant="contained"
color="primary"
@ -257,6 +263,7 @@ export default function BinConditioningInteraction(props: Props) {
}}>
Update Conditions
</Button>
}
</Grid>
</Grid>
);

View file

@ -11,6 +11,7 @@ import { CableData } from "bin/3dView/Data/BuildCableData";
import ModeChangeDialog from "bin/conditioning/modeChangeDialog";
import { cloneDeep } from "lodash";
import { useMobile } from "hooks";
import BinModeController from "./binModes/BinModeController";
interface Props {
bin: Bin
@ -35,6 +36,7 @@ export default function bin3dVisualizer(props: Props){
const [showMoistureHeatmap, setShowMoistureHeatmap] = useState(false)
const [binDisplay, setBinDisplay] = useState<string>("temp")
const [binMode, setBinMode] = useState<pond.BinMode>(pond.BinMode.BIN_MODE_NONE)
const [newMode, setNewMode] = useState<pond.BinMode>(pond.BinMode.BIN_MODE_NONE)
const [openModeChange, setOpenModeChange] = useState(false)
const [selectedCable, setSelectedCable] = useState<CableData | undefined>(undefined)
const [selectedNode, setSelectedNode] = useState<NodeData | undefined>(undefined)
@ -58,14 +60,6 @@ export default function bin3dVisualizer(props: Props){
setBinMode(bin.settings.mode)
},[bin])
const updateBin = () => {
if(updateBinCallback){
let clone = cloneDeep(bin)
clone.settings.mode = binMode
updateBinCallback(clone)
}
};
const binModeControl = () => {
return (
<Box>
@ -86,8 +80,10 @@ export default function bin3dVisualizer(props: Props){
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { border: 'none' },
}}
onChange={event => {
setBinMode(event.target.value as pond.BinMode)
setNewMode(event.target.value as pond.BinMode)
setOpenModeChange(true)
// for the re-built modes, each one will have its own dialog box and this will just control which one to open rather than the single dialog for all of them
// and having the mode determine what to show in the dialog
}}
>
<MenuItem value={pond.BinMode.BIN_MODE_NONE}>Select Mode..</MenuItem>
@ -327,7 +323,7 @@ export default function bin3dVisualizer(props: Props){
}}
/>
}
<ModeChangeDialog
{/* <ModeChangeDialog
binKey={bin.key()}
binMode={binMode}
grain={bin.settings.inventory?.grainType}
@ -361,7 +357,19 @@ export default function bin3dVisualizer(props: Props){
changeComplete={() => {
setModeChangeInProgress(false)
}}
/> */}
<BinModeController
bin={bin}
binPrefs={binPrefs}
devices={devices}
componentDevices={componentDevices}
componentMap={componentMap}
newMode={newMode}
open={openModeChange}
onClose={()=>{setOpenModeChange(false)}}
modeUpdated={(newMode) => {
setBinMode(newMode)
}}
/>
<Box position={"relative"} height={600}>
<Bin3dView

View file

@ -0,0 +1,259 @@
import { Box, Button, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog";
import { Bin, Component, Device, Interaction } from "models";
import { pond } from "protobuf-ts/pond";
import React, { useEffect, useState } from "react";
import StorageMode from "./StorageMode";
import DryingMode from "./DryingMode";
import HydratingMode from "./HydratingMode";
import CooldownMode from "./CooldownMode";
import { PromiseProgress, Stage, Step } from "common/PromiseProgress";
import { useBinAPI, useComponentAPI, useInteractionsAPI } from "providers";
import { ComponentSet } from "bin/conditioning/pickComponentSet";
import { cloneDeep } from "lodash";
import { Controller } from "models/Controller";
import ThreePhaseFanDry from "./ThreePhaseFanDry";
export interface DeviceChangeData {
/**
* the id of the device changes are being made to
*/
deviceId: number
/**
* the array of interactions to remove from the device
*/
toRemove: Interaction[]
/**
* the new interactions to add to the device
*/
toAdd?: pond.MultiInteractionSettings
/**
* the controllers that need to have there state updated
*/
controllers: Controller[]
}
interface Props {
newMode: pond.BinMode //used to control which dialog to open
open: boolean
onClose: () => void
bin: Bin
binPrefs?: Map<string, pond.BinComponentPreferences>
devices: Device[]
componentDevices: Map<string, number>
componentMap: Map<string, Component>
modeUpdated?: (mode: pond.BinMode) => void
}
export default function BinModeController(props: Props) {
const {open, onClose, newMode, devices, componentDevices, componentMap, binPrefs, bin, modeUpdated} = props
const [showProgress, setShowProgress] = useState(false)
const [deviceComponents, setDeviceComponents] = useState<Map<number, Component[]>>(new Map())
const [promiseStages, setPromiseStages] = useState<Stage[]>([])
const interactionAPI = useInteractionsAPI()
const componentAPI = useComponentAPI()
const binAPI = useBinAPI();
//the use effects for the initial setup of what will be needed
useEffect(()=>{
let newMap:Map<number, Component[]> = new Map()
componentMap.forEach((comp, key) => {
//first we need to get the device id this component belongs to
let dev = componentDevices.get(key)
if(dev){
//check if the key exists in the new map yet
if(newMap.has(dev)){
newMap.get(dev)?.push(comp)
}else{
newMap.set(dev, [comp])
}
}
})
setDeviceComponents(newMap)
},[componentDevices, componentMap])
//steps involved for devices when changing the bin mode:
//remove conflicting interactions
//add new interactions
//updating the controllers
const closeDialog = () => {
setShowProgress(false)
onClose()
}
const buildStages = (deviceData: DeviceChangeData[]) => {
//build the stages to pass into the PromiseProgress
//stage one is to remove conflicting interactions
let stages: Stage[] = []
let stage1: Stage = {
title: "Remove Interactions",
steps: []
}
let stage2: Stage = {
title: "Add New Interactions",
steps: []
}
let stage3: Stage = {
title: "Update Controllers",
steps: []
}
deviceData.forEach(data => {
data.toRemove.forEach(interaction => {
let newStep: Step = {
title: "Removing Interaction",
promise: () => interactionAPI.removeInteraction(data.deviceId, interaction.key())
}
stage1.steps.push(newStep);
})
let newInteractions = data.toAdd
if(newInteractions){
stage2.steps.push({
title: "Adding Interactions",
promise: () => interactionAPI.addMultiInteractions(data.deviceId, newInteractions)
})
}
data.controllers.forEach(controller => {
let newStep: Step = {
title: "Updating " + controller.name(),
promise: () => componentAPI.update(data.deviceId, controller.settings)
}
stage3.steps.push(newStep)
})
})
if(stage1.steps.length > 0){
stages.push(stage1)
}
if(stage2.steps.length > 0){
stages.push(stage2)
}
if(stage3.steps.length > 0){
stages.push(stage3)
}
//stage 4 is to update the bin with the new mode
let stage4: Stage = {
title: "Update Bin With New Mode",
steps: [
{
title: "Update " + bin.name(),
promise: () => {
let clone = cloneDeep(bin.settings)
clone.mode = newMode
//this will set the bin to use the auto top nodes for cables when the bin is going into storage mode, and turn them off if the bin is being conditioned
if(newMode === pond.BinMode.BIN_MODE_STORAGE){
clone.autoGrainNode = true
}else{
clone.autoGrainNode = false
}
return binAPI.updateBin(bin.key(), clone)
},
onComplete: () => modeUpdated?.(newMode)
}
]
}
stages.push(stage4)
//set those stages to a state variable
setPromiseStages(stages)
}
/**
* the progress step is the last step of each mode change that shows what is being done and what succeeded/failed
* initially i was going to put it here because it would effectively be the same thing for each mode, however depending
* on how i decide to handle the api calls, and since i am thinking about passing the functions into the children it may go into each mode component
*
*/
const progressContent = () => {
return (
<React.Fragment>
<DialogTitle>Change Mode</DialogTitle>
<DialogContent>
<PromiseProgress
stages={promiseStages}
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 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()
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Close</Button>
</DialogActions>
</React.Fragment>
)
}
const modeContent = () => {
switch(newMode){
case pond.BinMode.BIN_MODE_DRYING:
//setNumSteps(DryingStepCount)
return <DryingMode
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)
}}
/>
case pond.BinMode.BIN_MODE_HYDRATING:
return <HydratingMode
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)
}}
/>
case pond.BinMode.BIN_MODE_COOLDOWN:
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)
}}
/>
case pond.BinMode.BIN_MODE_THREE_PHASE_FAN_DRYING:
return <ThreePhaseFanDry
deviceComponents={deviceComponents}
/>
default:
return <StorageMode
devices={devices}
binPrefs={binPrefs}
bin={bin}
deviceComponents={deviceComponents}
cancel={closeDialog}
confirm={(deviceData)=>{
buildStages(deviceData)
setShowProgress(true)
}}/>
}
}
return (
<ResponsiveDialog open={open} onClose={closeDialog}>
{showProgress ? progressContent() : modeContent()}
</ResponsiveDialog>
)
}

View file

@ -0,0 +1,394 @@
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 [interactionsLoading,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()}
{interactionsLoading ? <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>
)
}
return (
<React.Fragment>
<DialogTitle>Bin Cooldown</DialogTitle>
<DialogContent>
{stepper()}
{stepperContent()}
</DialogContent>
<DialogActions>
{actions()}
</DialogActions>
</React.Fragment>
)
}

View file

@ -0,0 +1,537 @@
import { Box, Theme, Stepper, Step, StepLabel, Typography, RadioGroup, Radio, FormControlLabel, 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 { GetGrainExtensionMap } from "grain";
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: "Style"}, {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){
const {devices, deviceComponents, binPrefs, grain, customGrain, cancel, confirm} = props
const grainExtensionMap = GetGrainExtensionMap();
const classes = useStyles()
const [{user}] = useGlobalState()
const interactionAPI = useInteractionsAPI()
const [dryingMethod, setDryingMethod] = useState("air")//whether they are using natural air or a heater
const [options, setOptions] = useState<Option[]>([])
const [deviceOption, setDeviceOption] = useState<Option>()
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>()
const [plenums, setPlenums] = useState<Plenum[]>([])
const [ambients, setAmbients] = useState<Ambient[]>([])
const [heaters, setHeaters] = useState<Controller[]>([])
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 heaters: Controller[] = [];
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_HEATER) {
let heater = Controller.create(comp);
heater.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_OFF;
heaters.push(heater);
sinkMap.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);
setHeaters(heaters);
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 => {
let newOption: Option = {device: device, label: device.name()}
if(deviceOption === undefined) {
setDeviceOption(newOption)
setSelectedDevice(device)
}
o.push(newOption);
});
setOptions(o);
}, [devices, setOptions]);
const buildHeaterInteraction = (
sensor: Plenum | Ambient,
heater: Controller,
): 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(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,
undefined,
user
).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 (user.tempUnit() === 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,
undefined,
user
).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",
) => {
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(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
}
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,
undefined,
user
).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 (user.tempUnit() === 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,
undefined,
user
).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 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[]) => {
console.log("create interactions")
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)
// 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))
}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"))
}
}
})
})
console.log(cables)
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 styleStep = () => {
return (
<Box>
<Typography>Select your drying method</Typography>
<RadioGroup
value={dryingMethod}
onChange={(_, value) => {
setDryingMethod(value)
}}>
<FormControlLabel
control={<Radio />}
value={"air"}
label={"Natural Air"}
/>
<FormControlLabel
control={<Radio />}
value={"heat"}
label={"Supplemental Heat"}
/>
</RadioGroup>
</Box>
)
}
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={heaters}
//presets={presets}
binMode={pond.BinMode.BIN_MODE_DRYING}
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
grain={grain}
customGrain={customGrain}
key={index}
interaction={temp}//using temp because it wants a full interaction, not just its settings
device={selectedDevice}
sink={sink}
source={source}
changeConditions={(newSettings) => {
console.log(newSettings)
}}
/>
)
}
})}
</Box>
)
}
const stepperContent = () => {
switch(currentStep){
case 1:
return deviceStep()
case 2:
return interactionStep()
default:
return styleStep()
}
}
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>
)
}
return (
<React.Fragment>
<DialogTitle>Bin Drying</DialogTitle>
<DialogContent>
{stepper()}
{stepperContent()}
</DialogContent>
<DialogActions>
{actions()}
</DialogActions>
</React.Fragment>
)
}

View file

@ -0,0 +1,393 @@
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 { GetGrainExtensionMap } from "grain";
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 DryingMode(props: Props){
const {devices, deviceComponents, binPrefs, grain, customGrain, cancel, confirm} = props
const grainExtensionMap = GetGrainExtensionMap();
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 = 25;
let humidityDefault = 60;
let conditions = [];
let fanConditionOne = pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_PERCENT,
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_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_HYDRATING}
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 = () => {
console.log(interactionsToAdd)
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>
)
}
return (
<React.Fragment>
<DialogTitle>Bin Hydration</DialogTitle>
<DialogContent>
{stepper()}
{stepperContent()}
</DialogContent>
<DialogActions>
{actions()}
</DialogActions>
</React.Fragment>
)
}

View file

@ -0,0 +1,183 @@
import { DialogContent, DialogTitle, DialogActions, Button } from "@mui/material";
import { Bin, Component, Device, Interaction } from "models";
import { Ambient } from "models/Ambient";
import { Controller } from "models/Controller";
import { GrainCable } from "models/GrainCable";
import { Plenum } from "models/Plenum";
import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack";
import React, { useEffect, useState } from "react";
import { DeviceChangeData } from "./BinModeController";
import { useInteractionsAPI } from "providers";
import { AxiosResponse } from "axios";
import { componentIDToString } from "pbHelpers/Component";
import { ComponentSet } from "bin/conditioning/pickComponentSet";
import moment from "moment";
import { lowerCase } from "lodash";
interface Props {
// the devices connected to the bin
devices: Device[]
deviceComponents: Map<number, Component[]>
bin: Bin
binPrefs?: Map<string, pond.BinComponentPreferences>
confirm: (deviceData: DeviceChangeData[]) => void
cancel: () => void
}
/**
* when a bin goes into storage mode then the auto top node needs to be set, should be handled in the controller actually
* interactions with connected controllers as sink should be deleted, and controllers should be set to off
* @returns
*/
export default function StorageMode(props: Props){
const {devices, deviceComponents, bin, binPrefs, confirm, cancel} = props
const interactionsAPI = useInteractionsAPI()
const [data, setData] = useState<DeviceChangeData[]>([])
const [loading, setLoading] = useState(false)
const [loadErr, setLoadErr] = useState(false)
useEffect(()=>{
let interactionPromises: Promise<Interaction[]>[] = []
let deviceContext: {device: Device, plenums: Plenum[], ambients: Ambient[], cables: GrainCable[], controllers: Controller[]}[] = []
devices.forEach(device => {
if (!deviceComponents.get(device.id())) return;
let plenums: Plenum[] = []
let ambients: Ambient[] = []
let cables: GrainCable[] = [] //since this mode is going to add notification interactions to cables they should be filtered as well
let controllers: Controller[] = [] //because storage is just setting things to false i dont think i need to seperate fans from heaters
//sort the components into the plenum, ambient, grain cable, and controller arrays
deviceComponents.get(device.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_HEATER || pref.type === pond.BinComponent.BIN_COMPONENT_FAN) {
let controller = Controller.create(comp);
controller.settings.defaultOutputState = quack.OutputMode.OUTPUT_MODE_OFF;
controllers.push(controller);
//sinkMap.set(comp.locationString(), comp)
}
if (pref.type === pond.BinComponent.BIN_COMPONENT_GRAIN_CABLE){
let cable = GrainCable.create(comp)
cables.push(cable)
}
}
}
});
//after sorting it will need to load the interactions for the device
deviceContext.push({device, plenums, ambients, cables, controllers})
interactionPromises.push(interactionsAPI.listInteractionsByDevice(device.id()))
})
setLoading(true)
let deviceChangeData: DeviceChangeData[] = []
Promise.all(interactionPromises).then(resp => {
//the index of this should match the device index in the devices array
resp.forEach((interactions,i) => {
let toRemove: Interaction[] = []
let ctx = deviceContext[i]
let sensorsAddr = ctx.plenums.concat(ctx.ambients).map(sensor => sensor.locationString())
let controllerAddr = ctx.controllers.map(cont => cont.locationString())
let cableAddr = ctx.cables.map(cab => cab.locationString())
let cablesWithInteraction = new Set<string>()
interactions.forEach(interaction => {
//first check if the interaction has a sink in the devices fans or heaters and is being controlled by a plenum or ambient
//this should make it so that it only removes conditioning interactions and not all interactions on the device
let source = interaction.settings.source
let sink = interaction.settings.sink
//this checks if the interaction is a 'conditioning' interaction that needs to be removed
if(source && sink){
if(controllerAddr.includes(componentIDToString(sink)) && sensorsAddr.includes(componentIDToString(source))){
toRemove.push(interaction)
}
}
//need to make sure that it is a notification only interaction and not a controlling one
//it is possible to control things with cables and we want to leave those alone and not consider them notification interactions
//the reason we check that there is no sink is because even control interactions can still send notifications
//we want interactions that effectively ONLY send notifications
if (source && !sink && cableAddr.includes(componentIDToString(source))) {
//check if notifications is true
if(interaction.settings.notifications?.notify){
cablesWithInteraction.add(componentIDToString(source))
}
}
})
let cablesNeedingNotification = ctx.cables.filter(
cable => !cablesWithInteraction.has(cable.locationString())
)
let dd: DeviceChangeData = {
deviceId: ctx.device.id(),
toRemove: toRemove,
controllers: ctx.controllers,
}
if(cablesNeedingNotification.length > 0){
let toAdd: pond.MultiInteractionSettings = pond.MultiInteractionSettings.create()
cablesNeedingNotification.forEach(cable => {
//create the new interaction here
let notification: pond.InteractionSettings = pond.InteractionSettings.create({
source: cable.location(),
schedule: pond.InteractionSchedule.create({
timeOfDayStart: "00:00",
timeOfDayEnd: "24:00",
timezone: moment.tz.guess(),
weekdays: moment.weekdays().map(d => lowerCase(d))
}),
conditions: [pond.InteractionCondition.create({
measurementType: quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
comparison: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN,
value: bin.settings.highTemp
})],
result: pond.InteractionResult.create({
type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_REPORT
}),
notifications: pond.InteractionNotifications.create({
notify: true
}),
})
//push it to toAdd.interactions
toAdd.interactions.push(notification)
})
//add toAdd to dd
dd.toAdd = toAdd
}
deviceChangeData.push(dd)
})
setData(deviceChangeData)
}).catch(err => {
//TODO: display a snackBar message saying there was an issue loading the data for one or more devices
setData([])
setLoadErr(true)
}).finally(() => {
setLoading(false)
})
},[devices, binPrefs, deviceComponents])
return (
<React.Fragment>
<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>
</DialogActions>
</React.Fragment>
)
}

View file

@ -0,0 +1,230 @@
import React, { useState } from "react"
import { Autocomplete, Box, Button, CircularProgress, DialogActions, DialogContent, DialogTitle, Step, StepLabel, Stepper, TextField, Theme, Typography } from "@mui/material"
import { makeStyles } from "@mui/styles";
import { pond } from "protobuf-ts/pond";
import ConditioningSelector from "bin/conditioning/conditioningSelector";
import { Component, Device, Interaction } from "models";
import { Plenum } from "models/Plenum";
import { Ambient } from "models/Ambient";
import { Controller } from "models/Controller";
import { GrainCable } from "models/GrainCable";
import { ComponentSet } from "bin/conditioning/pickComponentSet";
import { sameComponentID } from "pbHelpers/Component";
const steps = [{label: "Device"}, {label: "Phase 1"}, {label: "Phase 2"}, {label: "Phase 3"}]
const useStyles = makeStyles((theme: Theme) => {
return ({
stepper: {
padding: theme.spacing(0.5)
},
})
});
interface Option {
label: string;
device: Device;
icon?: string;
}
interface Props {
deviceComponents: Map<number, Component[]>
}
export default function ThreePhaseFanDry(props: Props){
const {deviceComponents} = props
const classes = useStyles()
const [currentStep, setCurrentStep] = useState(0)
const [options, setOptions] = useState<Option[]>([])
const [deviceOption, setDeviceOption] = useState<Option>({device: Device.create(), label: "Select Device"})
const [selectedDevice, setSelectedDevice] = useState<Device | undefined>()
const [interactionsLoading,setInteractionsLoading] = useState(false)
const [plenums, setPlenums] = useState<Plenum[]>([])
const [ambients, setAmbients] = useState<Ambient[]>([])
const [fans, setFans] = useState<Controller[]>([])
const [cables, setCables] = useState<GrainCable[]>([])
const [existingInteractions, setExistingInteractions] = useState<Interaction[]>([])
const [componentSets, setComponentSets] = useState<ComponentSet[]>([])
const [conflictingInteractions, setConflictingInteractions] = useState<Interaction[]>([])
const [interactionsToAdd, setInteractionsToAdd] = useState<pond.MultiInteractionSettings>()
//each of the functions that builds the interactions
const buildPhase1Interaction = () => {}
const buildPhase2Interaction = () => {}
const buildPhase3Interaction = () => {}
const phase1Step = () => {
return (
<React.Fragment></React.Fragment>
)
}
const phase2Step = () => {
return (
<React.Fragment></React.Fragment>
)
}
const phase3Step = () => {
return (
<React.Fragment></React.Fragment>
)
}
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" />}
/>
)
}
//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)
//add the first phase interaction
// multiInteractionSettings.interactions.push()
//add the second phase interaction
// multiInteractionSettings.interactions.push()
//add the third phase interaction
// multiInteractionSettings.interactions.push()
})
})
//removes notification interactions on the cables
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 deviceStep = () => {
return (
<Box>
<Typography>Select the device to condition the bin</Typography>
{deviceSelector()}
{interactionsLoading ? <CircularProgress /> :
<ConditioningSelector
plenums={plenums}
ambients={ambients}
fans={fans}
heaters={[]}
binMode={pond.BinMode.BIN_MODE_THREE_PHASE_FAN_DRYING}
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>
)
}
const actions = () => {
return (
<React.Fragment>
{/* close - tells the parent to close the dialog without doing anything, always visible */}
<Button>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 && <Button onClick={() => {}}>Confirm</Button>}
</React.Fragment>
)
}
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 stepperContent = () => {
switch(currentStep){
case 1:
return phase1Step()
case 2:
return phase2Step()
case 3:
return phase3Step()
default:
return deviceStep()
}
}
return (
<React.Fragment>
<DialogTitle>Bin Cooldown</DialogTitle>
<DialogContent>
{stepper()}
{stepperContent()}
</DialogContent>
<DialogActions>
{actions()}
</DialogActions>
</React.Fragment>
)
}

View file

@ -0,0 +1,507 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
darken,
Grid,
IconButton,
Slider,
Theme,
Tooltip,
Typography
} from "@mui/material";
import { AddCircle, ExpandMore, RemoveCircleOutline } from "@mui/icons-material";
import { ExtractMoisture } from "grain";
import { cloneDeep } from "lodash";
import { Component, Device, Interaction } from "models";
import { interactionConditionText, interactionResultText } from "pbHelpers/Interaction";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { pond, quack } from "protobuf-ts/pond";
import { useGlobalState } from "providers";
import React, { useEffect, useMemo, useState } from "react";
import { avg, fahrenheitToCelsius } from "utils";
import { makeStyles } from "@mui/styles";
const useStyles = makeStyles((theme: Theme) => {
return ({
displayBG: {
marginTop: 10,
background: darken(theme.palette.background.default, 0.05),
borderRadius: 5,
padding: 10
},
markContainer: {
zIndex: 2
},
arrowDown: {
width: 0,
height: 0,
borderLeft: "10px solid transparent",
borderRight: "10px solid transparent"
},
sliderRoot: {},
sliderThumb: {
height: 15,
width: 15,
backgroundColor: "yellow"
},
sliderTrack: {
height: 3,
backgroundColor: "white"
},
sliderRail: {
height: 3,
backgroundColor: "white"
},
sliderValLabel: {
left: -20,
top: 40,
background: "transparent",
"& *": {
color: "#fff"
}
},
sliderMark: {
visibility: "hidden"
},
sliderMarked: {
marginTop: 25,
marginBottom: 0
},
sliderMarkLabel: {
top: -25
},
groupHeader: {
marginTop: 10,
marginBottom: 5,
fontWeight: 650
}
});
});
interface Props {
interaction: Interaction;
device: Device;
source: Component;
sink: Component;
grain?: pond.Grain;
customGrain?: pond.GrainSettings
changeConditions?: (newSettings: pond.InteractionSettings) => void
}
// A "corner" pairing: one temp condition + one humidity condition whose
// comparison directions are consistent with each other, so together they
// bound a single EMC value.
// temp > X && humid < Y -> bounds EMC from ABOVE (an upper limit)
// temp < X && humid > Y -> bounds EMC from BELOW (a lower limit)
interface EMCCornerPair {
temp: pond.InteractionCondition;
humid: pond.InteractionCondition;
}
interface PairedConditions {
upperBoundPair?: EMCCornerPair; // temp > X && humid < Y
lowerBoundPair?: EMCCornerPair; // temp < X && humid > Y
unmatched: pond.InteractionCondition[];
}
const isGTorEQ = (op: quack.RelationalOperator) =>
op === quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN ||
op === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO;
const isLTorEQ = (op: quack.RelationalOperator) =>
op === quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN ||
op === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO;
// Groups the flat condition array into (at most) two EMC-bounding corners,
// based purely on comparison direction, not array position/order.
function pairEMCConditions(conditions: pond.InteractionCondition[]): PairedConditions {
const temps = conditions.filter(
c => c.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE
);
const humids = conditions.filter(
c => c.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT
);
const hiTemp = temps.find(c => isGTorEQ(c.comparison)); // temp > X
const loTemp = temps.find(c => isLTorEQ(c.comparison)); // temp < X
const loHumid = humids.find(c => isLTorEQ(c.comparison)); // humid < Y
const hiHumid = humids.find(c => isGTorEQ(c.comparison)); // humid > Y
const result: PairedConditions = { unmatched: [] };
if (hiTemp && loHumid) result.upperBoundPair = { temp: hiTemp, humid: loHumid };
if (loTemp && hiHumid) result.lowerBoundPair = { temp: loTemp, humid: hiHumid };
const paired = new Set<pond.InteractionCondition>(
[hiTemp, loHumid, loTemp, hiHumid].filter((c): c is pond.InteractionCondition => !!c)
);
result.unmatched = conditions.filter(c => !paired.has(c));
return result;
}
export default function ConditionDisplay(props: Props) {
const { interaction, device, source, sink, grain, customGrain, changeConditions } = props;
const [sliderVals, setSliderVals] = useState<Map<pond.InteractionCondition, number>>(new Map());
const [sliderMarks, setSliderMarks] = useState<Map<quack.MeasurementType, number>>(new Map());
// EMC bound(s) calculated from the temp/humidity condition pairs.
// If only one pair exists, only one of these will be set.
const [upperEMC, setUpperEMC] = useState<number | undefined>();
const [lowerEMC, setLowerEMC] = useState<number | undefined>();
const [{ user }] = useGlobalState();
const classes = useStyles();
// Bumped whenever conditions are added/removed so `pairs` recomputes.
// Needed because pushing into interaction.settings.conditions mutates
// the array in place without changing the `interaction` reference, so
// useMemo keyed only on [interaction] would never see the new conditions.
const [conditionsVersion, setConditionsVersion] = useState(0);
const pairs = useMemo(
() => pairEMCConditions(interaction.conditions()),
[interaction, conditionsVersion]
);
useEffect(() => {
let passedInteraction = interaction;
let vals: Map<pond.InteractionCondition, number> = new Map();
let marks: Map<quack.MeasurementType, number> = new Map();
passedInteraction.conditions().forEach(condition => {
let describer = describeMeasurement(condition.measurementType, source.type(), undefined, undefined, user);
// NOTE: toDisplay will convert the temp value to fahrenheit
// keyed by condition object itself (not measurementType) since there
// can now be more than one condition per measurement type
vals.set(condition, describer.toDisplay(condition.value));
});
source.status.lastGoodMeasurement.forEach(measurement => {
let m = pond.UnitMeasurementsForComponent.fromObject(measurement);
if (m.values[0]) {
let markVal = avg(m.values[0].values);
// do this since this is how interactions handle the values so that the slider can use the toDisplay method of the describer for the marks
if (m.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) markVal = markVal * 10;
if (m.type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) markVal = markVal * 100;
marks.set(m.type, markVal);
}
});
setSliderVals(vals);
setSliderMarks(marks);
}, [interaction, source]);
// Given a corner pair, calculate the EMC that pair implies. Returns
// undefined if grain info or either value isn't available yet.
const emcForPair = (pair?: EMCCornerPair): number | undefined => {
if (!pair || grain === undefined || grain === pond.Grain.GRAIN_INVALID) return undefined;
let temp = sliderVals.get(pair.temp);
let hum = sliderVals.get(pair.humid);
if (temp === undefined || hum === undefined) return undefined;
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
// the emc calc needs the temp to be in celsius
temp = fahrenheitToCelsius(temp);
}
let emc = ExtractMoisture(grain, temp, hum, customGrain);
return emc === hum ? undefined : emc;
};
useEffect(() => {
setUpperEMC(emcForPair(pairs.upperBoundPair));
setLowerEMC(emcForPair(pairs.lowerBoundPair));
}, [sliderVals, grain, user, pairs]);
// Builds a new InteractionCondition with sensible defaults, offset away
// from an existing reference condition's value so the new slider doesn't
// start stacked exactly on top of the one it's paired against.
// ASSUMPTION: InteractionCondition is a plain settable class instance,
// matching how the rest of this file mutates `condition.value` directly.
// If your codebase instead uses a factory (e.g. pond.InteractionCondition.create(...)),
// swap the `new ...()` + assignment below for that factory call.
const buildCondition = (
measurementType: quack.MeasurementType,
comparison: quack.RelationalOperator,
displayValue: number
): pond.InteractionCondition => {
let describer = describeMeasurement(measurementType, source?.type(), source.subType(), undefined, user);
const condition = new pond.InteractionCondition();
condition.measurementType = measurementType;
condition.comparison = comparison;
condition.value = Math.round(describer.toStored(displayValue));
return condition;
};
// Adds the missing opposite corner pair (temp + humidity condition) so
// the EMC display becomes a "between" range instead of a single bound.
// Only relevant when exactly one clean corner pair currently exists.
const addOppositeCornerPair = () => {
const existing = pairs.upperBoundPair ?? pairs.lowerBoundPair;
if (!existing) return; // nothing to mirror off of -- shouldn't happen if button is only shown in this case
const addingLowerBound = !!pairs.upperBoundPair; // if we have the "less than" pair, we're adding the "greater than" one
const tempComparison = addingLowerBound
? quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN
: quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN;
const humidComparison = addingLowerBound
? quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN
: quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN;
const tempDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, source?.type(), source.subType(), undefined, user);
const humidDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, source?.type(), source.subType(), undefined, user);
const existingTempVal = sliderVals.get(existing.temp) ?? tempDescriber.toDisplay(existing.temp.value);
const existingHumidVal = sliderVals.get(existing.humid) ?? humidDescriber.toDisplay(existing.humid.value);
// offset 10% of the slider's range away from the existing threshold,
// in the direction that keeps the two thresholds from overlapping
const tempRange = tempDescriber.max() - tempDescriber.min();
const humidRange = humidDescriber.max() - humidDescriber.min();
const tempOffsetDir = addingLowerBound ? -1 : 1;
const humidOffsetDir = addingLowerBound ? 1 : -1;
const newTempVal = existingTempVal + tempOffsetDir * tempRange * 0.1;
const newHumidVal = existingHumidVal + humidOffsetDir * humidRange * 0.1;
const newTemp = buildCondition(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, tempComparison, newTempVal);
const newHumid = buildCondition(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, humidComparison, newHumidVal);
// ASSUMPTION: interaction.settings.conditions is the backing array for
// interaction.conditions() -- push directly so changeConditions() sends
// the full, updated list. Adjust if conditions are stored/added differently.
interaction.settings.conditions.push(newTemp, newHumid);
const sliders = cloneDeep(sliderVals);
sliders.set(newTemp, newTempVal);
sliders.set(newHumid, newHumidVal);
setSliderVals(sliders);
setConditionsVersion(v => v + 1);
if (changeConditions) {
changeConditions(interaction.settings);
}
};
// Removes a corner pair's two conditions, collapsing back to a single
// bound. Only relevant when both pairs currently exist.
const removeCornerPair = (pair: EMCCornerPair) => {
interaction.settings.conditions = interaction.settings.conditions.filter(
c => c !== pair.temp && c !== pair.humid
);
const sliders = cloneDeep(sliderVals);
sliders.delete(pair.temp);
sliders.delete(pair.humid);
setSliderVals(sliders);
setConditionsVersion(v => v + 1);
if (changeConditions) {
changeConditions(interaction.settings);
}
};
const renderConditionSlider = (
condition: pond.InteractionCondition,
key: React.Key,
onRemove?: () => void,
removeTooltip?: string
) => {
let describer = describeMeasurement(condition.measurementType, source?.type(), source.subType(), undefined, user);
return (
<Grid key={key} container item xs={12} alignItems="center" wrap="nowrap">
<Grid item xs style={{ marginBottom: 20 }}>
<Slider
classes={{
root: classes.sliderRoot,
rail: classes.sliderRail,
track: classes.sliderTrack,
thumb: classes.sliderThumb,
valueLabel: classes.sliderValLabel,
}}
step={0.1}
valueLabelDisplay="on"
valueLabelFormat={value => {
if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) {
if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
return value.toFixed(1) + "°F";
} else {
return value.toFixed(1) + "°C";
}
}
if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) {
return value.toFixed(1) + "%";
}
}}
min={describer.min()}
max={describer.max()}
value={sliderVals.get(condition) ?? describer.min()}
onChange={(_, val) => {
// note that changing it here like this is what is changing it in the interaction itself
condition.value = Math.round(describer.toStored(val as number));
let sliders = cloneDeep(sliderVals);
sliders.set(condition, val as number);
setSliderVals(sliders);
}}
onChangeCommitted={() => {
if (changeConditions) {
changeConditions(interaction.settings);
}
}}
/>
</Grid>
{onRemove && (
<Grid item>
<Tooltip title={removeTooltip ?? "Remove"}>
<IconButton color="error" size="small" onClick={onRemove}>
<RemoveCircleOutline />
</IconButton>
</Tooltip>
</Grid>
)}
</Grid>
);
};
// Text like "Humidity is below 32.5% and above 60.0%" / "Humidity between 32.5% and 60.0%"
// Falls back gracefully if only one of the pair exists yet (e.g. mid-edit).
const groupLabel = (
measurementType: quack.MeasurementType,
loCondition: pond.InteractionCondition | undefined,
hiCondition: pond.InteractionCondition | undefined
) => {
let describer = describeMeasurement(measurementType, source?.type(), source.subType(), undefined, user);
let name = measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE ? "Temperature" : "Humidity";
let unit = describer.unit();
if (loCondition && hiCondition) {
let loVal = sliderVals.get(loCondition);
let hiVal = sliderVals.get(hiCondition);
if (loVal !== undefined && hiVal !== undefined) {
return `${name} is between ${loVal.toFixed(1)}${unit} and ${hiVal.toFixed(1)}${unit}`;
}
}
// fall back to whichever single condition is present
let only = loCondition ?? hiCondition;
if (only) return interactionConditionText(source, only, false, user);
return name;
};
const conditionDisplay = () => {
// If both corner pairs exist, we have two temp conditions and two humid
// conditions -- group them together (temp with temp, humid with humid)
// instead of rendering per-condition like the single-pair case.
const hasTwoPairs = !!pairs.upperBoundPair && !!pairs.lowerBoundPair;
if (hasTwoPairs) {
// upperBoundPair.temp is "temp > X" (the low end of the temp range)
// lowerBoundPair.temp is "temp < X" (the high end of the temp range)
const tempLo = pairs.upperBoundPair!.temp;
const tempHi = pairs.lowerBoundPair!.temp;
// upperBoundPair.humid is "humid < Y" (the high end doesn't apply here -
// it's the low end of the humidity range)
const humidLo = pairs.upperBoundPair!.humid;
const humidHi = pairs.lowerBoundPair!.humid;
return (
<Grid container>
<Grid item xs={12}>
<Typography>{groupLabel(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, tempLo, tempHi)}</Typography>
</Grid>
{renderConditionSlider(tempLo, "temp-lo", () => removeCornerPair(pairs.upperBoundPair!), "Remove less than range (also removes matching humidity threshold)")}
{renderConditionSlider(tempHi, "temp-hi", () => removeCornerPair(pairs.lowerBoundPair!), "Remove greater than range (also removes matching humidity threshold)")}
<Grid item xs={12}>
<Typography>{groupLabel(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, humidLo, humidHi)}</Typography>
</Grid>
{renderConditionSlider(humidLo, "humid-lo", () => removeCornerPair(pairs.upperBoundPair!), "Remove less than range (also removes matching temperature threshold)")}
{renderConditionSlider(humidHi, "humid-hi", () => removeCornerPair(pairs.lowerBoundPair!), "Remove greater than range (also removes matching temperature threshold)")}
{pairs.unmatched.map((c, i) => (
<React.Fragment key={`unmatched-${i}`}>
<Grid item xs={12}>
<Typography>{interactionConditionText(source, c, false, user)}</Typography>
</Grid>
{renderConditionSlider(c, `unmatched-slider-${i}`)}
</React.Fragment>
))}
</Grid>
);
}
// single pair (or no clean pair) -- original flat rendering
const hasExactlyOnePair =
(!!pairs.upperBoundPair && !pairs.lowerBoundPair) ||
(!pairs.upperBoundPair && !!pairs.lowerBoundPair);
return (
<Grid container>
{interaction.conditions().map((condition, i) => (
<React.Fragment key={i}>
<Grid item xs={12}>
<Typography>{interactionConditionText(source, condition, false, user)}</Typography>
</Grid>
{renderConditionSlider(condition, `slider-${i}`)}
</React.Fragment>
))}
{hasExactlyOnePair && (
<Grid item xs={12} container justifyContent="center" style={{ marginTop: 10 }}>
<Tooltip title={`Add ${pairs.upperBoundPair ? "greater than" : "less than"} range`}>
<IconButton disabled={device.maxConditions() < 4} color="primary" onClick={addOppositeCornerPair}>
<AddCircle fontSize="large" />
</IconButton>
</Tooltip>
</Grid>
)}
</Grid>
);
};
const determineEMCLabel = () => {
if (upperEMC !== undefined && lowerEMC !== undefined) return "Between: ";
if (upperEMC !== undefined) return "Less Than: ";
if (lowerEMC !== undefined) return "Greater Than: ";
return "Approximately: ";
};
const emcColour = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour();
const emcValueDisplay = () => {
if (upperEMC !== undefined && lowerEMC !== undefined) {
// present low-to-high regardless of which pair produced which,
// since "greater than" bound is the smaller number and
// "less than" bound is the larger number
const lo = Math.min(upperEMC, lowerEMC);
const hi = Math.max(upperEMC, lowerEMC);
return `${lo.toFixed(1)}% and ${hi.toFixed(1)}%`;
}
const single = upperEMC ?? lowerEMC;
return single !== undefined ? `${single.toFixed(1)}%` : "";
};
const hasAnyEMC = upperEMC !== undefined || lowerEMC !== undefined;
return (
<Grid key={interaction.key()} container className={classes.displayBG}>
<Grid item xs={12}>
<Typography align="center" style={{ margin: 5, marginBottom: 10, fontWeight: 650 }}>
{interactionResultText(interaction, sink)}
</Typography>
{hasAnyEMC ? (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box display="flex">
<Typography style={{ fontWeight: 650 }}>EMC {determineEMCLabel()}</Typography>
<Typography style={{ marginLeft: 5, fontWeight: 650, color: emcColour }}>
{emcValueDisplay()}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>{conditionDisplay()}</AccordionDetails>
</Accordion>
) : (
<React.Fragment>{conditionDisplay()}</React.Fragment>
)}
</Grid>
</Grid>
);
}

View file

@ -1,5 +1,5 @@
import { AccessTime, MoreVert } from "@mui/icons-material";
import { Avatar, Box, Button, Card, Checkbox, FormControlLabel, Grid2, IconButton, Menu, MenuItem, Typography, useTheme } from "@mui/material";
import { Avatar, Box, Button, Card, Checkbox, DialogActions, DialogContent, DialogTitle, FormControlLabel, Grid2, IconButton, Menu, MenuItem, Typography, useTheme } from "@mui/material";
import BinSensorCard from "bin/BinSensorCard";
import { GetDefaultDateRange } from "common/time/DateRange";
import { ExtractMoisture } from "grain";
@ -21,6 +21,7 @@ import React, { useEffect, useState } from "react";
import { avg } from "utils";
import BinSensorGraph from "./graphs/BinSensorGraph";
import TimeBar from "common/time/TimeBar";
import ResponsiveDialog from "common/ResponsiveDialog";
interface Props {
bin: Bin
@ -72,8 +73,13 @@ export default function BinSensorsDisplay(props: Props){
const [startDate, setStartDate] = useState<Moment>(defaultDateRange.start);
const [endDate, setEndDate] = useState<Moment>(defaultDateRange.end);
const [filterNodes, setFilterNodes] = useState(true)
const [controllerConfirmation, setControllerConfirmation] = useState(false)
const isMobile = useMobile()
//state variables fo controller updates using the switch on the card
const [currentController, setCurrentController] = useState<Controller | undefined>()
const [controllersNewMode, setControllersNewMode] = useState(quack.OutputMode.OUTPUT_MODE_OFF)
//organize the components into the correct 'positions' using the bins preferences
useEffect(()=>{
let unassigned: Component[] = []
@ -499,6 +505,46 @@ export default function BinSensorsDisplay(props: Props){
)
}
const controllerDialog = () => {
return (
<ResponsiveDialog open={controllerConfirmation} onClose={()=>{setControllerConfirmation(false)}}>
<DialogTitle>Changing Controller</DialogTitle>
<DialogContent>
<Typography>
This action will change the state of the controller
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={()=>{setControllerConfirmation(false)}}>Cancel</Button>
<Button onClick={()=>{
updateController()
}}>Confirm</Button>
</DialogActions>
</ResponsiveDialog>
)
}
const updateController = () => {
if(!currentController) return
const prefType = preferences?.get(currentController.key())?.type
//this will update the components settings here to change the mode
currentController.settings.defaultOutputState = controllersNewMode
let device = componentDevices.get(currentController.key())
if(device){
componentAPI.update(device, currentController.settings).then(resp => {
currentController.mode = controllersNewMode
if (prefType === pond.BinComponent.BIN_COMPONENT_FAN) {
setFans(prev => prev.map(f => f.key() === currentController.key() ? currentController : f))
} else {
setHeaters(prev => prev.map(h => h.key() === currentController.key() ? currentController : h))
}
}).catch(err => {
console.log("there was a problem updating the controller")
}).finally(() => {
setControllerConfirmation(false)
})}
}
const controllerCard = (controller: Controller) => {
const component = controller.asComponent()
let icon = GetComponentIcon(component.type(), component.subType(), themeType)
@ -592,24 +638,9 @@ export default function BinSensorsDisplay(props: Props){
key={label}
component="button"
onClick={() => {
//this will update the components settings here to change the mode
console.log("changing mode: " + mode)
controller.settings.defaultOutputState = mode
let device = componentDevices.get(controller.key())
if(device){
componentAPI.update(device, controller.settings).then(resp => {
controller.mode = mode // ← update the field the UI reads
if (prefType === pond.BinComponent.BIN_COMPONENT_FAN) {
setFans(prev => prev.map(f => f.key() === controller.key() ? controller : f))
} else {
setHeaters(prev => prev.map(h => h.key() === controller.key() ? controller : h))
}
}).catch(err => {
console.log("there was a problem updating the controller")
})
}
setCurrentController(controller)
setControllersNewMode(mode)
setControllerConfirmation(true)
}}
sx={{
...segStyle(active, label === "Off" ? theme.palette.action.selected : undefined),
@ -718,6 +749,7 @@ export default function BinSensorsDisplay(props: Props){
return (
<Box padding={1}>
{controllerDialog()}
{assignmentMenu()}
{sensorCount > 0 &&
<Box>

View file

@ -439,7 +439,7 @@ if (!selectedDevice) return;
conflictingSetInteractions.forEach(i => {
let newStep: PromiseStep = {
title: "Removing Interaction",
promise: interactionAPI.removeInteraction(selectedDevice.id(), i.key(), as)
promise: () => interactionAPI.removeInteraction(selectedDevice.id(), i.key(), as)
}
stage1.steps.push(newStep);
});
@ -452,7 +452,7 @@ if (!selectedDevice) return;
steps: [
{
title: "Adding Interactions",
promise: interactionAPI.addMultiInteractions(selectedDevice.id(), interactionsToAdd)
promise: () => interactionAPI.addMultiInteractions(selectedDevice.id(), interactionsToAdd)
}
]
}
@ -469,7 +469,7 @@ if (!selectedDevice) return;
set.controllers.forEach(controller => {
let newStep: PromiseStep = {
title: "Updating " + controller.name(),
promise: componentAPI.update(selectedDevice.id(), controller.settings)
promise: () => componentAPI.update(selectedDevice.id(), controller.settings)
}
stage3.steps.push(newStep)
})

View file

@ -138,7 +138,7 @@ export default function PickComponentSet(props: Props) {
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>Controllers Selected: {currentSet.controllers.length}</AccordionSummary>
<AccordionDetails>
{heaters && heaters.length > 0 && binMode !== pond.BinMode.BIN_MODE_HYDRATING &&
{heaters && heaters.length > 0 && binMode === pond.BinMode.BIN_MODE_DRYING &&
<React.Fragment>
<Typography>Heaters</Typography>
{heaters.map(heater => (

View file

@ -11,7 +11,7 @@ export interface Stage {
export interface Step {
title: string
promise: Promise<AxiosResponse>
promise: () => Promise<AxiosResponse>
onComplete?: () => void
onError?: () => void
}
@ -52,7 +52,7 @@ export function PromiseProgress(props: Props){
// mark step as in progress
progMap.set(i+"-"+k, progress.InProgress);
setCompletion(new Map(progMap));
return step.promise
return step.promise()
.then(() => {
// mark step as completed
progMap.set(i+"-"+k, progress.Complete);

View file

@ -150,6 +150,7 @@ export default function InteractionSettings(props: Props) {
const [timezone, setTimezone] = useState<string | undefined>(undefined);
//whether is any node(0), average of all nodes(1), single node(2), or node diff(3)
const [subtypeDropdown, setSubtypeDropdown] = useState<number>(0);
const [interactionEnabled, setInteractionEnabled] = useState(true); //by default interactions are enabled
const initialConditions = useCallback(
(type: quack.ComponentType): pond.InteractionCondition[] => {
@ -168,6 +169,8 @@ export default function InteractionSettings(props: Props) {
let interaction = getDefaultInteraction();
if (initialInteraction && mode === "update") {
interaction = initialInteraction;
//set the switch's state value
setInteractionEnabled(!interaction.settings.inactive);
if (interaction.settings.subtype === 0 || interaction.settings.subtype === 1) {
setSubtypeDropdown(interaction.settings.subtype);
} else {
@ -427,6 +430,13 @@ export default function InteractionSettings(props: Props) {
setInteraction(updatedInteraction);
};
const changeInactive = (event: any) => {
let updatedInteraction = Interaction.clone(interaction);
//the switch uses true for active but the settings use true for inactive, so need to use the opposite of the switch
updatedInteraction.settings.inactive = !event.target.checked;
setInteraction(updatedInteraction);
};
const describeSource = (measurementType: quack.MeasurementType): MeasurementDescriber => {
const source: Component = or(
mappedComponents.get(componentIDToString(interaction.settings.source)),
@ -1532,6 +1542,27 @@ export default function InteractionSettings(props: Props) {
);
};
const activeSwitch = () => {
return (
<FormControlLabel
control={
<Switch
id="active"
checked={interactionEnabled}
onChange={(event) => {
setInteractionEnabled(!interactionEnabled)
changeInactive(event)
}}
value="active"
color="secondary"
/>
}
label="Active"
disabled={!canEdit}
/>
)
}
const content = () => {
return (
<Grid container direction="row" spacing={2}>
@ -1540,10 +1571,14 @@ export default function InteractionSettings(props: Props) {
</Grid>
{hasDeviceFeature(device.settings.upgradeChannel, "better-controls") && (
<Grid size={{ xs: 12 }}>
<div style={{ display: "flex", flexDirection: "row" }}>
<div style={{ display: "flex", flexDirection: "row", gap: 20 }}>
{priorityInput()}
<div style={{ marginLeft: theme.spacing(2) }}></div>
{/* <div style={{ marginLeft: theme.spacing(2) }}></div> */}
{sortingInput()}
{/* this will do the check for the feature once we know what version the feature is supported on */}
{/* {device.featureSupported("disableInteractions") && activeSwitch()} */}
{/* this is for testing on dev */}
{activeSwitch()}
</div>
</Grid>
)}

View file

@ -108,18 +108,25 @@ export class Ambient {
return quack.ComponentID.fromObject({
type: this.settings.type,
addressType: this.settings.addressType,
address: this.settings.address
address: this.settings.address,
expansionLine: this.settings.expansionLine,
muxLine: this.settings.muxLine
});
}
public locationString(): string {
return (
or(this.settings.type, 0).toString() +
let compositeLocation = or(this.settings.type, 0).toString() +
"-" +
or(this.settings.addressType, 0).toString() +
"-" +
or(this.settings.address, 0).toString()
);
if(this.settings.expansionLine){
compositeLocation = compositeLocation + "-" + this.settings.expansionLine
}
if(this.settings.muxLine){
compositeLocation = compositeLocation + ":" + this.settings.muxLine
}
return compositeLocation;
}
public type(): quack.ComponentType {

View file

@ -82,18 +82,25 @@ export class Controller {
return quack.ComponentID.fromObject({
type: this.settings.type,
addressType: this.settings.addressType,
address: this.settings.address
address: this.settings.address,
expansionLine: this.settings.expansionLine,
muxLine: this.settings.muxLine
});
}
public locationString(): string {
return (
or(this.settings.type, 0).toString() +
let compositeLocation = or(this.settings.type, 0).toString() +
"-" +
or(this.settings.addressType, 0).toString() +
"-" +
or(this.settings.address, 0).toString()
);
if(this.settings.expansionLine){
compositeLocation = compositeLocation + "-" + this.settings.expansionLine
}
if(this.settings.muxLine){
compositeLocation = compositeLocation + ":" + this.settings.muxLine
}
return compositeLocation;
}
public type(): quack.ComponentType {

View file

@ -79,6 +79,21 @@ const featureVersions: Map<string, FeatureVersionByPlatform> = new Map([
v2CellBlue: "2.1.10",
v2EthBlue: "2.1.10"
}
],[
"disableInteractions",
{
photon: "N/A",
electron: "N/A",
v2Wifi: "N/A",
v2Cell: "N/A",
v2WifiS3: "N/A",
v2CellS3: "N/A",
v2CellBlack: "N/A",
v2CellGreen: "N/A",
v2WifiBlue: "N/A",
v2CellBlue: "N/A",
v2EthBlue: "N/A"
}
]
]);
export class Device {

View file

@ -230,18 +230,25 @@ export class GrainCable {
return quack.ComponentID.fromObject({
type: this.settings.type,
addressType: this.settings.addressType,
address: this.settings.address
address: this.settings.address,
expansionLine: this.settings.expansionLine,
muxLine: this.settings.muxLine
});
}
public locationString(): string {
return (
or(this.settings.type, 0).toString() +
let compositeLocation = or(this.settings.type, 0).toString() +
"-" +
or(this.settings.addressType, 0).toString() +
"-" +
or(this.settings.address, 0).toString()
);
if(this.settings.expansionLine){
compositeLocation = compositeLocation + "-" + this.settings.expansionLine
}
if(this.settings.muxLine){
compositeLocation = compositeLocation + ":" + this.settings.muxLine
}
return compositeLocation;
}
public type(): quack.ComponentType {

View file

@ -108,18 +108,25 @@ export class Plenum {
return quack.ComponentID.fromObject({
type: this.settings.type,
addressType: this.settings.addressType,
address: this.settings.address
address: this.settings.address,
expansionLine: this.settings.expansionLine,
muxLine: this.settings.muxLine
});
}
public locationString(): string {
return (
or(this.settings.type, 0).toString() +
let compositeLocation = or(this.settings.type, 0).toString() +
"-" +
or(this.settings.addressType, 0).toString() +
"-" +
or(this.settings.address, 0).toString()
);
if(this.settings.expansionLine){
compositeLocation = compositeLocation + "-" + this.settings.expansionLine
}
if(this.settings.muxLine){
compositeLocation = compositeLocation + ":" + this.settings.muxLine
}
return compositeLocation;
}
public type(): quack.ComponentType {

View file

@ -417,7 +417,8 @@ export default function ComponentProvider(props: PropsWithChildren<Props>) {
"&end=" +
end +
(keys ? "&keys=" + keys : "&keys=" + [deviceId.toString()]) +
(types ? "&types=" + types : "&types=" + ["device"])
(types ? "&types=" + types : "&types=" + ["device"]) +
(as ? "&as=" + as : "")
)
return new Promise<AxiosResponse<pond.ListComponentHistoryBetweenResponse>>((resolve,reject) => {
get<pond.ListComponentHistoryBetweenResponse>(url).then(resp => {