import { Accordion, AccordionDetails, AccordionSummary, Alert, AlertTitle, Box, Button, Collapse, FormControl, FormControlLabel, Grid2 as Grid, IconButton, InputAdornment, MenuItem, Switch, TextField, Theme, Tooltip, Typography } from "@mui/material"; import { ExpandMore, Close as CloseIcon } from "@mui/icons-material"; import PeriodSelect from "common/time/PeriodSelect"; import SearchSelect, { Option } from "common/SearchSelect"; import { cloneDeep } from "lodash"; import { Component, Device } from "models"; import { extension, getMeasurements, hasDimensions, hasFan, isController, isMultiSensor, primaryMeasurement } from "pbHelpers/ComponentType"; import { describeMeasurement, MeasurementDescriber } from "pbHelpers/MeasurementDescriber"; import { pond } from "protobuf-ts/pond"; import { quack } from "protobuf-ts/quack"; import React, { useEffect, useState } from "react"; import { bestUnit, milliToX } from "common/time/duration"; import { useGlobalState } from "providers"; import { green, red } from "@mui/material/colors"; import { makeStyles } from "@mui/styles"; import { GrainOptions } from "grain"; import CompModes from "component/ComponentMode.json"; import FanPicker from "fans/fanPicker"; import CustomGrainSelector from "grain/CustomGrainSelector"; const useStyles = makeStyles((theme: Theme) => { return ({ stepper: { paddingRight: 0, paddingLeft: 0 }, switchControl: { marginLeft: theme.spacing(0.25), [theme.breakpoints.up("sm")]: { marginLeft: theme.spacing(1) } }, selectInput: { minHeight: "48px" }, fullWidth: { width: "100%" }, heading: { fontSize: theme.typography.pxToRem(13), flexBasis: "25%", flexShrink: 0 }, secondaryHeading: { fontSize: theme.typography.pxToRem(13), color: theme.palette.text.secondary }, greenButton: { color: green["600"] }, redButton: { color: red["600"] }, noPadding: { padding: 0 }, dataUsageCard: { width: "100%" }, dataUsageHeader: { paddingBottom: 0 }, dataUsageIcon: { color: "var(--status-warning)" }, dataUsageContent: { paddingTop: 0 } }) }); interface Props { device: Device; component: Component; componentChanged: (component: Component, formValid: boolean) => void; canEdit: boolean; } interface ComponentData { component: Component; measure: boolean; report: boolean; partiallyFilled: boolean; coefficient: string; offset: string; //these are for the new calibrations coefficients: string[]; offsets: string[]; length: string; height: string; width: string; sensorDistance: string; } export default function ComponentForm(props: Props) { const classes = useStyles(); const grainOptions = GrainOptions(); const [{ user }] = useGlobalState(); const { device, component, componentChanged, canEdit } = props; const [reportExpanded, setReportExpanded] = useState(false); const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true); const [form, setForm] = useState({ component: cloneDeep(component), measure: false, report: false, partiallyFilled: false, coefficient: "1", offset: "0", coefficients: [""], offsets: [""], length: "0", height: "0", width: "0", sensorDistance: "0", }); const [compMode, setCompMode] = useState(); const [useCustomGrain, setUseCustomGrain] = useState(false) //const [numCalibrations, setNumCalibrations] = useState(0) useEffect(() => { let formComponent = cloneDeep(component); let describer = describeMeasurement( primaryMeasurement(formComponent.settings.type), formComponent.settings.type, formComponent.settings.subtype, undefined, user ); let offset = describer.toDisplay(formComponent.settings.calibrationOffset); let formCoefficients: string[] = []; let formOffsets: string[] = []; //setNumCalibrations(formComponent.settings.calibrations.length) formComponent.settings.calibrations.forEach(calibration => { formCoefficients.push(calibration.calibrationCoefficient.toString()); formOffsets.push(describer.toDisplay(calibration.calibrationOffset).toString()); }); if ( formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE && formComponent.settings.grainType === pond.Grain.GRAIN_INVALID ) { formComponent.settings.grainType = pond.Grain.GRAIN_NONE; } setReportExpanded( formComponent.settings.measurementPeriodMs !== formComponent.settings.reportPeriodMs ); setDataUsageWarningDismissed(localStorage.getItem("dataUsageWarningDismissed") !== null); CompModes.forEach((mode: any) => { if (formComponent.type() === mode.type && formComponent.subType() === mode.subtype) { setCompMode(mode); } }); //get the dimension values for the form and convert them to the form display values (m or ft) let length = formComponent.settings.containerDimensions?.lengthCm ?? 0; let width = formComponent.settings.containerDimensions?.widthCm ?? 0; let height = formComponent.settings.containerDimensions?.heightCm ?? 0; if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { length = length / 100; width = width / 100; height = height / 100; } else if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { length = length / 30.48; width = width / 30.48; height = height / 30.48; } let sensorDistance = formComponent.settings.sensorDistanceCm ?? 0; if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { sensorDistance = sensorDistance / 100; } else if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { sensorDistance = sensorDistance / 30.48; } if(formComponent.settings.customGrain){ setUseCustomGrain(true) } setForm({ component: formComponent, measure: formComponent.settings.measurementPeriodMs > 0, report: formComponent.settings.reportPeriodMs > 0, partiallyFilled: formComponent.settings.grainFilledTo > 0, coefficient: formComponent.settings.calibrationCoefficient.toString(), offset: offset.toString(), coefficients: formCoefficients, offsets: formOffsets, length: length.toFixed(2), width: width.toFixed(2), height: height.toFixed(2), sensorDistance: sensorDistance.toFixed(2), }); }, [component]); const tidyComponent = (component: Component): Component => { const { measure, report } = form; if (!report) { component.settings.reportPeriodMs = 0; } if (!measure) { component.settings.measurementPeriodMs = 0; } return component; }; const isFormValid = () => { let { component } = form; return ( isNameValid(component.settings.name) && isMeasurementPeriodValid() && isReportPeriodValid() && isFilledToValid() && isCoefficientValid() && isOffsetValid() && isMinCycleTimeValid() && isLengthDimensionValid() && isHeightDimensionValid() && isWidthDimensionValid() && isSensorDistanceValid() ); }; useEffect(() => { componentChanged(form.component, isFormValid()); }, [form.component, componentChanged]); // eslint-disable-line react-hooks/exhaustive-deps const isNameValid = (name: string): boolean => { return name.length > 0; }; const isMeasurementPeriodValid = () => { const { measure, component } = form; const ext = extension(component.settings.type, component.settings.subtype); let valid = true; if ( measure && (component.settings.measurementPeriodMs < 1000 || component.settings.measurementPeriodMs < ext.minMeasurementPeriodMs) ) { valid = false; } return valid; }; const isFilledToValid = () => { const { partiallyFilled, component } = form; return (partiallyFilled && component.settings.grainFilledTo > 0) || !partiallyFilled; }; const minMeasurementPeriodDescription = () => { const { component } = form; const ext = extension(component.settings.type, component.settings.subtype); const min = Math.max(1000, ext.minMeasurementPeriodMs); const unit = bestUnit(min); const value = milliToX(min, unit).toString(); return value + " " + unit; }; const isReportPeriodValid = () => { const { report, component } = form; const ext = extension(component.settings.type, component.settings.subtype); let valid = true; if ( report && (component.settings.reportPeriodMs < 10000 || component.settings.reportPeriodMs < ext.minMeasurementPeriodMs) ) { valid = false; } return valid; }; const minReportPeriodDescription = () => { const { component } = form; const ext = extension(component.settings.type, component.settings.subtype); const min = Math.max(10000, ext.minMeasurementPeriodMs); const unit = bestUnit(min); const value = milliToX(min, unit).toString(); return value + " " + unit; }; const isMinCycleTimeValid = () => { const { component } = form; const ext = extension(component.settings.type, component.settings.subtype); if (!ext.isController) return true; let min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0); return component.settings.minCycleTimeMs >= min; }; const minCycleTimeDescription = () => { const { component } = form; const ext = extension(component.settings.type, component.settings.subtype); if (!ext.isController) return ""; const min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0); const unit = bestUnit(min); const value = milliToX(min, unit).toString(); return "Must be at least " + value + " " + unit; }; const handleNameChanged = (event: any) => { let f = cloneDeep(form); f.component.settings.name = event.target.value; setForm(f); }; const handleOutputModeChanged = (event: any) => { let f = cloneDeep(form); f.component.settings.defaultOutputState = event.target.value; setForm(f); }; const handleReportPeriodChanged = (ms: number) => { let f = cloneDeep(form); f.component.settings.reportPeriodMs = ms; setForm(f); }; const handleMeasurementPeriodChanged = (ms: number) => { let f = cloneDeep(form); f.component.settings.measurementPeriodMs = ms; if (!reportExpanded) { f.component.settings.reportPeriodMs = ms; } setForm(f); }; const handleMinCycleTimeChanged = (ms: number) => { let f = cloneDeep(form); f.component.settings.minCycleTimeMs = Number(ms); setForm(f); }; const toggleMeasure = (event: any) => { let f = cloneDeep(form); f.measure = event.target.checked; if (!reportExpanded) { f.report = event.target.checked; } setForm(f); }; const toggleReport = (event: any) => { let f = cloneDeep(form); f.report = event.target.checked; setForm(f); }; const togglePartiallyFilled = (event: any) => { let f = cloneDeep(form); f.partiallyFilled = event.target.checked; if (event.target.checked && f.component.settings.grainFilledTo <= 0) { f.component.settings.grainFilledTo = 1; } if (!event.target.checked) { f.component.settings.grainFilledTo = 0; } setForm(f); }; const toggleReverseNodes = (event: any) => { let f = cloneDeep(form); f.component.settings.reverseNodes = event.target.checked; setForm(f); }; const updateCustomGrain = (grainSettings?: pond.GrainSettings) => { let f = cloneDeep(form) f.component.settings.customGrain = grainSettings f.component.settings.grainType = pond.Grain.GRAIN_CUSTOM setForm(f) } const updateGrainType = (option: Option | null) => { let f = cloneDeep(form); if (!option) { f.component.settings.grainType = pond.Grain.GRAIN_INVALID; } else { let grainType = pond.Grain[option.value as keyof typeof pond.Grain]; f.component.settings.grainType = grainType; if (grainType === pond.Grain.GRAIN_NONE) { f.component.settings.defaultMutations.splice( f.component.settings.defaultMutations.indexOf(pond.Mutator.MUTATOR_EMC, 1) ); } else if (!f.component.settings.defaultMutations.includes(pond.Mutator.MUTATOR_EMC)) { f.component.settings.defaultMutations.push(pond.Mutator.MUTATOR_EMC); } } setForm(f); }; const updateGrainFilledTo = (event: any) => { let f = cloneDeep(form); f.component.settings.grainFilledTo = Number(event.target.value); setForm(f); }; const toggleCalibrate = () => { let f = cloneDeep(form); f.component.settings.calibrate = !f.component.settings.calibrate; let isZero = f.coefficient === "0" || f.coefficient === ""; if (f.component.settings.calibrate && isZero) { f.coefficient = "1"; f.component.settings.calibrationCoefficient = 1; } let offsetZero = f.offset === "0" || f.offset === ""; if (f.component.settings.calibrate && offsetZero) { f.offset = "1"; f.component.settings.calibrationOffset = 1; } setForm(f); }; const isCoefficientValid = () => { let calibrate = form.component.settings.calibrate; let coefficient = Number(form.coefficient); let valid = !isNaN(coefficient) && (!calibrate || (calibrate && coefficient > 0)); return valid; }; const changeCoefficient = (event: any) => { let f = cloneDeep(form); let value = event.target.value; f.coefficient = value; if (!isNaN(Number(value))) { f.component.settings.calibrationCoefficient = +Number(value); } setForm(f); }; const updateCoefficients = (event: any, index: number) => { let f = cloneDeep(form); let value = event.target.value; f.coefficients[index] = value; if (!isNaN(Number(value))) { f.component.settings.calibrations[index].calibrationCoefficient = +Number(value); } setForm(f); }; const isOffsetValid = (min?: number, max?: number) => { let minimum = min ?? 0; let maximum = max ?? 100; if (compMode && form.component.settings.calibrate) { return Number(form.offset) <= maximum && Number(form.offset) >= minimum; } return !isNaN(Number(form.offset)); }; const changeOffset = (event: any) => { let f = cloneDeep(form); f.offset = event.target.value; if (!isNaN(Number(event.target.value))) { f.component.settings.calibrationOffset = describeMeasurement( primaryMeasurement(f.component.settings.type), f.component.settings.type, f.component.settings.subtype, undefined, user ).toStored(+event.target.value); } setForm(f); }; const updateOffsets = (event: any, index: number) => { let f = cloneDeep(form); f.offsets[index] = event.target.value; if (!isNaN(+event.target.value)) { f.component.settings.calibrations[index].calibrationOffset = describeMeasurement( primaryMeasurement(f.component.settings.type), f.component.settings.type, f.component.settings.subtype, undefined, user ).toStored(+event.target.value); } setForm(f); }; const updateCalibrationTypes = (event: any, index: number) => { let f = cloneDeep(form); f.component.settings.calibrations[index].measurementType = event.target.value as quack.MeasurementType; setForm(f); }; const changeAverages = (event: any) => { let f = cloneDeep(form); if (!isNaN(Number(event.target.value))) { f.component.settings.smoothingAverages = event.target.value; } setForm(f); }; const changeLengthDimension = (event: any) => { let f = cloneDeep(form); f.length = event.target.value; if (!isNaN(Number(event.target.value))) { let dimensions = f.component.settings.containerDimensions; let value = Number(event.target.value); if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { value = value * 100; } else if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { value = value * 30.48; } if (dimensions !== undefined && dimensions !== null) { dimensions.lengthCm = value; } else { dimensions = quack.Dimensions.create({ heightCm: 0, lengthCm: value, widthCm: 0 }); } f.component.settings.containerDimensions = dimensions; } setForm(f); }; const isLengthDimensionValid = () => { return !isNaN(Number(form.length)); }; const changeHeightDimension = (event: any) => { let f = cloneDeep(form); f.height = event.target.value; if (!isNaN(Number(event.target.value))) { let dimensions = f.component.settings.containerDimensions; let value = Number(event.target.value); if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { value = value * 100; } else if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { value = value * 30.48; } if (dimensions !== undefined && dimensions !== null) { dimensions.heightCm = value; } else { dimensions = quack.Dimensions.create({ heightCm: value, lengthCm: 0, widthCm: 0 }); } f.component.settings.containerDimensions = dimensions; } setForm(f); }; const isHeightDimensionValid = () => { return !isNaN(Number(form.height)); }; const changeWidthDimension = (event: any) => { let f = cloneDeep(form); f.width = event.target.value; if (!isNaN(Number(event.target.value))) { let dimensions = f.component.settings.containerDimensions; let value = Number(event.target.value); if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { value = value * 100; } else if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { value = value * 30.48; } if (dimensions !== undefined && dimensions !== null) { dimensions.widthCm = value; } else { dimensions = quack.Dimensions.create({ heightCm: 0, lengthCm: 0, widthCm: value }); } f.component.settings.containerDimensions = dimensions; } setForm(f); }; const isWidthDimensionValid = () => { return !isNaN(Number(form.width)); }; const changeSensorDistance = (event: any) => { let f = cloneDeep(form); f.sensorDistance = event.target.value; if (!isNaN(Number(event.target.value))) { let value = Number(event.target.value); if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS) { value = value * 100; } else if (user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_FEET) { value = value * 30.48; } f.component.settings.sensorDistanceCm = value; } setForm(f); }; const isSensorDistanceValid = () => { return !isNaN(Number(form.sensorDistance)); }; const selectText = (event: any) => { event.target.select(); }; const findSelectedGrain = (options: Option[]): Option | undefined => { const { component } = form; return options.find(o => { let target = pond.Grain[o.value as keyof typeof pond.Grain]; return component.settings.grainType === target; }); }; const grainSelect = () => { let selected = findSelectedGrain(grainOptions); return ( { setUseCustomGrain(!useCustomGrain); updateCustomGrain() }} /> } label="Custom Grain" labelPlacement="start" /> {useCustomGrain ? : } ); }; const dismissDataUsageWarning = () => { localStorage.setItem("dataUsageWarningDismissed", "true"); setDataUsageWarningDismissed(true); }; const blanketCalibration = () => { const { component, coefficient, offset } = form; return ( } label="Calibrate" labelPlacement="top" className={classes.switchControl} disabled={!canEdit} /> {describeMeasurement( primaryMeasurement(component.settings.type), component.settings.type, component.settings.subtype, undefined, user ).unit()} ) }}> ); }; const componentMode = () => { const { component, coefficient, offset } = form; return ( } label="Mode" labelPlacement="top" className={classes.switchControl} disabled={!canEdit} /> {compMode.mode.entryA && ( {compMode.mode.entryA === "select" && compMode.mode.dictionaryA.map((elem: any) => { if (!elem.restricted || (elem.restricted && user.hasAdmin())) { return ( {elem.key} ); } return undefined; //TODO-CS: Time permitting re-think the structure of this function so that I don't have to return undefined })} )} {compMode.mode.entryB && ( {compMode.mode.entryB === "select" && compMode.mode.dictionaryB.map((elem: any) => ( {elem.key} ))} )} ) } const describeSource = (measurementType: quack.MeasurementType): MeasurementDescriber => { const { component } = form; return describeMeasurement(measurementType, component.type(), component.subType(), undefined, user); }; const availableMeasurementTypes = (type: quack.ComponentType): quack.MeasurementType[] => { return getMeasurements(type).map(m => m.measurementType); }; const measurementTypeMenuItems = () => { return availableMeasurementTypes(form.component.type()).map(measurementType => ( {describeSource(measurementType).label()} )); }; const individualCalibration = () => { const { component, coefficients, offsets } = form; return ( } label="Calibrate" labelPlacement="top" className={classes.switchControl} disabled={!canEdit} /> {component.settings.calibrations.map((calibration, i) => { return ( {i > 0 && } 0 ? 12 : 8, sm: 3 }} > { updateCoefficients(event, i); }} onFocus={selectText} margin="normal" fullWidth type="text" variant="outlined" /> { updateOffsets(event, i); }} onFocus={selectText} margin="normal" fullWidth type="text" variant="outlined" /> updateCalibrationTypes(event, i)} autoFocus={false} margin="normal" variant="outlined" fullWidth InputLabelProps={{ shrink: true }}> All {measurementTypeMenuItems()} ); })} ); }; const reverseCheck = () => { const {reverseNodes} = form.component.settings let ext = extension(component.type(), component.subType()) if (ext.isArray && device.featureSupported("reverseNodes")) { return ( } label={Reverse Nodes} labelPlacement="top" className={classes.switchControl} disabled={!canEdit} /> ) } return } const generalForm = () => { const { component, measure, report, partiallyFilled, coefficient, offset, length, height, width, sensorDistance } = form; let reportDesc = ""; let reportPeriod = component.settings.reportPeriodMs; let measurementPeriod = component.settings.measurementPeriodMs; if (reportPeriod === measurementPeriod) { reportDesc = "Every measurement"; } else { reportDesc = "Independently"; } if (!form.report) { reportDesc = "Not reporting"; } let showDataUsageWarning = !dataUsageWarningDismissed && form.report && component.settings.reportPeriodMs < 300000 && device.settings.platform === pond.DevicePlatform.DEVICE_PLATFORM_ELECTRON; return ( {/* TODO-CS: Change this to have a parameter in the extensions that gets checked rather then checking for the component type */} {hasFan(component.type()) && ( { component.settings.fanId = fanID; if ( fanID > 0 && !component.settings.defaultMutations.includes(pond.Mutator.MUTATOR_CFM) ) { component.settings.defaultMutations.push(pond.Mutator.MUTATOR_CFM); } else if (fanID === 0 || !fanID) { component.settings.defaultMutations.splice( component.settings.defaultMutations.indexOf(pond.Mutator.MUTATOR_CFM) ); } }} /> )} {component.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE && grainSelect()} {reverseCheck()} {component.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE && ( } label={Partially Filled} labelPlacement="top" className={classes.switchControl} disabled={!canEdit} /> )} { } label={Measure} labelPlacement="top" className={classes.switchControl} disabled={!canEdit} /> } { } { setReportExpanded(expanded)}> }> Report {reportDesc} } label={Enabled} labelPlacement="top" className={classes.switchControl} disabled={!canEdit} /> } {device.featureSupported("individualCalibrations") ? ( {compMode && componentMode()} {individualCalibration()} ) : ( !compMode ? ( blanketCalibration() ):( componentMode() ) )} {hasDimensions(component.settings.type) && ( Dimensions {user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"} ) }} /> {user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"} ) }} /> {user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"} ) }} /> )} {isMultiSensor(component.settings.type) && ( {user.distanceUnit() === pond.DistanceUnit.DISTANCE_UNIT_METERS ? "m" : "ft"} ) }} /> )} {isController(component.settings.type) && ( Auto On Off )} {showDataUsageWarning && ( }> High Data UsageA report period less than 5 minutes will use a large amount of cellular data )} ); }; return {generalForm()}; }