From 90ad5f8b120c8997fc65e00447a263033a801db6 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 21 Apr 2026 15:38:14 -0600 Subject: [PATCH 01/19] new component type --- package-lock.json | 4 +- package.json | 2 +- src/bin/BinVisualizerV2.tsx | 2 +- src/pbHelpers/ComponentType.tsx | 6 +- .../ComponentTypes/AnalogPressure.ts | 88 +++++++++++++++++++ src/pbHelpers/ComponentTypes/index.ts | 1 + src/products/MiVent/MiVentAvailability.ts | 4 +- 7 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 src/pbHelpers/ComponentTypes/AnalogPressure.ts diff --git a/package-lock.json b/package-lock.json index 2cd53e8..0e54b33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,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#dev", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11205,7 +11205,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#9c0f668d4a56b8216dd71a44c3110a818aaf3541", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#b44f5fa7183d9565e9fd9e7a9ac756114401ddfd", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 90ac71b..ba69f2e 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,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#dev", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/bin/BinVisualizerV2.tsx b/src/bin/BinVisualizerV2.tsx index f6f9c1d..437f09d 100644 --- a/src/bin/BinVisualizerV2.tsx +++ b/src/bin/BinVisualizerV2.tsx @@ -1539,7 +1539,7 @@ export default function BinVisualizer(props: Props) { ) : ( - + {fullScreenHandler.enter()}}> )} diff --git a/src/pbHelpers/ComponentType.tsx b/src/pbHelpers/ComponentType.tsx index 047c852..c31154b 100644 --- a/src/pbHelpers/ComponentType.tsx +++ b/src/pbHelpers/ComponentType.tsx @@ -39,7 +39,8 @@ import { Voltage, VPD, Weight, - CapacitorCable + CapacitorCable, + AnalogPressure } from "pbHelpers/ComponentTypes"; //import { multilineGrainCableData } from "pbHelpers/ComponentTypes/GrainCable"; //import { multilineCapCableData } from "./ComponentTypes/CapacitorCable"; @@ -96,7 +97,8 @@ const COMPONENT_TYPE_MAP = new Map([ [quack.ComponentType.COMPONENT_TYPE_SEN5X, Sen5x], [quack.ComponentType.COMPONENT_TYPE_VIBRATION_CHAIN, VibrationCable], [quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, dragerGasDongle], - [quack.ComponentType.COMPONENT_TYPE_AIRFLOW, Airflow] + [quack.ComponentType.COMPONENT_TYPE_AIRFLOW, Airflow], + [quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, AnalogPressure] ]); export interface Subtype { diff --git a/src/pbHelpers/ComponentTypes/AnalogPressure.ts b/src/pbHelpers/ComponentTypes/AnalogPressure.ts new file mode 100644 index 0000000..f4d3936 --- /dev/null +++ b/src/pbHelpers/ComponentTypes/AnalogPressure.ts @@ -0,0 +1,88 @@ +import { + ComponentTypeExtension, + Summary, + Subtype, + simpleMeasurements, + simpleSummaries, + unitMeasurementSummaries, + AreaChartData, + GraphFilters, + simpleAreaChartData, + LineChartData, + simpleLineChartData + } from "pbHelpers/ComponentType"; + import PressureDarkIcon from "assets/components/pressureDark.png"; + import PressureLightIcon from "assets/components/pressureLight.png"; + import { quack } from "protobuf-ts/quack"; + import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; + import { convertedUnitMeasurement } from "models/UnitMeasurement"; + import { pond } from "protobuf-ts/pond"; + + export function AnalogPressure(subtype: number = 0): ComponentTypeExtension { + let pressure = describeMeasurement( + quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE, + quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, + subtype + ); + let voltage = describeMeasurement( + quack.MeasurementType.MEASUREMENT_TYPE_VOLTAGE, + quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, + subtype + ) + let addressTypes = [quack.AddressType.ADDRESS_TYPE_I2C]; + return { + type: quack.ComponentType.COMPONENT_TYPE_PRESSURE, + subtypes: [ + { + key: quack.AnalogPressureSubtype.ANALOG_PRESSURE_SUBTYPE_PMC21, + value: "ANALOG_PRESSURE_SUBTYPE_PMC21", + friendlyName: "PMC 21" + } as Subtype + ], + friendlyName: "Analog Pressure", + description: "Measures the atmospheric pressure or absolute pressure", + isController: false, + isSource: true, + isCalibratable: true, + hasFan: true, + addressTypes: addressTypes, + interactionResultTypes: [], + states: [], + measurements: simpleMeasurements(pressure, voltage), + measurementSummary: async function(measurement: quack.Measurement): Promise> { + return simpleSummaries(measurement, pressure); + }, + unitMeasurementSummary: ( + measurements: convertedUnitMeasurement, + ): Summary[] => { + return unitMeasurementSummaries( + measurements, + quack.ComponentType.COMPONENT_TYPE_PRESSURE, + subtype, + ); + }, + areaChartData: ( + measurement: pond.UnitMeasurementsForComponent, + smoothingAverages?: number, + filters?: GraphFilters + ): AreaChartData => { + return simpleAreaChartData(measurement, smoothingAverages, filters); + }, + lineChartData: ( + measurement: pond.UnitMeasurementsForComponent, + smoothingAverages?: number, + filters?: GraphFilters + ): LineChartData => { + return simpleLineChartData( + quack.ComponentType.COMPONENT_TYPE_PRESSURE, + measurement, + smoothingAverages, + filters + ); + }, + minMeasurementPeriodMs: 1000, + icon: (theme?: "light" | "dark"): string | undefined => { + return theme === "light" ? PressureDarkIcon : PressureLightIcon; + } + }; + } \ No newline at end of file diff --git a/src/pbHelpers/ComponentTypes/index.ts b/src/pbHelpers/ComponentTypes/index.ts index 349bbbe..39b1560 100644 --- a/src/pbHelpers/ComponentTypes/index.ts +++ b/src/pbHelpers/ComponentTypes/index.ts @@ -29,3 +29,4 @@ export * from "./Voltage"; export * from "./VPD"; export * from "./Weight"; export * from "./CapacitorCable"; +export * from "./AnalogPressure"; diff --git a/src/products/MiVent/MiVentAvailability.ts b/src/products/MiVent/MiVentAvailability.ts index 0108806..87d0db2 100644 --- a/src/products/MiVent/MiVentAvailability.ts +++ b/src/products/MiVent/MiVentAvailability.ts @@ -10,6 +10,7 @@ const MiVentV1Pins: ConfigurablePin[] = [ { address: 1024, label: "C2" } ]; +//no longer in development so if you are adding new component types DO NOT add the I2C address here because the firmware of the device will not support it export const MiVentV1Availability: DeviceAvailabilityMap = new Map< quack.AddressType, DevicePositions @@ -53,7 +54,8 @@ export const MiVentV2Availability: DeviceAvailabilityMap = new Map< [quack.ComponentType.COMPONENT_TYPE_DHT, [0x40]], [quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]], [quack.ComponentType.COMPONENT_TYPE_AIRFLOW, [0x6c]], - [quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, [0x6d]] + [quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, [0x6d]], + [quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, [0x6e]] ]) ], From c0a3e23d678b7e8e89d635ee8d528848ebde7bb9 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 21 Apr 2026 16:13:59 -0600 Subject: [PATCH 02/19] missed the device availability stuff --- src/pbHelpers/DeviceAvailability.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pbHelpers/DeviceAvailability.ts b/src/pbHelpers/DeviceAvailability.ts index 972c2e2..da39022 100644 --- a/src/pbHelpers/DeviceAvailability.ts +++ b/src/pbHelpers/DeviceAvailability.ts @@ -64,7 +64,8 @@ const DefaultAvailability: DeviceAvailabilityMap = new Map Date: Fri, 12 Jun 2026 14:43:37 -0600 Subject: [PATCH 03/19] changing some naming for the component --- package-lock.json | 2 +- src/pbHelpers/ComponentTypes/AnalogPressure.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e54b33..1f15d16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11205,7 +11205,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#b44f5fa7183d9565e9fd9e7a9ac756114401ddfd", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#807316f6e3473e9416e2366c6081d5a93245d9d9", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/src/pbHelpers/ComponentTypes/AnalogPressure.ts b/src/pbHelpers/ComponentTypes/AnalogPressure.ts index f4d3936..7921b38 100644 --- a/src/pbHelpers/ComponentTypes/AnalogPressure.ts +++ b/src/pbHelpers/ComponentTypes/AnalogPressure.ts @@ -34,9 +34,9 @@ import { type: quack.ComponentType.COMPONENT_TYPE_PRESSURE, subtypes: [ { - key: quack.AnalogPressureSubtype.ANALOG_PRESSURE_SUBTYPE_PMC21, - value: "ANALOG_PRESSURE_SUBTYPE_PMC21", - friendlyName: "PMC 21" + key: quack.AnalogPressureSubtype.ANALOG_PRESSURE_SUBTYPE_PM1618, + value: "ANALOG_PRESSURE_SUBTYPE_PM1618", + friendlyName: "PM 1618" } as Subtype ], friendlyName: "Analog Pressure", From 1051534bb59c5a847c06db5d60a73aee41519744 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 12 Jun 2026 14:58:30 -0600 Subject: [PATCH 04/19] proto update to stagings proto --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f15d16..37bd044 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,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#dev", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11205,7 +11205,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#807316f6e3473e9416e2366c6081d5a93245d9d9", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#6d889de1a42adf590373ff932823e5e697634c6a", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index ba69f2e..90ac71b 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,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#dev", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", From c4999a0ba5e7cc3de81680c9ba92abefa253b30f Mon Sep 17 00:00:00 2001 From: Carter Date: Tue, 16 Jun 2026 12:32:43 -0600 Subject: [PATCH 05/19] adding control emails to control component settings form --- package-lock.json | 4 +- package.json | 2 +- src/component/ComponentForm.tsx | 84 ++++++++++- src/component/ComponentSettings.tsx | 223 ++++++++++++++++++++++++---- 4 files changed, 275 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58133fb..4d78d05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,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#control_emails", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", @@ -11198,7 +11198,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#29d9765ce93c04ad70d795b80e9873fc2fc5f600", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#4011e9e7a75148a195e7311c41a4e6c8d6886d7b", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index cd2f476..daa71c4 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,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#master", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#control_emails", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/component/ComponentForm.tsx b/src/component/ComponentForm.tsx index 17c1bf7..dbebbb5 100644 --- a/src/component/ComponentForm.tsx +++ b/src/component/ComponentForm.tsx @@ -19,9 +19,10 @@ import { Tooltip, Typography } from "@mui/material"; -import { +import { ExpandMore, - Close as CloseIcon + Close as CloseIcon, + Remove as RemoveIcon } from "@mui/icons-material"; import PeriodSelect from "common/time/PeriodSelect"; import SearchSelect, { Option } from "common/SearchSelect"; @@ -97,6 +98,7 @@ interface Props { component: Component; componentChanged: (component: Component, formValid: boolean) => void; canEdit: boolean; + hideControllerFields?: boolean; } interface ComponentData { @@ -119,7 +121,7 @@ export default function ComponentForm(props: Props) { const classes = useStyles(); const grainOptions = GrainOptions(); const [{ user }] = useGlobalState(); - const { device, component, componentChanged, canEdit } = props; + const { device, component, componentChanged, canEdit, hideControllerFields } = props; const [reportExpanded, setReportExpanded] = useState(false); const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true); const [form, setForm] = useState({ @@ -137,7 +139,8 @@ export default function ComponentForm(props: Props) { sensorDistance: "0", }); const [compMode, setCompMode] = useState(); - const [useCustomGrain, setUseCustomGrain] = useState(false) + const [useCustomGrain, setUseCustomGrain] = useState(false); + const [controlEmail, setControlEmail] = useState(""); //const [numCalibrations, setNumCalibrations] = useState(0) useEffect(() => { @@ -356,6 +359,21 @@ export default function ComponentForm(props: Props) { setForm(f); }; + const addControlEmail = () => { + const trimmed = controlEmail.trim().toLowerCase(); + if (trimmed === "" || form.component.settings.controlEmails.includes(trimmed)) return; + let f = cloneDeep(form); + f.component.settings.controlEmails.push(trimmed); + setForm(f); + setControlEmail(""); + }; + + const removeControlEmail = (index: number) => { + let f = cloneDeep(form); + f.component.settings.controlEmails.splice(index, 1); + setForm(f); + }; + const toggleMeasure = (event: any) => { let f = cloneDeep(form); f.measure = event.target.checked; @@ -1341,7 +1359,7 @@ export default function ComponentForm(props: Props) { }} /> )} - {isController(component.settings.type) && ( + {isController(component.settings.type) && !hideControllerFields && ( + + + Authorized Control Emails + + + + setControlEmail(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + addControlEmail(); + } + }} + disabled={!canEdit} + /> + + + + + + {component.settings.controlEmails.map((email, i) => ( + + + {email} + + + removeControlEmail(i)} + disabled={!canEdit} + className={classes.redButton}> + + + + + ))} + )} {showDataUsageWarning && ( diff --git a/src/component/ComponentSettings.tsx b/src/component/ComponentSettings.tsx index db0e5ad..c6c981b 100644 --- a/src/component/ComponentSettings.tsx +++ b/src/component/ComponentSettings.tsx @@ -8,6 +8,7 @@ import { DialogContentText, DialogTitle, Divider, + FormControl, FormControlLabel, Grid2 as Grid, IconButton, @@ -41,8 +42,10 @@ import { GetComponentTypeOptions, getFriendlyName, getMeasurements, - isController + isController, } from "pbHelpers/ComponentType"; +import PeriodSelect from "common/time/PeriodSelect"; +import { bestUnit, milliToX } from "common/time/duration"; import { ComponentAvailabilityMap, DeviceAvailabilityMap, @@ -177,6 +180,7 @@ export default function ComponentSettings(props: Props) { const [formValid, setFormValid] = useState(false); const [excludedNodes, setExcludedNodes] = useState([]); const [nodeToExclude, setNodeToExclude] = useState(0); + const [controlEmail, setControlEmail] = useState(""); const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { setTabVal(newValue); @@ -498,6 +502,7 @@ export default function ComponentSettings(props: Props) { component={formComponent} device={device} canEdit={canEdit} + hideControllerFields={isController(formComponent.settings.type)} componentChanged={(formComp, isValid) => { formComponent.settings = formComp.settings; setFormValid(isValid); @@ -867,6 +872,145 @@ export default function ComponentSettings(props: Props) { ); }; + const addControlEmail = () => { + const trimmed = controlEmail.trim().toLowerCase(); + if (trimmed === "" || formComponent.settings.controlEmails.includes(trimmed)) return; + let f = cloneDeep(formComponent); + f.settings.controlEmails.push(trimmed); + setFormComponent(f); + setControlEmail(""); + }; + + const removeControlEmail = (index: number) => { + let f = cloneDeep(formComponent); + f.settings.controlEmails.splice(index, 1); + setFormComponent(f); + }; + + const handleOutputModeChanged = (event: any) => { + let f = cloneDeep(formComponent); + f.settings.defaultOutputState = event.target.value; + setFormComponent(f); + }; + + const handleMinCycleTimeChanged = (ms: number) => { + let f = cloneDeep(formComponent); + f.settings.minCycleTimeMs = Number(ms); + setFormComponent(f); + }; + + const isMinCycleTimeValid = () => { + const ext = extension(formComponent.settings.type, formComponent.settings.subtype); + if (!ext.isController) return true; + let min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0); + return formComponent.settings.minCycleTimeMs >= min; + }; + + const minCycleTimeDescription = () => { + const ext = extension(formComponent.settings.type, formComponent.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 controlContent = () => { + return ( + + + Auto + On + Off + + + + + + Authorized Control Emails + + + + setControlEmail(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + addControlEmail(); + } + }} + disabled={!canEdit} + /> + + + + + + {formComponent.settings.controlEmails.map((email, i) => ( + + + {email} + + + removeControlEmail(i)} + disabled={!canEdit} + className={classNames(classes.redButton)}> + + + + + ))} + + ); + }; + const actions = () => { return ( @@ -1042,36 +1186,55 @@ export default function ComponentSettings(props: Props) { scroll="paper"> {title()} - {!isController(formComponent.settings.type) && ( - - - - - + {isController(formComponent.settings.type) ? ( + + + + + + + {content()} + + + {controlContent()} + + + ) : ( + + + + + + + + {content()} + + + + + {overlayGroup()} + + + + + {componentPrefs()} + + )} - - {content()} - - - - - {overlayGroup()} - - - - - {componentPrefs()} - {actions()} )} From b378764d627e7c5e8b53e19fe94bb0b6e1684cac Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 17 Jun 2026 14:11:43 -0600 Subject: [PATCH 06/19] added some info about email control to the control tab for the component settings --- src/component/ComponentSettings.tsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/component/ComponentSettings.tsx b/src/component/ComponentSettings.tsx index c6c981b..e9a7983 100644 --- a/src/component/ComponentSettings.tsx +++ b/src/component/ComponentSettings.tsx @@ -953,7 +953,7 @@ export default function ComponentSettings(props: Props) { } /> - + Authorized Control Emails @@ -1007,6 +1007,28 @@ export default function ComponentSettings(props: Props) { ))} + + + Email Control + + + Authorized emails can control this component by sending an email to the + control address. Contact your administrator for the control email + address. Use one of the following as the email subject: + + +
{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - ON
+
{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - OFF
+
{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - AUTO
+
); }; From d9846ea02b211373b1ba45cd84837bec2537c815 Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 17 Jun 2026 15:41:26 -0600 Subject: [PATCH 07/19] firmware renders with warning styling if it wasn't found in the server --- src/device/VersionChip.tsx | 8 ++++++++ src/pbHelpers/FirmwareVersion.tsx | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/device/VersionChip.tsx b/src/device/VersionChip.tsx index 09dac0f..8826239 100644 --- a/src/device/VersionChip.tsx +++ b/src/device/VersionChip.tsx @@ -22,6 +22,14 @@ class VersionChip extends React.Component { variant="outlined" label={firmwareVersionHelper.description} icon={firmwareVersionHelper.icon} + sx={ + firmwareVersionHelper.colour + ? { + borderColor: firmwareVersionHelper.colour, + color: firmwareVersionHelper.colour + } + : undefined + } /> ); diff --git a/src/pbHelpers/FirmwareVersion.tsx b/src/pbHelpers/FirmwareVersion.tsx index d7dcc8c..43ea776 100644 --- a/src/pbHelpers/FirmwareVersion.tsx +++ b/src/pbHelpers/FirmwareVersion.tsx @@ -9,6 +9,7 @@ export interface FirmwareVersionHelper { const oldFirmwareColour = "var(--status-warning)"; const currentFirmwareColour = "var(--status-ok)"; +const unknownFirmwareColour = "var(--status-warning)"; export function getFirmwareVersionHelper( version: string, @@ -17,24 +18,34 @@ export function getFirmwareVersionHelper( const firmwareIcon = ; const oldFirmwareIcon = ; const currentFirmwareIcon = ; + const unknownFirmwareIcon = ; let helper = {} as FirmwareVersionHelper; if (!version || version === "") { helper.description = "Unknown version"; helper.icon = oldFirmwareIcon; helper.tooltip = "We don't know what version your device is, it may behave unexpectedly"; + helper.colour = oldFirmwareColour; + } else if (version.startsWith("!")) { + helper.description = version; + helper.icon = unknownFirmwareIcon; + helper.tooltip = "This firmware version was reported by the device but is missing from the firmware list"; + helper.colour = unknownFirmwareColour; } else if (available !== "" && version !== available) { helper.description = version; helper.icon = oldFirmwareIcon; helper.tooltip = "A firmware upgrade is available, some features may be disabled for out-of-date devices"; + helper.colour = oldFirmwareColour; } else if (available === "") { helper.description = version; helper.icon = firmwareIcon; helper.tooltip = "Running version " + version; + helper.colour = ""; } else { helper.description = version; helper.icon = currentFirmwareIcon; helper.tooltip = "Your device is running the latest firmware"; + helper.colour = currentFirmwareColour; } return helper; } From 0984ae0f37efed9f48b21e4f60f14dd957b09a9b Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 17 Jun 2026 16:19:35 -0600 Subject: [PATCH 08/19] got rid of warning signifier on firmware print --- src/pbHelpers/FirmwareVersion.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pbHelpers/FirmwareVersion.tsx b/src/pbHelpers/FirmwareVersion.tsx index 43ea776..49472be 100644 --- a/src/pbHelpers/FirmwareVersion.tsx +++ b/src/pbHelpers/FirmwareVersion.tsx @@ -26,9 +26,10 @@ export function getFirmwareVersionHelper( helper.tooltip = "We don't know what version your device is, it may behave unexpectedly"; helper.colour = oldFirmwareColour; } else if (version.startsWith("!")) { - helper.description = version; + const cleanVersion = version.substring(1); + helper.description = cleanVersion; helper.icon = unknownFirmwareIcon; - helper.tooltip = "This firmware version was reported by the device but is missing from the firmware list"; + helper.tooltip = "Firmware " + cleanVersion + " was reported by the device but is missing from the firmware list"; helper.colour = unknownFirmwareColour; } else if (available !== "" && version !== available) { helper.description = version; From 939a53d0724cb325e55a15a8a0fd59be66f5bfba Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 23 Jun 2026 16:25:31 -0600 Subject: [PATCH 09/19] had claude look at the bin player to determine how to take into account the component history for the top nodes --- package-lock.json | 4 +- package.json | 2 +- src/bin/BinPlayer.tsx | 96 ++++++++++++++++++++++++++++- src/providers/pond/componentAPI.tsx | 39 ++++++++++++ 4 files changed, 135 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index a6c8aad..2c4d240 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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#sendgrid_whitelabel", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bin_player_inventory_changes", "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#9c0f668d4a56b8216dd71a44c3110a818aaf3541", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#4bd44bd7e19e7e58344c224b3413a81ece379fed", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 1fb2505..e86d5f5 100644 --- a/package.json +++ b/package.json @@ -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#sendgrid_whitelabel", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#bin_player_inventory_changes", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", diff --git a/src/bin/BinPlayer.tsx b/src/bin/BinPlayer.tsx index 88bc45c..6173596 100644 --- a/src/bin/BinPlayer.tsx +++ b/src/bin/BinPlayer.tsx @@ -90,6 +90,58 @@ function upsertReading( } } +/** + * A trimmed record of a single component-settings history entry: just the timestamp and the + * top-node value (`grain_filled_to`) that was in effect from that point forward. + */ +type TopNodeHistoryEntry = { + timestamp: Moment; + topNode: number; +}; + +/** + * Converts raw `ComponentHistory` entries for one cable into a sorted (ascending) array of + * `TopNodeHistoryEntry` records, keeping only entries where `grain_filled_to` is defined and + * non-zero so that we never overwrite a known top node with a "not set" value. + */ +function buildTopNodeHistory( + history: pond.ComponentHistory[] +): TopNodeHistoryEntry[] { + return history + .filter(h => h.component && h.component.grainFilledTo > 0 && h.timestamp) + .map(h => ({ + timestamp: moment(h.timestamp), + topNode: h.component ? h.component.grainFilledTo : 0, + })) + .sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf()); +} + +/** + * Binary-searches a sorted `TopNodeHistoryEntry[]` for the most recent entry whose timestamp + * is at or before `checkpointTime`. Returns `undefined` when no such entry exists (i.e. the + * checkpoint is before the first recorded top-node change). + */ +function topNodeAtTime( + history: TopNodeHistoryEntry[], + checkpointTime: Moment +): number | undefined { + if (history.length === 0) return undefined; + const t = checkpointTime.valueOf(); + let lo = 0; + let hi = history.length - 1; + let result: number | undefined = undefined; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + if (history[mid].timestamp.valueOf() <= t) { + result = history[mid].topNode; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return result; +} + /** * Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive). * These are the playback frames — not derived from measurement data. @@ -360,6 +412,8 @@ function buildStatusCheckpoints( cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[], fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[], heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[], + /** Sorted top-node history per cable key. Pass an empty Map when not needed. */ + topNodeHistories: Map, start: Moment, end: Moment, count: number = CHECKPOINT_COUNT @@ -416,7 +470,19 @@ function buildStatusCheckpoints( const bucketReadings = cableBucket.get(key) ?? {}; const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings); carriedByCable.set(key, merged); - return buildGrainCableFromReadings(template, merged, time); + const cable = buildGrainCableFromReadings(template, merged, time); + + // Apply the most recent top-node setting that was in effect at this checkpoint time. + // Falls back to the template's current top_node when no history entry precedes this time. + const history = topNodeHistories.get(key); + if (history) { + const tn = topNodeAtTime(history, time); + if (tn !== undefined) { + cable.topNode = tn; + } + } + + return cable; }); // Build fans @@ -529,6 +595,18 @@ export default function BinPlayer(props: Props) { return componentAPI.sampleUnitMeasurements(deviceID, cable.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); }); + // Cable top-node history requests — one per cable, covering the full playback range. + const cableHistoryRequests = bin.status.grainCables.map(cable => { + const deviceID = componentDevices.get(cable.key) ?? 0; + console.log(deviceID) + return componentAPI.listHistoryBetween( + deviceID, + cable.key, + startDate.toISOString(), + endDate.toISOString() + ); + }); + // Fan requests const fanSampleRequests = bin.status.fans.map(fan => { const deviceID = componentDevices.get(fan.key) ?? 0; @@ -541,12 +619,23 @@ export default function BinPlayer(props: Props) { return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); }); - const [cableResponses, fanResponses, heaterResponses] = await Promise.all([ + const [cableResponses, fanResponses, heaterResponses, cableHistoryResponses] = await Promise.all([ Promise.all(cableSampleRequests), Promise.all(fanSampleRequests), Promise.all(heaterSampleRequests), + Promise.all(cableHistoryRequests), ]); + console.log(cableHistoryResponses) + + // Build a per-cable sorted top-node history map for use during checkpoint construction. + const topNodeHistories = new Map(); + bin.status.grainCables.forEach((cable, i) => { + const historyResp = cableHistoryResponses[i]; + const entries = buildTopNodeHistory(historyResp?.data?.history ?? []); + topNodeHistories.set(cable.key, entries); + }); + const checkpoints = buildStatusCheckpoints( bin.status.grainCables, bin.status.fans, @@ -554,6 +643,7 @@ export default function BinPlayer(props: Props) { cableResponses.map(r => r.data), fanResponses.map(r => r.data), heaterResponses.map(r => r.data), + topNodeHistories, startDate, endDate, checkpointCountFromRange(startDate, endDate) @@ -715,4 +805,4 @@ export default function BinPlayer(props: Props) { ); -} +} \ No newline at end of file diff --git a/src/providers/pond/componentAPI.tsx b/src/providers/pond/componentAPI.tsx index 3e2207e..a224952 100644 --- a/src/providers/pond/componentAPI.tsx +++ b/src/providers/pond/componentAPI.tsx @@ -62,6 +62,14 @@ export interface IComponentAPIContext { keys?: string[], types?: string[] ) => Promise>; + listHistoryBetween: ( + deviceId: string | number, + componentKey: string, + start: string, + end: string, + keys?: string[], + types?: string[] + ) => Promise>; // Old measuremnt structure functions, no longer used in codebase // listMeasurements: ( // device: number, @@ -391,6 +399,36 @@ export default function ComponentProvider(props: PropsWithChildren) { }) }; + const listHistoryBetween = ( + deviceId: string | number, + componentKey: string, + start: string, + end: string, + keys?: string[], + types?: string[] + ) => { + let url = pondURL( + "/devices/" + + deviceId + + "/components/" + + componentKey + + "/historyBetween?start=" + + start + + "&end=" + + end + + (keys ? "&keys=" + keys : "&keys=" + [deviceId.toString()]) + + (types ? "&types=" + types : "&types=" + ["device"]) + ) + return new Promise>((resolve,reject) => { + get(url).then(resp => { + resp.data = pond.ListComponentHistoryBetweenResponse.fromObject(resp.data) + return resolve(resp) + }).catch(err => { + return reject(err) + }) + }) + }; + // const listMeasurements = ( // device: number, // component: string, @@ -596,6 +634,7 @@ export default function ComponentProvider(props: PropsWithChildren) { listForObject: listComponentsForObject, listComponentCardData: listComponentCardData, listHistory, + listHistoryBetween, //listMeasurements: listMeasurements, //sampleMeasurements: sampleMeasurements, sampleUnitMeasurements, From 270df1a11876b581c5fb9476ebba6891b06f2eec Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 23 Jun 2026 16:40:09 -0600 Subject: [PATCH 10/19] fixed unit from V to mV for analog pressure --- package-lock.json | 2 +- src/pbHelpers/MeasurementDescriber.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 37bd044..4d9535e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11205,7 +11205,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#6d889de1a42adf590373ff932823e5e697634c6a", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#42f72fa7fb838b7771e6c847faaa192e93f42da7", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/src/pbHelpers/MeasurementDescriber.ts b/src/pbHelpers/MeasurementDescriber.ts index 947c545..ccc50a7 100644 --- a/src/pbHelpers/MeasurementDescriber.ts +++ b/src/pbHelpers/MeasurementDescriber.ts @@ -174,10 +174,9 @@ export class MeasurementDescriber { this.details.path = "power.inputVoltageTimes10"; this.details.decimals = 1; } - if (componentType === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE) { + if (componentType === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE || componentType === quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE) { this.details.unit = "mV"; this.details.graph = GraphType.MULTILINE; - this.details.unit = "mV"; this.details.decimals = 0 // this.details.nodeDetails = { // colours: ["white", "black", "red", "blue"], From 728d07a07e625300d98b15a0d045bdeb940c1778 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 23 Jun 2026 16:42:30 -0600 Subject: [PATCH 11/19] proto update --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 55cca88..7df4b5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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#42f72fa7fb838b7771e6c847faaa192e93f42da7", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#8c5091affb9c1ef4e80c675072435b745b07e612", "dependencies": { "protobufjs": "^6.8.8" } From f73eb0fcbd80d6c37bf92aabea6a867072ad76fb Mon Sep 17 00:00:00 2001 From: csawatzky Date: Tue, 23 Jun 2026 16:44:42 -0600 Subject: [PATCH 12/19] proto update --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7df4b5b..afc0c3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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#sendgrid_whitelabel", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "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#8c5091affb9c1ef4e80c675072435b745b07e612", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#42f72fa7fb838b7771e6c847faaa192e93f42da7", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/package.json b/package.json index 1fb2505..3a76c43 100644 --- a/package.json +++ b/package.json @@ -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#sendgrid_whitelabel", + "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging", "query-string": "^9.2.1", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", From 4ea0d53c94482786bf6671efc369c1378bbab180 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 24 Jun 2026 12:38:29 -0600 Subject: [PATCH 13/19] adding analog pressure to have the cable id show up for analog cables --- src/component/ComponentSettings.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/component/ComponentSettings.tsx b/src/component/ComponentSettings.tsx index db0e5ad..a133de3 100644 --- a/src/component/ComponentSettings.tsx +++ b/src/component/ComponentSettings.tsx @@ -507,11 +507,12 @@ export default function ComponentSettings(props: Props) { ); }; - const isCableComponent = () => { + const hasCableID = () => { return (formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE || formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE || formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_PRESSURE_CABLE || + formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE || (formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_CAPACITOR_CABLE && formComponent.subType() === quack.CapacitorCableSubtype.CAPACITOR_CABLE_SUBTYPE_FROG)) @@ -577,7 +578,8 @@ export default function ComponentSettings(props: Props) { )} {supportsExpansion() && setExpLine()} - {(isCableComponent() && !supportsExpansion())&& setComponentAddrType()} + + {(hasCableID() && !supportsExpansion())&& setComponentAddrType()} ) : activeStep === 1 ? ( Date: Wed, 24 Jun 2026 13:13:14 -0600 Subject: [PATCH 14/19] opening interaction websocket to detect accepted pending changes --- src/interactions/InteractionsOverview.tsx | 45 ++++++++++++++++++++--- src/pages/Device.tsx | 43 +++++++++++++++++++++- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/interactions/InteractionsOverview.tsx b/src/interactions/InteractionsOverview.tsx index 600c88a..db73675 100644 --- a/src/interactions/InteractionsOverview.tsx +++ b/src/interactions/InteractionsOverview.tsx @@ -4,6 +4,7 @@ import { CardActions, CardContent, CardHeader, + CircularProgress, darken, Grid2 as Grid, IconButton, @@ -14,7 +15,7 @@ import { Tooltip, Typography } from "@mui/material"; -import { Settings } from "@mui/icons-material"; +import { CheckCircleOutline, Settings } from "@mui/icons-material"; import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks"; import { cloneDeep } from "lodash"; import { Component, Device, Interaction } from "models"; @@ -80,6 +81,7 @@ export default function InteractionsOverview(props: Props) { const [interactions, setInteractions] = useState(props.interactions); const [dirtyInteractions, setDirtyInteractions] = useState>(new Map()); const [mappedComponents, setMappedComponents] = useState>(new Map()); + const [acceptedInteractions, setAcceptedInteractions] = useState>(new Set()); const [renderToggle, setRenderToggle] = useState(false); const [selectedInteraction, setSelectedInteraction] = useState( undefined @@ -95,6 +97,20 @@ export default function InteractionsOverview(props: Props) { } if (props.interactions !== prevInteractions) { + if (prevInteractions) { + setAcceptedInteractions((prevAccepted) => { + const updatedAccepted = new Set(prevAccepted); + props.interactions.forEach((interaction: Interaction) => { + const previous = prevInteractions.find(prevInteraction => prevInteraction.key() === interaction.key()); + if (!interaction.status.synced) { + updatedAccepted.delete(interaction.key()); + } else if (previous && !previous.status.synced) { + updatedAccepted.add(interaction.key()); + } + }); + return updatedAccepted; + }); + } setInteractions(cloneDeep(props.interactions)); } }, [components, prevComponents, props.interactions, prevInteractions]); @@ -226,10 +242,16 @@ export default function InteractionsOverview(props: Props) { let key = interaction.key(); let source = mappedComponents.get(componentIDToString(interaction.settings.source)); let sink = mappedComponents.get(componentIDToString(interaction.settings.sink)); + const isPending = !interaction.status.synced && Boolean(interaction.status.lastUpdate); + const isAccepted = interaction.status.synced && acceptedInteractions.has(interaction.key()); let statusText = - !interaction.status.synced && interaction.status.lastUpdate - ? "Pending " + - moment(interaction.status.lastUpdate && interaction.status.lastUpdate).fromNow() + !interaction.status.synced + ? interaction.status.lastUpdate + ? "Pending " + + moment(interaction.status.lastUpdate && interaction.status.lastUpdate).fromNow() + : "" + : acceptedInteractions.has(interaction.key()) + ? "Pending change accepted" : ""; let schedule = pond.InteractionSchedule.create( interaction.settings.schedule !== null ? interaction.settings.schedule : undefined @@ -272,7 +294,20 @@ export default function InteractionsOverview(props: Props) { className={classes.header} titleTypographyProps={{ variant: "subtitle2" }} title={interactionResultText(interaction, sink)} - subheader={statusText} + subheader={ + statusText ? ( + + {isPending && } + {isAccepted && ( + + )} + + {statusText} + + + ) : ""} subheaderTypographyProps={{ variant: "caption" }} action={action} /> diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 6c9535d..96b06b6 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -282,6 +282,45 @@ export default function DevicePage() { enabled: !!deviceID, }); + // --- Real-time interaction updates --- + // Streams interaction changes for this device, including status changes + // when the device accepts pending interaction updates. + useWebSocket<{ key: string; interaction: Interaction } | null>({ + path: `/v1/live/devices/${deviceID}/interactions`, + parse: (e) => { + try { + const raw = JSON.parse(e.data); + const interaction = Interaction.any(raw); + if (!interaction?.settings) { + console.warn("[ws] received interaction without settings:", raw); + return null; + } + return { key: interaction.key(), interaction }; + } catch (err) { + console.warn("[ws] failed to parse interaction:", err); + return null; + } + }, + onMessage: (data) => { + if (!data) return; + const { key, interaction } = data; + setInteractions((prev) => { + const index = prev.findIndex((existing) => existing.key() === key); + if (index < 0) { + return [...prev, interaction]; + } + const updated = [...prev]; + updated[index] = interaction; + return updated; + }); + }, + keys: liveContextKeys, + types: liveContextTypes, + as: liveAs, + token, + enabled: !!deviceID, + }); + const loadPortScan = () => { deviceAPI.listFoundComponents(parseInt(deviceID)) .then(resp => { @@ -395,7 +434,7 @@ export default function DevicePage() { interactions={filteredInteractions} permissions={permissions} deviceComponentPreferences={prefsMap.get(c.key())} - key={i} + key={c.key()} refreshCallback={(updatedComponent?: Component) => updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice() } @@ -412,7 +451,7 @@ export default function DevicePage() { interactions={filteredInteractions} permissions={permissions} deviceComponentPreferences={prefsMap.get(c.key())} - key={i} + key={c.key()} refreshCallback={(updatedComponent?: Component) => updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice() } From 879a5ae1f08bc9b4109c50dabdfe189a22e9daab Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 24 Jun 2026 14:16:54 -0600 Subject: [PATCH 15/19] fixed pending state logic --- src/interactions/InteractionsOverview.tsx | 60 ++++++++++++++--------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/src/interactions/InteractionsOverview.tsx b/src/interactions/InteractionsOverview.tsx index db73675..2619e97 100644 --- a/src/interactions/InteractionsOverview.tsx +++ b/src/interactions/InteractionsOverview.tsx @@ -70,6 +70,11 @@ interface Props { refreshCallback: () => void; } +interface PendingInteraction { + since: string; + accepted: boolean; +} + export default function InteractionsOverview(props: Props) { const classes = useStyles(); const [{as, user}] = useGlobalState(); @@ -81,7 +86,7 @@ export default function InteractionsOverview(props: Props) { const [interactions, setInteractions] = useState(props.interactions); const [dirtyInteractions, setDirtyInteractions] = useState>(new Map()); const [mappedComponents, setMappedComponents] = useState>(new Map()); - const [acceptedInteractions, setAcceptedInteractions] = useState>(new Set()); + const [pendingInteractions, setPendingInteractions] = useState>(new Map()); const [renderToggle, setRenderToggle] = useState(false); const [selectedInteraction, setSelectedInteraction] = useState( undefined @@ -97,20 +102,23 @@ export default function InteractionsOverview(props: Props) { } if (props.interactions !== prevInteractions) { - if (prevInteractions) { - setAcceptedInteractions((prevAccepted) => { - const updatedAccepted = new Set(prevAccepted); - props.interactions.forEach((interaction: Interaction) => { - const previous = prevInteractions.find(prevInteraction => prevInteraction.key() === interaction.key()); - if (!interaction.status.synced) { - updatedAccepted.delete(interaction.key()); - } else if (previous && !previous.status.synced) { - updatedAccepted.add(interaction.key()); + setPendingInteractions((prevPending) => { + const updatedPending = new Map(prevPending); + props.interactions.forEach((interaction: Interaction) => { + const existing = updatedPending.get(interaction.key()); + if (!interaction.status.synced) { + if (!existing || existing.accepted) { + updatedPending.set(interaction.key(), { + since: interaction.status.lastUpdate || moment().toISOString(), + accepted: false + }); } - }); - return updatedAccepted; + } else if (existing && !existing.accepted) { + updatedPending.set(interaction.key(), { ...existing, accepted: true }); + } }); - } + return updatedPending; + }); setInteractions(cloneDeep(props.interactions)); } }, [components, prevComponents, props.interactions, prevInteractions]); @@ -217,6 +225,14 @@ export default function InteractionsOverview(props: Props) { index: number, settings: pond.IInteractionSettings ) => { + const interactionKey = settings.key ?? ""; + setPendingInteractions((prevPending) => { + const updatedPending = new Map(prevPending); + if (interactionKey) { + updatedPending.set(interactionKey, { since: moment().toISOString(), accepted: false }); + } + return updatedPending; + }); interactionsAPI .updateInteraction(Number(deviceID), settings, as) .then((_response: any) => { @@ -224,7 +240,6 @@ export default function InteractionsOverview(props: Props) { updatedDirtyInteractions.set(index, false); setDirtyInteractions(updatedDirtyInteractions); success("Successfully updated the interaction for " + component.name()); - refreshCallback(); }) .catch((_err: any) => { error("Error occurred while updating the interaction for " + component.name()); @@ -242,16 +257,13 @@ export default function InteractionsOverview(props: Props) { let key = interaction.key(); let source = mappedComponents.get(componentIDToString(interaction.settings.source)); let sink = mappedComponents.get(componentIDToString(interaction.settings.sink)); - const isPending = !interaction.status.synced && Boolean(interaction.status.lastUpdate); - const isAccepted = interaction.status.synced && acceptedInteractions.has(interaction.key()); - let statusText = - !interaction.status.synced - ? interaction.status.lastUpdate - ? "Pending " + - moment(interaction.status.lastUpdate && interaction.status.lastUpdate).fromNow() - : "" - : acceptedInteractions.has(interaction.key()) - ? "Pending change accepted" + const pending = pendingInteractions.get(interaction.key()); + const isAccepted = Boolean(pending?.accepted); + const isPending = Boolean(pending && !pending.accepted); + let statusText = isAccepted + ? "Pending change accepted" + : isPending + ? "Pending " + moment(pending.since).fromNow() : ""; let schedule = pond.InteractionSchedule.create( interaction.settings.schedule !== null ? interaction.settings.schedule : undefined From b9a13c578115c23fe2558a7b04b15e472bf98329 Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 24 Jun 2026 14:40:04 -0600 Subject: [PATCH 16/19] fixed pending change race condition --- src/interactions/InteractionsOverview.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/interactions/InteractionsOverview.tsx b/src/interactions/InteractionsOverview.tsx index 2619e97..b579660 100644 --- a/src/interactions/InteractionsOverview.tsx +++ b/src/interactions/InteractionsOverview.tsx @@ -72,6 +72,7 @@ interface Props { interface PendingInteraction { since: string; + seenUnsynced: boolean; accepted: boolean; } @@ -110,10 +111,13 @@ export default function InteractionsOverview(props: Props) { if (!existing || existing.accepted) { updatedPending.set(interaction.key(), { since: interaction.status.lastUpdate || moment().toISOString(), + seenUnsynced: true, accepted: false }); + } else if (!existing.seenUnsynced) { + updatedPending.set(interaction.key(), { ...existing, seenUnsynced: true }); } - } else if (existing && !existing.accepted) { + } else if (existing && existing.seenUnsynced && !existing.accepted) { updatedPending.set(interaction.key(), { ...existing, accepted: true }); } }); @@ -229,7 +233,7 @@ export default function InteractionsOverview(props: Props) { setPendingInteractions((prevPending) => { const updatedPending = new Map(prevPending); if (interactionKey) { - updatedPending.set(interactionKey, { since: moment().toISOString(), accepted: false }); + updatedPending.set(interactionKey, { since: moment().toISOString(), seenUnsynced: false, accepted: false }); } return updatedPending; }); From e8cc5099150384a2838f517af98b72d93adffdbd Mon Sep 17 00:00:00 2001 From: Carter Date: Thu, 25 Jun 2026 12:15:49 -0600 Subject: [PATCH 17/19] less restrictive on receiving websockets --- src/interactions/InteractionsOverview.tsx | 85 ++++++++++++++--------- src/pages/Device.tsx | 19 +++++ 2 files changed, 70 insertions(+), 34 deletions(-) diff --git a/src/interactions/InteractionsOverview.tsx b/src/interactions/InteractionsOverview.tsx index b579660..450c44f 100644 --- a/src/interactions/InteractionsOverview.tsx +++ b/src/interactions/InteractionsOverview.tsx @@ -70,12 +70,6 @@ interface Props { refreshCallback: () => void; } -interface PendingInteraction { - since: string; - seenUnsynced: boolean; - accepted: boolean; -} - export default function InteractionsOverview(props: Props) { const classes = useStyles(); const [{as, user}] = useGlobalState(); @@ -87,12 +81,21 @@ export default function InteractionsOverview(props: Props) { const [interactions, setInteractions] = useState(props.interactions); const [dirtyInteractions, setDirtyInteractions] = useState>(new Map()); const [mappedComponents, setMappedComponents] = useState>(new Map()); - const [pendingInteractions, setPendingInteractions] = useState>(new Map()); + const [acceptedInteractions, setAcceptedInteractions] = useState>(new Set()); const [renderToggle, setRenderToggle] = useState(false); const [selectedInteraction, setSelectedInteraction] = useState( undefined ); + useEffect(() => { + console.log("[interactions:mount] initial interactions:", props.interactions.map(i => ({ + key: i.key(), + synced: i.status.synced, + lastUpdate: i.status.lastUpdate, + lastSynced: i.status.lastSynced, + }))); + }, []); + useEffect(() => { if (components !== prevComponents) { let initMappedComponents: Map = new Map(); @@ -103,25 +106,30 @@ export default function InteractionsOverview(props: Props) { } if (props.interactions !== prevInteractions) { - setPendingInteractions((prevPending) => { - const updatedPending = new Map(prevPending); + console.log("[interactions:effect] props.interactions changed, prevInteractions was", prevInteractions ? "defined" : "undefined"); + props.interactions.forEach((interaction: Interaction) => { + const key = interaction.key(); + const prevInteraction = prevInteractions?.find(p => p.key() === key); + console.log("[interactions:effect]", key, { + synced: interaction.status.synced, + lastUpdate: interaction.status.lastUpdate, + prevSynced: prevInteraction?.status.synced, + prevLastUpdate: prevInteraction?.status.lastUpdate, + }); + }); + setAcceptedInteractions(prev => { + const updated = new Set(prev); props.interactions.forEach((interaction: Interaction) => { - const existing = updatedPending.get(interaction.key()); + const key = interaction.key(); + const prevInteraction = prevInteractions?.find(p => p.key() === key); if (!interaction.status.synced) { - if (!existing || existing.accepted) { - updatedPending.set(interaction.key(), { - since: interaction.status.lastUpdate || moment().toISOString(), - seenUnsynced: true, - accepted: false - }); - } else if (!existing.seenUnsynced) { - updatedPending.set(interaction.key(), { ...existing, seenUnsynced: true }); - } - } else if (existing && existing.seenUnsynced && !existing.accepted) { - updatedPending.set(interaction.key(), { ...existing, accepted: true }); + updated.delete(key); + } else if (prevInteraction && !prevInteraction.status.synced) { + console.log("[interactions:effect] ACCEPTED", key); + updated.add(key); } }); - return updatedPending; + return updated; }); setInteractions(cloneDeep(props.interactions)); } @@ -229,20 +237,30 @@ export default function InteractionsOverview(props: Props) { index: number, settings: pond.IInteractionSettings ) => { - const interactionKey = settings.key ?? ""; - setPendingInteractions((prevPending) => { - const updatedPending = new Map(prevPending); - if (interactionKey) { - updatedPending.set(interactionKey, { since: moment().toISOString(), seenUnsynced: false, accepted: false }); - } - return updatedPending; - }); interactionsAPI .updateInteraction(Number(deviceID), settings, as) .then((_response: any) => { + console.log("[interactions:submit] API success, setting synced=false for index", index); let updatedDirtyInteractions = cloneDeep(dirtyInteractions); updatedDirtyInteractions.set(index, false); setDirtyInteractions(updatedDirtyInteractions); + setInteractions(prev => { + const updated = cloneDeep(prev); + if (updated[index]) { + updated[index].status.synced = false; + updated[index].status.lastUpdate = moment().toISOString(); + console.log("[interactions:submit] local state updated for", updated[index].key()); + } + return updated; + }); + const interactionKey = settings.key ?? ""; + if (interactionKey) { + setAcceptedInteractions(prev => { + const updated = new Set(prev); + updated.delete(interactionKey); + return updated; + }); + } success("Successfully updated the interaction for " + component.name()); }) .catch((_err: any) => { @@ -261,13 +279,12 @@ export default function InteractionsOverview(props: Props) { let key = interaction.key(); let source = mappedComponents.get(componentIDToString(interaction.settings.source)); let sink = mappedComponents.get(componentIDToString(interaction.settings.sink)); - const pending = pendingInteractions.get(interaction.key()); - const isAccepted = Boolean(pending?.accepted); - const isPending = Boolean(pending && !pending.accepted); + const isAccepted = acceptedInteractions.has(interaction.key()); + const isPending = !interaction.status.synced; let statusText = isAccepted ? "Pending change accepted" : isPending - ? "Pending " + moment(pending.since).fromNow() + ? "Pending " + moment(interaction.status.lastUpdate).fromNow() : ""; let schedule = pond.InteractionSchedule.create( interaction.settings.schedule !== null ? interaction.settings.schedule : undefined diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 96b06b6..733d621 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -304,11 +304,30 @@ export default function DevicePage() { onMessage: (data) => { if (!data) return; const { key, interaction } = data; + console.log("[ws:interaction] received", key, { + synced: interaction.status.synced, + lastUpdate: interaction.status.lastUpdate, + lastSynced: interaction.status.lastSynced, + }); setInteractions((prev) => { const index = prev.findIndex((existing) => existing.key() === key); if (index < 0) { return [...prev, interaction]; } + const existing = prev[index]; + const existingLastUpdate = getTimestampMillis(existing.status.lastUpdate); + const incomingLastUpdate = getTimestampMillis(interaction.status.lastUpdate); + const incomingLastSynced = getTimestampMillis(interaction.status.lastSynced); + if (existingLastUpdate !== undefined && incomingLastUpdate !== undefined && + existingLastUpdate > incomingLastUpdate) { + return prev; + } + if (!existing.status.synced && interaction.status.synced && + incomingLastUpdate !== undefined && + existingLastUpdate === incomingLastUpdate && + (incomingLastSynced === undefined || incomingLastSynced < incomingLastUpdate)) { + return prev; + } const updated = [...prev]; updated[index] = interaction; return updated; From ffd5e0a76902bc8d4813a31740377289fc0f5587 Mon Sep 17 00:00:00 2001 From: Carter Date: Thu, 25 Jun 2026 12:33:14 -0600 Subject: [PATCH 18/19] preventing form updates when settings dialog is open --- src/component/ComponentSettings.tsx | 3 ++- src/device/DeviceSettings.tsx | 6 ++++-- src/interactions/InteractionSettings.tsx | 9 +++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/component/ComponentSettings.tsx b/src/component/ComponentSettings.tsx index db0e5ad..c703393 100644 --- a/src/component/ComponentSettings.tsx +++ b/src/component/ComponentSettings.tsx @@ -213,7 +213,8 @@ export default function ComponentSettings(props: Props) { }, [props.component, componentTypeOptions]); useEffect(() => { - if (props.component !== prevComponent || (!isDialogOpen && prevIsDialogOpen)) { + if (isDialogOpen && prevIsDialogOpen) return; + if (props.component !== prevComponent || isDialogOpen !== prevIsDialogOpen) { init(); } }, [props.component, prevComponent, init, isDialogOpen, prevIsDialogOpen]); diff --git a/src/device/DeviceSettings.tsx b/src/device/DeviceSettings.tsx index a9277f5..26ca591 100644 --- a/src/device/DeviceSettings.tsx +++ b/src/device/DeviceSettings.tsx @@ -95,6 +95,7 @@ export default function DeviceSettings(props: Props) { const deviceAPI = useDeviceAPI(); const { device, isDialogOpen, closeDialogCallback, canEdit, refreshCallback, components } = props; const prevDevice = usePrevious(device); + const prevIsDialogOpen = usePrevious(isDialogOpen); const [deviceForm, setDeviceForm] = useState(deviceFromForm(Device.clone(device))); const [isRemoveDeviceDialogOpen, setIsRemoveDeviceDialogOpen] = useState(false); const [sleeps, setSleeps] = useState(device.settings.sleepDurationS > 0); @@ -110,7 +111,8 @@ export default function DeviceSettings(props: Props) { const [existingMutation, setExistingMutation] = useState(); useEffect(() => { - if (prevDevice !== device) { + if (isDialogOpen && prevIsDialogOpen) return; + if (prevDevice !== device || isDialogOpen !== prevIsDialogOpen) { setDeviceForm(deviceFromForm(Device.clone(props.device))); if (device.settings.extensionComponents[0]) { setCompExtOne(device.settings.extensionComponents[0]); @@ -130,7 +132,7 @@ export default function DeviceSettings(props: Props) { setComponentsByDevice(cbd); } } - }, [device, prevDevice, props.device, components]); + }, [device, prevDevice, props.device, components, isDialogOpen, prevIsDialogOpen]); const close = () => { closeDialogCallback(); diff --git a/src/interactions/InteractionSettings.tsx b/src/interactions/InteractionSettings.tsx index 4f2a2d3..c98742f 100644 --- a/src/interactions/InteractionSettings.tsx +++ b/src/interactions/InteractionSettings.tsx @@ -133,6 +133,7 @@ export default function InteractionSettings(props: Props) { const prevInitialInteraction = usePrevious(initialInteraction); const prevComponents = usePrevious(components); const prevInitialComponent = usePrevious(initialComponent); + const prevIsDialogOpen = usePrevious(isDialogOpen); const classes = useStyles(); const [{ user, as }] = useGlobalState(); const interactionsAPI = useInteractionsAPI(); @@ -232,10 +233,12 @@ export default function InteractionSettings(props: Props) { if (user && user.settings.timezone) { setTimezone(user.settings.timezone); } + if (isDialogOpen && prevIsDialogOpen) return; if ( prevInitialInteraction !== initialInteraction || (prevComponents && prevComponents.length !== components.length) || - getComponentIDString(prevInitialComponent) !== getComponentIDString(initialComponent) + getComponentIDString(prevInitialComponent) !== getComponentIDString(initialComponent) || + isDialogOpen !== prevIsDialogOpen ) { setDefaultState(); } @@ -247,7 +250,9 @@ export default function InteractionSettings(props: Props) { prevInitialComponent, prevInitialInteraction, setDefaultState, - user + user, + isDialogOpen, + prevIsDialogOpen ]); const getAvailableSinks = () => { From 7666fbf9b65137187c4add4a3d9010d827b880ae Mon Sep 17 00:00:00 2001 From: csawatzky Date: Fri, 26 Jun 2026 15:25:18 -0600 Subject: [PATCH 19/19] update to the bin player to set the bin bushels in the status using the measurements from the bin measurements --- src/bin/BinPlayer.tsx | 94 +++++++++++++++++++++++++++++++++------- src/bin/binTableView.tsx | 2 +- 2 files changed, 79 insertions(+), 17 deletions(-) diff --git a/src/bin/BinPlayer.tsx b/src/bin/BinPlayer.tsx index 6173596..364d5af 100644 --- a/src/bin/BinPlayer.tsx +++ b/src/bin/BinPlayer.tsx @@ -8,7 +8,7 @@ import { Bin } from "models"; import moment, { Moment } from "moment"; import { pond } from "protobuf-ts/pond"; import { quack } from "protobuf-ts/quack"; -import { useComponentAPI } from "providers"; +import { useBinAPI, useComponentAPI } from "providers"; import React, { useEffect, useRef, useState } from "react"; const CHECKPOINT_COUNT = 10; @@ -16,6 +16,7 @@ const CHECKPOINT_COUNT = 10; interface StatusCheckpoint { time: Moment; timeString: string; + statusBushels: number; cables: pond.GrainCable[]; fans: pond.BinFan[]; heaters: pond.BinHeater[]; @@ -99,11 +100,6 @@ type TopNodeHistoryEntry = { topNode: number; }; -/** - * Converts raw `ComponentHistory` entries for one cable into a sorted (ascending) array of - * `TopNodeHistoryEntry` records, keeping only entries where `grain_filled_to` is defined and - * non-zero so that we never overwrite a known top node with a "not set" value. - */ function buildTopNodeHistory( history: pond.ComponentHistory[] ): TopNodeHistoryEntry[] { @@ -116,11 +112,6 @@ function buildTopNodeHistory( .sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf()); } -/** - * Binary-searches a sorted `TopNodeHistoryEntry[]` for the most recent entry whose timestamp - * is at or before `checkpointTime`. Returns `undefined` when no such entry exists (i.e. the - * checkpoint is before the first recorded top-node change). - */ function topNodeAtTime( history: TopNodeHistoryEntry[], checkpointTime: Moment @@ -142,6 +133,61 @@ function topNodeAtTime( return result; } +/** + * A single recorded bushel estimate with its timestamp, sourced from bin object measurements. + */ +type BushelEntry = { + timestamp: Moment; + bushels: number; +}; + +function buildBushelHistory( + response: pond.ListObjectMeasurementsResponse +): BushelEntry[] { + const entries: BushelEntry[] = []; + + response.measurements.forEach(um => { + const isBushelType = + um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIDAR || + um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_CABLE || + um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIBRACART; + + if (!isBushelType) return; + + um.timestamps.forEach((ts, i) => { + const valueArray = um.values[i]; + if (!ts || !valueArray || valueArray.error || valueArray.values.length === 0) return; + entries.push({ + timestamp: moment(ts), + bushels: valueArray.values[0], + }); + }); + }); + + return entries.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf()); +} + +function bushelsAtTime( + history: BushelEntry[], + checkpointTime: Moment +): number | undefined { + if (history.length === 0) return undefined; + const t = checkpointTime.valueOf(); + let lo = 0; + let hi = history.length - 1; + let result: number | undefined = undefined; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + if (history[mid].timestamp.valueOf() <= t) { + result = history[mid].bushels; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return result; +} + /** * Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive). * These are the playback frames — not derived from measurement data. @@ -414,6 +460,10 @@ function buildStatusCheckpoints( heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[], /** Sorted top-node history per cable key. Pass an empty Map when not needed. */ topNodeHistories: Map, + /** Sorted bushel history derived from bin object measurements. */ + bushelHistory: BushelEntry[], + /** Fallback bushel count used when no measurement precedes a checkpoint. */ + fallbackBushels: number, start: Moment, end: Moment, count: number = CHECKPOINT_COUNT @@ -503,7 +553,12 @@ function buildStatusCheckpoints( return buildBinHeaterFromState(template, merged); }); - return { time, cables, fans, heaters, timeString: time.toISOString() }; + // Resolve bushels: binary search gives the most recent recorded value at or before this + // checkpoint (carry-forward is implicit in the search). Falls back to the current live + // status bushels when the playback range predates all recorded measurements. + const statusBushels = bushelsAtTime(bushelHistory, time) ?? fallbackBushels; + + return { time, cables, fans, heaters, timeString: time.toISOString(), statusBushels }; }).filter(checkpoint => // Keep checkpoints that have at least some cable data checkpoint.cables.some(cable => @@ -537,6 +592,7 @@ export default function BinPlayer(props: Props) { const { bin, componentDevices, setBin } = props; const isMobile = useMobile(); const componentAPI = useComponentAPI(); + const binAPI = useBinAPI(); const [dateSelect, setDateSelect] = useState(0); const [startDate, setStartDate] = useState(moment().subtract(1, 'week')); const [endDate, setEndDate] = useState(moment); @@ -598,7 +654,6 @@ export default function BinPlayer(props: Props) { // Cable top-node history requests — one per cable, covering the full playback range. const cableHistoryRequests = bin.status.grainCables.map(cable => { const deviceID = componentDevices.get(cable.key) ?? 0; - console.log(deviceID) return componentAPI.listHistoryBetween( deviceID, cable.key, @@ -619,15 +674,16 @@ export default function BinPlayer(props: Props) { return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); }); - const [cableResponses, fanResponses, heaterResponses, cableHistoryResponses] = await Promise.all([ + const binMeasurementsRequest = binAPI.listBinMeasurements(bin.key(), startDate, endDate, 0, 0, 'asc'); + + const [cableResponses, fanResponses, heaterResponses, cableHistoryResponses, binMeasurementsResponse] = await Promise.all([ Promise.all(cableSampleRequests), Promise.all(fanSampleRequests), Promise.all(heaterSampleRequests), Promise.all(cableHistoryRequests), + binMeasurementsRequest, ]); - console.log(cableHistoryResponses) - // Build a per-cable sorted top-node history map for use during checkpoint construction. const topNodeHistories = new Map(); bin.status.grainCables.forEach((cable, i) => { @@ -636,6 +692,9 @@ export default function BinPlayer(props: Props) { topNodeHistories.set(cable.key, entries); }); + // Build sorted bushel history from bin object measurements. + const bushelHistory = buildBushelHistory(binMeasurementsResponse.data); + const checkpoints = buildStatusCheckpoints( bin.status.grainCables, bin.status.fans, @@ -644,6 +703,8 @@ export default function BinPlayer(props: Props) { fanResponses.map(r => r.data), heaterResponses.map(r => r.data), topNodeHistories, + bushelHistory, + bin.status.grainBushels, startDate, endDate, checkpointCountFromRange(startDate, endDate) @@ -664,6 +725,7 @@ export default function BinPlayer(props: Props) { newBin.status.grainCables = checkpoints[i].cables; newBin.status.fans = checkpoints[i].fans; newBin.status.heaters = checkpoints[i].heaters; + newBin.status.grainBushels = checkpoints[i].statusBushels; setBin(newBin); setCurrentCheckpointTime(checkpoints[i].time); diff --git a/src/bin/binTableView.tsx b/src/bin/binTableView.tsx index 0659335..e11132e 100644 --- a/src/bin/binTableView.tsx +++ b/src/bin/binTableView.tsx @@ -312,7 +312,7 @@ export default function BinTableView(props: Props) {