Merge branch 'staging_environment' into bin_modes_v2

This commit is contained in:
csawatzky 2026-06-29 15:29:43 -06:00
commit 179e9abbd9
19 changed files with 765 additions and 66 deletions

4
package-lock.json generated
View file

@ -46,7 +46,7 @@
"mui-tel-input": "^7.0.0", "mui-tel-input": "^7.0.0",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"openweathermap-ts": "^1.2.10", "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", "query-string": "^9.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
@ -11752,7 +11752,7 @@
}, },
"node_modules/protobuf-ts": { "node_modules/protobuf-ts": {
"version": "1.0.0", "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#100126cc7f3cdf18f68af6372e5db4113db012b7",
"dependencies": { "dependencies": {
"protobufjs": "^6.8.8" "protobufjs": "^6.8.8"
} }

View file

@ -59,7 +59,7 @@
"mui-tel-input": "^7.0.0", "mui-tel-input": "^7.0.0",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"openweathermap-ts": "^1.2.10", "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", "query-string": "^9.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",

View file

@ -8,7 +8,7 @@ import { Bin } from "models";
import moment, { Moment } from "moment"; import moment, { Moment } from "moment";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack"; import { quack } from "protobuf-ts/quack";
import { useComponentAPI } from "providers"; import { useBinAPI, useComponentAPI } from "providers";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
const CHECKPOINT_COUNT = 10; const CHECKPOINT_COUNT = 10;
@ -16,6 +16,7 @@ const CHECKPOINT_COUNT = 10;
interface StatusCheckpoint { interface StatusCheckpoint {
time: Moment; time: Moment;
timeString: string; timeString: string;
statusBushels: number;
cables: pond.GrainCable[]; cables: pond.GrainCable[];
fans: pond.BinFan[]; fans: pond.BinFan[];
heaters: pond.BinHeater[]; heaters: pond.BinHeater[];
@ -90,6 +91,103 @@ 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;
};
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());
}
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;
}
/**
* 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). * Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive).
* These are the playback frames not derived from measurement data. * These are the playback frames not derived from measurement data.
@ -360,6 +458,12 @@ function buildStatusCheckpoints(
cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[], cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[], fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[], heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
/** Sorted top-node history per cable key. Pass an empty Map when not needed. */
topNodeHistories: Map<string, TopNodeHistoryEntry[]>,
/** Sorted bushel history derived from bin object measurements. */
bushelHistory: BushelEntry[],
/** Fallback bushel count used when no measurement precedes a checkpoint. */
fallbackBushels: number,
start: Moment, start: Moment,
end: Moment, end: Moment,
count: number = CHECKPOINT_COUNT count: number = CHECKPOINT_COUNT
@ -416,7 +520,19 @@ function buildStatusCheckpoints(
const bucketReadings = cableBucket.get(key) ?? {}; const bucketReadings = cableBucket.get(key) ?? {};
const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings); const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings);
carriedByCable.set(key, merged); 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 // Build fans
@ -437,7 +553,12 @@ function buildStatusCheckpoints(
return buildBinHeaterFromState(template, merged); 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 => }).filter(checkpoint =>
// Keep checkpoints that have at least some cable data // Keep checkpoints that have at least some cable data
checkpoint.cables.some(cable => checkpoint.cables.some(cable =>
@ -471,6 +592,7 @@ export default function BinPlayer(props: Props) {
const { bin, componentDevices, setBin } = props; const { bin, componentDevices, setBin } = props;
const isMobile = useMobile(); const isMobile = useMobile();
const componentAPI = useComponentAPI(); const componentAPI = useComponentAPI();
const binAPI = useBinAPI();
const [dateSelect, setDateSelect] = useState<number>(0); const [dateSelect, setDateSelect] = useState<number>(0);
const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week')); const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week'));
const [endDate, setEndDate] = useState<Moment>(moment); const [endDate, setEndDate] = useState<Moment>(moment);
@ -529,6 +651,17 @@ export default function BinPlayer(props: Props) {
return componentAPI.sampleUnitMeasurements(deviceID, cable.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); 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;
return componentAPI.listHistoryBetween(
deviceID,
cable.key,
startDate.toISOString(),
endDate.toISOString()
);
});
// Fan requests // Fan requests
const fanSampleRequests = bin.status.fans.map(fan => { const fanSampleRequests = bin.status.fans.map(fan => {
const deviceID = componentDevices.get(fan.key) ?? 0; const deviceID = componentDevices.get(fan.key) ?? 0;
@ -541,12 +674,27 @@ export default function BinPlayer(props: Props) {
return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
}); });
const [cableResponses, fanResponses, heaterResponses] = 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(cableSampleRequests),
Promise.all(fanSampleRequests), Promise.all(fanSampleRequests),
Promise.all(heaterSampleRequests), Promise.all(heaterSampleRequests),
Promise.all(cableHistoryRequests),
binMeasurementsRequest,
]); ]);
// Build a per-cable sorted top-node history map for use during checkpoint construction.
const topNodeHistories = new Map<string, TopNodeHistoryEntry[]>();
bin.status.grainCables.forEach((cable, i) => {
const historyResp = cableHistoryResponses[i];
const entries = buildTopNodeHistory(historyResp?.data?.history ?? []);
topNodeHistories.set(cable.key, entries);
});
// Build sorted bushel history from bin object measurements.
const bushelHistory = buildBushelHistory(binMeasurementsResponse.data);
const checkpoints = buildStatusCheckpoints( const checkpoints = buildStatusCheckpoints(
bin.status.grainCables, bin.status.grainCables,
bin.status.fans, bin.status.fans,
@ -554,6 +702,9 @@ export default function BinPlayer(props: Props) {
cableResponses.map(r => r.data), cableResponses.map(r => r.data),
fanResponses.map(r => r.data), fanResponses.map(r => r.data),
heaterResponses.map(r => r.data), heaterResponses.map(r => r.data),
topNodeHistories,
bushelHistory,
bin.status.grainBushels,
startDate, startDate,
endDate, endDate,
checkpointCountFromRange(startDate, endDate) checkpointCountFromRange(startDate, endDate)
@ -574,6 +725,7 @@ export default function BinPlayer(props: Props) {
newBin.status.grainCables = checkpoints[i].cables; newBin.status.grainCables = checkpoints[i].cables;
newBin.status.fans = checkpoints[i].fans; newBin.status.fans = checkpoints[i].fans;
newBin.status.heaters = checkpoints[i].heaters; newBin.status.heaters = checkpoints[i].heaters;
newBin.status.grainBushels = checkpoints[i].statusBushels;
setBin(newBin); setBin(newBin);
setCurrentCheckpointTime(checkpoints[i].time); setCurrentCheckpointTime(checkpoints[i].time);
@ -715,4 +867,4 @@ export default function BinPlayer(props: Props) {
</Box> </Box>
</React.Fragment> </React.Fragment>
); );
} }

View file

@ -312,7 +312,7 @@ export default function BinTableView(props: Props) {
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
<Button <Button
variant="contained" variant="contained"
sx={{ background: theme.palette.background.default}} sx={{ background: theme.palette.background.default, color: theme.palette.text.primary}}
onClick={() => { onClick={() => {
setOpenExpandedCables(true) setOpenExpandedCables(true)
}}> }}>

View file

@ -19,9 +19,10 @@ import {
Tooltip, Tooltip,
Typography Typography
} from "@mui/material"; } from "@mui/material";
import { import {
ExpandMore, ExpandMore,
Close as CloseIcon Close as CloseIcon,
Remove as RemoveIcon
} from "@mui/icons-material"; } from "@mui/icons-material";
import PeriodSelect from "common/time/PeriodSelect"; import PeriodSelect from "common/time/PeriodSelect";
import SearchSelect, { Option } from "common/SearchSelect"; import SearchSelect, { Option } from "common/SearchSelect";
@ -97,6 +98,7 @@ interface Props {
component: Component; component: Component;
componentChanged: (component: Component, formValid: boolean) => void; componentChanged: (component: Component, formValid: boolean) => void;
canEdit: boolean; canEdit: boolean;
hideControllerFields?: boolean;
} }
interface ComponentData { interface ComponentData {
@ -119,7 +121,7 @@ export default function ComponentForm(props: Props) {
const classes = useStyles(); const classes = useStyles();
const grainOptions = GrainOptions(); const grainOptions = GrainOptions();
const [{ user }] = useGlobalState(); const [{ user }] = useGlobalState();
const { device, component, componentChanged, canEdit } = props; const { device, component, componentChanged, canEdit, hideControllerFields } = props;
const [reportExpanded, setReportExpanded] = useState(false); const [reportExpanded, setReportExpanded] = useState(false);
const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true); const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true);
const [form, setForm] = useState<ComponentData>({ const [form, setForm] = useState<ComponentData>({
@ -137,7 +139,8 @@ export default function ComponentForm(props: Props) {
sensorDistance: "0", sensorDistance: "0",
}); });
const [compMode, setCompMode] = useState<any>(); const [compMode, setCompMode] = useState<any>();
const [useCustomGrain, setUseCustomGrain] = useState<boolean>(false) const [useCustomGrain, setUseCustomGrain] = useState<boolean>(false);
const [controlEmail, setControlEmail] = useState("");
//const [numCalibrations, setNumCalibrations] = useState(0) //const [numCalibrations, setNumCalibrations] = useState(0)
useEffect(() => { useEffect(() => {
@ -356,6 +359,21 @@ export default function ComponentForm(props: Props) {
setForm(f); 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) => { const toggleMeasure = (event: any) => {
let f = cloneDeep(form); let f = cloneDeep(form);
f.measure = event.target.checked; f.measure = event.target.checked;
@ -1341,7 +1359,7 @@ export default function ComponentForm(props: Props) {
}} }}
/> />
)} )}
{isController(component.settings.type) && ( {isController(component.settings.type) && !hideControllerFields && (
<React.Fragment> <React.Fragment>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<TextField <TextField
@ -1382,6 +1400,62 @@ export default function ComponentForm(props: Props) {
/> />
</FormControl> </FormControl>
</Grid> </Grid>
<Grid size={{ xs: 12 }}>
<Typography variant="subtitle2" style={{ marginTop: 16, marginBottom: 8 }}>
Authorized Control Emails
</Typography>
<Grid container spacing={1} alignItems="center">
<Grid size={{ xs: 9 }}>
<TextField
label="Email address"
type="email"
fullWidth
variant="outlined"
size="small"
value={controlEmail}
onChange={e => setControlEmail(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
addControlEmail();
}
}}
disabled={!canEdit}
/>
</Grid>
<Grid size={{ xs: 3 }}>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={addControlEmail}
disabled={!canEdit || controlEmail.trim() === ""}>
Add
</Button>
</Grid>
</Grid>
{component.settings.controlEmails.map((email, i) => (
<Grid
container
key={i}
alignItems="center"
justifyContent="space-between"
style={{ marginTop: 4 }}>
<Grid>
<Typography variant="body2">{email}</Typography>
</Grid>
<Grid>
<IconButton
size="small"
onClick={() => removeControlEmail(i)}
disabled={!canEdit}
className={classes.redButton}>
<RemoveIcon />
</IconButton>
</Grid>
</Grid>
))}
</Grid>
</React.Fragment> </React.Fragment>
)} )}
{showDataUsageWarning && ( {showDataUsageWarning && (

View file

@ -8,6 +8,7 @@ import {
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
Divider, Divider,
FormControl,
FormControlLabel, FormControlLabel,
Grid2 as Grid, Grid2 as Grid,
IconButton, IconButton,
@ -41,8 +42,10 @@ import {
GetComponentTypeOptions, GetComponentTypeOptions,
getFriendlyName, getFriendlyName,
getMeasurements, getMeasurements,
isController isController,
} from "pbHelpers/ComponentType"; } from "pbHelpers/ComponentType";
import PeriodSelect from "common/time/PeriodSelect";
import { bestUnit, milliToX } from "common/time/duration";
import { import {
ComponentAvailabilityMap, ComponentAvailabilityMap,
DeviceAvailabilityMap, DeviceAvailabilityMap,
@ -177,6 +180,7 @@ export default function ComponentSettings(props: Props) {
const [formValid, setFormValid] = useState(false); const [formValid, setFormValid] = useState(false);
const [excludedNodes, setExcludedNodes] = useState<number[]>([]); const [excludedNodes, setExcludedNodes] = useState<number[]>([]);
const [nodeToExclude, setNodeToExclude] = useState(0); const [nodeToExclude, setNodeToExclude] = useState(0);
const [controlEmail, setControlEmail] = useState("");
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
setTabVal(newValue); setTabVal(newValue);
@ -213,7 +217,8 @@ export default function ComponentSettings(props: Props) {
}, [props.component, componentTypeOptions]); }, [props.component, componentTypeOptions]);
useEffect(() => { useEffect(() => {
if (props.component !== prevComponent || (!isDialogOpen && prevIsDialogOpen)) { if (isDialogOpen && prevIsDialogOpen) return;
if (props.component !== prevComponent || isDialogOpen !== prevIsDialogOpen) {
init(); init();
} }
}, [props.component, prevComponent, init, isDialogOpen, prevIsDialogOpen]); }, [props.component, prevComponent, init, isDialogOpen, prevIsDialogOpen]);
@ -498,6 +503,7 @@ export default function ComponentSettings(props: Props) {
component={formComponent} component={formComponent}
device={device} device={device}
canEdit={canEdit} canEdit={canEdit}
hideControllerFields={isController(formComponent.settings.type)}
componentChanged={(formComp, isValid) => { componentChanged={(formComp, isValid) => {
formComponent.settings = formComp.settings; formComponent.settings = formComp.settings;
setFormValid(isValid); setFormValid(isValid);
@ -507,11 +513,12 @@ export default function ComponentSettings(props: Props) {
); );
}; };
const isCableComponent = () => { const hasCableID = () => {
return (formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE || return (formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE ||
formComponent.settings.type === formComponent.settings.type ===
quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE || quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE ||
formComponent.settings.type === quack.ComponentType.COMPONENT_TYPE_PRESSURE_CABLE || 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.settings.type === quack.ComponentType.COMPONENT_TYPE_CAPACITOR_CABLE &&
formComponent.subType() === formComponent.subType() ===
quack.CapacitorCableSubtype.CAPACITOR_CABLE_SUBTYPE_FROG)) quack.CapacitorCableSubtype.CAPACITOR_CABLE_SUBTYPE_FROG))
@ -577,7 +584,8 @@ export default function ComponentSettings(props: Props) {
)} )}
</TextField> </TextField>
{supportsExpansion() && setExpLine()} {supportsExpansion() && setExpLine()}
{(isCableComponent() && !supportsExpansion())&& setComponentAddrType()}
{(hasCableID() && !supportsExpansion())&& setComponentAddrType()}
</React.Fragment> </React.Fragment>
) : activeStep === 1 ? ( ) : activeStep === 1 ? (
<ComponentForm <ComponentForm
@ -867,6 +875,167 @@ 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 (
<DialogContent>
<TextField
id="output-mode"
name="outputMode"
select
label="Output Mode"
value={formComponent.settings.defaultOutputState}
onChange={handleOutputModeChanged}
margin="normal"
fullWidth
variant="outlined"
disabled={!canEdit}>
<MenuItem value={0}>Auto</MenuItem>
<MenuItem value={1}>On</MenuItem>
<MenuItem value={2}>Off</MenuItem>
</TextField>
<FormControl fullWidth>
<PeriodSelect
id="minCycleTimeMs"
label="Minimum cycle time"
units={["seconds", "minutes"]}
isDisabled={!canEdit}
isError={!isMinCycleTimeValid()}
initialMs={
formComponent.settings.minCycleTimeMs
? formComponent.settings.minCycleTimeMs
: undefined
}
onChange={handleMinCycleTimeChanged}
helperText={
isMinCycleTimeValid()
? "The minimum amount of time that the component spends in a state"
: minCycleTimeDescription()
}
/>
</FormControl>
<Typography variant="subtitle2" sx={{ marginTop: 2, marginBottom: 1 }}>
Authorized Control Emails
</Typography>
<Grid container spacing={1} alignItems="center">
<Grid size={{ xs: 9 }}>
<TextField
label="Email address"
type="email"
fullWidth
variant="outlined"
size="small"
value={controlEmail}
onChange={e => setControlEmail(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
addControlEmail();
}
}}
disabled={!canEdit}
/>
</Grid>
<Grid size={{ xs: 3 }}>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={addControlEmail}
disabled={!canEdit || controlEmail.trim() === ""}>
Add
</Button>
</Grid>
</Grid>
{formComponent.settings.controlEmails.map((email, i) => (
<Grid
container
key={i}
alignItems="center"
justifyContent="space-between"
style={{ marginTop: 4 }}>
<Grid>
<Typography variant="body2">{email}</Typography>
</Grid>
<Grid>
<IconButton
size="small"
onClick={() => removeControlEmail(i)}
disabled={!canEdit}
className={classNames(classes.redButton)}>
<RemoveIcon />
</IconButton>
</Grid>
</Grid>
))}
<Divider sx={{ marginTop: 3, marginBottom: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Email Control
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
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:
</Typography>
<Box
sx={{
backgroundColor: "action.hover",
borderRadius: 1,
padding: 1.5,
marginTop: 1,
fontFamily: "monospace",
fontSize: "0.85rem"
}}>
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - ON</div>
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - OFF</div>
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - AUTO</div>
</Box>
</DialogContent>
);
};
const actions = () => { const actions = () => {
return ( return (
<DialogActions> <DialogActions>
@ -1042,36 +1211,55 @@ export default function ComponentSettings(props: Props) {
scroll="paper"> scroll="paper">
{title()} {title()}
<Divider /> <Divider />
{!isController(formComponent.settings.type) && ( {isController(formComponent.settings.type) ? (
<Tabs <React.Fragment>
value={tabVal} <Tabs
indicatorColor="primary" value={tabVal}
textColor="primary" indicatorColor="primary"
onChange={handleChange}> textColor="primary"
<Tab label="General" /> onChange={handleChange}>
<Tab label="Overlays" /> <Tab label="General" />
<Tab label="Nodes" disabled={!prevComponent} /> <Tab label="Control" />
</Tabs> </Tabs>
<TabPanelMine value={tabVal} index={0}>
{content()}
</TabPanelMine>
<TabPanelMine value={tabVal} index={1}>
{controlContent()}
</TabPanelMine>
</React.Fragment>
) : (
<React.Fragment>
<Tabs
value={tabVal}
indicatorColor="primary"
textColor="primary"
onChange={handleChange}>
<Tab label="General" />
<Tab label="Overlays" />
<Tab label="Nodes" disabled={!prevComponent} />
</Tabs>
<TabPanelMine value={tabVal} index={0}>
{content()}
</TabPanelMine>
<TabPanelMine value={tabVal} index={1}>
<DialogContent>
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
alignContent="center"
spacing={1}>
{overlayGroup()}
</Grid>
</DialogContent>
</TabPanelMine>
<TabPanelMine value={tabVal} index={2}>
{componentPrefs()}
</TabPanelMine>
</React.Fragment>
)} )}
<TabPanelMine value={tabVal} index={0}>
{content()}
</TabPanelMine>
<TabPanelMine value={tabVal} index={1}>
<DialogContent>
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
alignContent="center"
spacing={1}>
{overlayGroup()}
</Grid>
</DialogContent>
</TabPanelMine>
<TabPanelMine value={tabVal} index={2}>
{componentPrefs()}
</TabPanelMine>
{actions()} {actions()}
</ResponsiveDialog> </ResponsiveDialog>
)} )}

View file

@ -95,6 +95,7 @@ export default function DeviceSettings(props: Props) {
const deviceAPI = useDeviceAPI(); const deviceAPI = useDeviceAPI();
const { device, isDialogOpen, closeDialogCallback, canEdit, refreshCallback, components } = props; const { device, isDialogOpen, closeDialogCallback, canEdit, refreshCallback, components } = props;
const prevDevice = usePrevious(device); const prevDevice = usePrevious(device);
const prevIsDialogOpen = usePrevious(isDialogOpen);
const [deviceForm, setDeviceForm] = useState<Device>(deviceFromForm(Device.clone(device))); const [deviceForm, setDeviceForm] = useState<Device>(deviceFromForm(Device.clone(device)));
const [isRemoveDeviceDialogOpen, setIsRemoveDeviceDialogOpen] = useState<boolean>(false); const [isRemoveDeviceDialogOpen, setIsRemoveDeviceDialogOpen] = useState<boolean>(false);
const [sleeps, setSleeps] = useState<boolean>(device.settings.sleepDurationS > 0); const [sleeps, setSleeps] = useState<boolean>(device.settings.sleepDurationS > 0);
@ -110,7 +111,8 @@ export default function DeviceSettings(props: Props) {
const [existingMutation, setExistingMutation] = useState<pond.LinearMutation>(); const [existingMutation, setExistingMutation] = useState<pond.LinearMutation>();
useEffect(() => { useEffect(() => {
if (prevDevice !== device) { if (isDialogOpen && prevIsDialogOpen) return;
if (prevDevice !== device || isDialogOpen !== prevIsDialogOpen) {
setDeviceForm(deviceFromForm(Device.clone(props.device))); setDeviceForm(deviceFromForm(Device.clone(props.device)));
if (device.settings.extensionComponents[0]) { if (device.settings.extensionComponents[0]) {
setCompExtOne(device.settings.extensionComponents[0]); setCompExtOne(device.settings.extensionComponents[0]);
@ -130,7 +132,7 @@ export default function DeviceSettings(props: Props) {
setComponentsByDevice(cbd); setComponentsByDevice(cbd);
} }
} }
}, [device, prevDevice, props.device, components]); }, [device, prevDevice, props.device, components, isDialogOpen, prevIsDialogOpen]);
const close = () => { const close = () => {
closeDialogCallback(); closeDialogCallback();

View file

@ -22,6 +22,14 @@ class VersionChip extends React.Component<Props, State> {
variant="outlined" variant="outlined"
label={firmwareVersionHelper.description} label={firmwareVersionHelper.description}
icon={firmwareVersionHelper.icon} icon={firmwareVersionHelper.icon}
sx={
firmwareVersionHelper.colour
? {
borderColor: firmwareVersionHelper.colour,
color: firmwareVersionHelper.colour
}
: undefined
}
/> />
</Tooltip> </Tooltip>
); );

View file

@ -133,6 +133,7 @@ export default function InteractionSettings(props: Props) {
const prevInitialInteraction = usePrevious(initialInteraction); const prevInitialInteraction = usePrevious(initialInteraction);
const prevComponents = usePrevious(components); const prevComponents = usePrevious(components);
const prevInitialComponent = usePrevious(initialComponent); const prevInitialComponent = usePrevious(initialComponent);
const prevIsDialogOpen = usePrevious(isDialogOpen);
const classes = useStyles(); const classes = useStyles();
const [{ user, as }] = useGlobalState(); const [{ user, as }] = useGlobalState();
const interactionsAPI = useInteractionsAPI(); const interactionsAPI = useInteractionsAPI();
@ -232,10 +233,12 @@ export default function InteractionSettings(props: Props) {
if (user && user.settings.timezone) { if (user && user.settings.timezone) {
setTimezone(user.settings.timezone); setTimezone(user.settings.timezone);
} }
if (isDialogOpen && prevIsDialogOpen) return;
if ( if (
prevInitialInteraction !== initialInteraction || prevInitialInteraction !== initialInteraction ||
(prevComponents && prevComponents.length !== components.length) || (prevComponents && prevComponents.length !== components.length) ||
getComponentIDString(prevInitialComponent) !== getComponentIDString(initialComponent) getComponentIDString(prevInitialComponent) !== getComponentIDString(initialComponent) ||
isDialogOpen !== prevIsDialogOpen
) { ) {
setDefaultState(); setDefaultState();
} }
@ -247,7 +250,9 @@ export default function InteractionSettings(props: Props) {
prevInitialComponent, prevInitialComponent,
prevInitialInteraction, prevInitialInteraction,
setDefaultState, setDefaultState,
user user,
isDialogOpen,
prevIsDialogOpen
]); ]);
const getAvailableSinks = () => { const getAvailableSinks = () => {

View file

@ -4,6 +4,7 @@ import {
CardActions, CardActions,
CardContent, CardContent,
CardHeader, CardHeader,
CircularProgress,
darken, darken,
Grid2 as Grid, Grid2 as Grid,
IconButton, IconButton,
@ -14,7 +15,7 @@ import {
Tooltip, Tooltip,
Typography Typography
} from "@mui/material"; } from "@mui/material";
import { Settings } from "@mui/icons-material"; import { CheckCircleOutline, Settings } from "@mui/icons-material";
import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks"; import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { Component, Device, Interaction } from "models"; import { Component, Device, Interaction } from "models";
@ -80,11 +81,21 @@ export default function InteractionsOverview(props: Props) {
const [interactions, setInteractions] = useState<Interaction[]>(props.interactions); const [interactions, setInteractions] = useState<Interaction[]>(props.interactions);
const [dirtyInteractions, setDirtyInteractions] = useState<Map<number, boolean>>(new Map()); const [dirtyInteractions, setDirtyInteractions] = useState<Map<number, boolean>>(new Map());
const [mappedComponents, setMappedComponents] = useState<Map<string, Component>>(new Map()); const [mappedComponents, setMappedComponents] = useState<Map<string, Component>>(new Map());
const [acceptedInteractions, setAcceptedInteractions] = useState<Set<string>>(new Set());
const [renderToggle, setRenderToggle] = useState(false); const [renderToggle, setRenderToggle] = useState(false);
const [selectedInteraction, setSelectedInteraction] = useState<Interaction | undefined>( const [selectedInteraction, setSelectedInteraction] = useState<Interaction | undefined>(
undefined 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(() => { useEffect(() => {
if (components !== prevComponents) { if (components !== prevComponents) {
let initMappedComponents: Map<string, Component> = new Map(); let initMappedComponents: Map<string, Component> = new Map();
@ -95,6 +106,31 @@ export default function InteractionsOverview(props: Props) {
} }
if (props.interactions !== prevInteractions) { if (props.interactions !== prevInteractions) {
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 key = interaction.key();
const prevInteraction = prevInteractions?.find(p => p.key() === key);
if (!interaction.status.synced) {
updated.delete(key);
} else if (prevInteraction && !prevInteraction.status.synced) {
console.log("[interactions:effect] ACCEPTED", key);
updated.add(key);
}
});
return updated;
});
setInteractions(cloneDeep(props.interactions)); setInteractions(cloneDeep(props.interactions));
} }
}, [components, prevComponents, props.interactions, prevInteractions]); }, [components, prevComponents, props.interactions, prevInteractions]);
@ -204,11 +240,28 @@ export default function InteractionsOverview(props: Props) {
interactionsAPI interactionsAPI
.updateInteraction(Number(deviceID), settings, as) .updateInteraction(Number(deviceID), settings, as)
.then((_response: any) => { .then((_response: any) => {
console.log("[interactions:submit] API success, setting synced=false for index", index);
let updatedDirtyInteractions = cloneDeep(dirtyInteractions); let updatedDirtyInteractions = cloneDeep(dirtyInteractions);
updatedDirtyInteractions.set(index, false); updatedDirtyInteractions.set(index, false);
setDirtyInteractions(updatedDirtyInteractions); 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()); success("Successfully updated the interaction for " + component.name());
refreshCallback();
}) })
.catch((_err: any) => { .catch((_err: any) => {
error("Error occurred while updating the interaction for " + component.name()); error("Error occurred while updating the interaction for " + component.name());
@ -226,10 +279,12 @@ export default function InteractionsOverview(props: Props) {
let key = interaction.key(); let key = interaction.key();
let source = mappedComponents.get(componentIDToString(interaction.settings.source)); let source = mappedComponents.get(componentIDToString(interaction.settings.source));
let sink = mappedComponents.get(componentIDToString(interaction.settings.sink)); let sink = mappedComponents.get(componentIDToString(interaction.settings.sink));
let statusText = const isAccepted = acceptedInteractions.has(interaction.key());
!interaction.status.synced && interaction.status.lastUpdate const isPending = !interaction.status.synced;
? "Pending " + let statusText = isAccepted
moment(interaction.status.lastUpdate && interaction.status.lastUpdate).fromNow() ? "Pending change accepted"
: isPending
? "Pending " + moment(interaction.status.lastUpdate).fromNow()
: ""; : "";
let schedule = pond.InteractionSchedule.create( let schedule = pond.InteractionSchedule.create(
interaction.settings.schedule !== null ? interaction.settings.schedule : undefined interaction.settings.schedule !== null ? interaction.settings.schedule : undefined
@ -272,7 +327,20 @@ export default function InteractionsOverview(props: Props) {
className={classes.header} className={classes.header}
titleTypographyProps={{ variant: "subtitle2" }} titleTypographyProps={{ variant: "subtitle2" }}
title={interactionResultText(interaction, sink)} title={interactionResultText(interaction, sink)}
subheader={statusText} subheader={
statusText ? (
<Grid container spacing={0.5} alignItems="center">
{isPending && <CircularProgress size={10} thickness={5} color="inherit" />}
{isAccepted && (
<CheckCircleOutline
sx={{ fontSize: 13, color: "var(--status-ok)" }}
/>
)}
<Grid>
{statusText}
</Grid>
</Grid>
) : ""}
subheaderTypographyProps={{ variant: "caption" }} subheaderTypographyProps={{ variant: "caption" }}
action={action} action={action}
/> />

View file

@ -282,6 +282,64 @@ export default function DevicePage() {
enabled: !!deviceID, 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;
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;
});
},
keys: liveContextKeys,
types: liveContextTypes,
as: liveAs,
token,
enabled: !!deviceID,
});
const loadPortScan = () => { const loadPortScan = () => {
deviceAPI.listFoundComponents(parseInt(deviceID)) deviceAPI.listFoundComponents(parseInt(deviceID))
.then(resp => { .then(resp => {
@ -395,7 +453,7 @@ export default function DevicePage() {
interactions={filteredInteractions} interactions={filteredInteractions}
permissions={permissions} permissions={permissions}
deviceComponentPreferences={prefsMap.get(c.key())} deviceComponentPreferences={prefsMap.get(c.key())}
key={i} key={c.key()}
refreshCallback={(updatedComponent?: Component) => refreshCallback={(updatedComponent?: Component) =>
updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice() updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice()
} }
@ -412,7 +470,7 @@ export default function DevicePage() {
interactions={filteredInteractions} interactions={filteredInteractions}
permissions={permissions} permissions={permissions}
deviceComponentPreferences={prefsMap.get(c.key())} deviceComponentPreferences={prefsMap.get(c.key())}
key={i} key={c.key()}
refreshCallback={(updatedComponent?: Component) => refreshCallback={(updatedComponent?: Component) =>
updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice() updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice()
} }

View file

@ -39,7 +39,8 @@ import {
Voltage, Voltage,
VPD, VPD,
Weight, Weight,
CapacitorCable CapacitorCable,
AnalogPressure
} from "pbHelpers/ComponentTypes"; } from "pbHelpers/ComponentTypes";
//import { multilineGrainCableData } from "pbHelpers/ComponentTypes/GrainCable"; //import { multilineGrainCableData } from "pbHelpers/ComponentTypes/GrainCable";
//import { multilineCapCableData } from "./ComponentTypes/CapacitorCable"; //import { multilineCapCableData } from "./ComponentTypes/CapacitorCable";
@ -96,7 +97,8 @@ const COMPONENT_TYPE_MAP = new Map<quack.ComponentType, Function>([
[quack.ComponentType.COMPONENT_TYPE_SEN5X, Sen5x], [quack.ComponentType.COMPONENT_TYPE_SEN5X, Sen5x],
[quack.ComponentType.COMPONENT_TYPE_VIBRATION_CHAIN, VibrationCable], [quack.ComponentType.COMPONENT_TYPE_VIBRATION_CHAIN, VibrationCable],
[quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, dragerGasDongle], [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 { export interface Subtype {

View file

@ -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_PM1618,
value: "ANALOG_PRESSURE_SUBTYPE_PM1618",
friendlyName: "PM 1618"
} 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<Array<Summary>> {
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;
}
};
}

View file

@ -29,3 +29,4 @@ export * from "./Voltage";
export * from "./VPD"; export * from "./VPD";
export * from "./Weight"; export * from "./Weight";
export * from "./CapacitorCable"; export * from "./CapacitorCable";
export * from "./AnalogPressure";

View file

@ -64,7 +64,8 @@ const DefaultAvailability: DeviceAvailabilityMap = new Map<quack.AddressType, De
[quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]], [quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]],
[quack.ComponentType.COMPONENT_TYPE_AIRFLOW, [0x6c]], [quack.ComponentType.COMPONENT_TYPE_AIRFLOW, [0x6c]],
[quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE, [0x71]], // the address cables will use when they are plugged into the expander with V2 device only [quack.ComponentType.COMPONENT_TYPE_GRAIN_CABLE, [0x71]], // the address cables will use when they are plugged into the expander with V2 device only
[quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, [0x6d]] [quack.ComponentType.COMPONENT_TYPE_DRAGER_GAS_DONGLE, [0x6d]],
[quack.ComponentType.COMPONENT_TYPE_ANALOG_PRESSURE, [0x6e]]
]) ])
], ],
[quack.AddressType.ADDRESS_TYPE_DAC, [0, 1]], [quack.AddressType.ADDRESS_TYPE_DAC, [0, 1]],

View file

@ -9,6 +9,7 @@ export interface FirmwareVersionHelper {
const oldFirmwareColour = "var(--status-warning)"; const oldFirmwareColour = "var(--status-warning)";
const currentFirmwareColour = "var(--status-ok)"; const currentFirmwareColour = "var(--status-ok)";
const unknownFirmwareColour = "var(--status-warning)";
export function getFirmwareVersionHelper( export function getFirmwareVersionHelper(
version: string, version: string,
@ -17,24 +18,35 @@ export function getFirmwareVersionHelper(
const firmwareIcon = <FirmwareIcon />; const firmwareIcon = <FirmwareIcon />;
const oldFirmwareIcon = <FirmwareIcon style={{ color: oldFirmwareColour }} />; const oldFirmwareIcon = <FirmwareIcon style={{ color: oldFirmwareColour }} />;
const currentFirmwareIcon = <FirmwareIcon style={{ color: currentFirmwareColour }} />; const currentFirmwareIcon = <FirmwareIcon style={{ color: currentFirmwareColour }} />;
const unknownFirmwareIcon = <FirmwareIcon style={{ color: unknownFirmwareColour }} />;
let helper = {} as FirmwareVersionHelper; let helper = {} as FirmwareVersionHelper;
if (!version || version === "") { if (!version || version === "") {
helper.description = "Unknown version"; helper.description = "Unknown version";
helper.icon = oldFirmwareIcon; helper.icon = oldFirmwareIcon;
helper.tooltip = "We don't know what version your device is, it may behave unexpectedly"; helper.tooltip = "We don't know what version your device is, it may behave unexpectedly";
helper.colour = oldFirmwareColour;
} else if (version.startsWith("!")) {
const cleanVersion = version.substring(1);
helper.description = cleanVersion;
helper.icon = unknownFirmwareIcon;
helper.tooltip = "Firmware " + cleanVersion + " was reported by the device but is missing from the firmware list";
helper.colour = unknownFirmwareColour;
} else if (available !== "" && version !== available) { } else if (available !== "" && version !== available) {
helper.description = version; helper.description = version;
helper.icon = oldFirmwareIcon; helper.icon = oldFirmwareIcon;
helper.tooltip = helper.tooltip =
"A firmware upgrade is available, some features may be disabled for out-of-date devices"; "A firmware upgrade is available, some features may be disabled for out-of-date devices";
helper.colour = oldFirmwareColour;
} else if (available === "") { } else if (available === "") {
helper.description = version; helper.description = version;
helper.icon = firmwareIcon; helper.icon = firmwareIcon;
helper.tooltip = "Running version " + version; helper.tooltip = "Running version " + version;
helper.colour = "";
} else { } else {
helper.description = version; helper.description = version;
helper.icon = currentFirmwareIcon; helper.icon = currentFirmwareIcon;
helper.tooltip = "Your device is running the latest firmware"; helper.tooltip = "Your device is running the latest firmware";
helper.colour = currentFirmwareColour;
} }
return helper; return helper;
} }

View file

@ -174,10 +174,9 @@ export class MeasurementDescriber {
this.details.path = "power.inputVoltageTimes10"; this.details.path = "power.inputVoltageTimes10";
this.details.decimals = 1; 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.unit = "mV";
this.details.graph = GraphType.MULTILINE; this.details.graph = GraphType.MULTILINE;
this.details.unit = "mV";
this.details.decimals = 0 this.details.decimals = 0
// this.details.nodeDetails = { // this.details.nodeDetails = {
// colours: ["white", "black", "red", "blue"], // colours: ["white", "black", "red", "blue"],

View file

@ -10,6 +10,7 @@ const MiVentV1Pins: ConfigurablePin[] = [
{ address: 1024, label: "C2" } { 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< export const MiVentV1Availability: DeviceAvailabilityMap = new Map<
quack.AddressType, quack.AddressType,
DevicePositions DevicePositions
@ -53,7 +54,8 @@ export const MiVentV2Availability: DeviceAvailabilityMap = new Map<
[quack.ComponentType.COMPONENT_TYPE_DHT, [0x40]], [quack.ComponentType.COMPONENT_TYPE_DHT, [0x40]],
[quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]], [quack.ComponentType.COMPONENT_TYPE_SEN5X, [0x69]],
[quack.ComponentType.COMPONENT_TYPE_AIRFLOW, [0x6c]], [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]]
]) ])
], ],

View file

@ -62,6 +62,14 @@ export interface IComponentAPIContext {
keys?: string[], keys?: string[],
types?: string[] types?: string[]
) => Promise<AxiosResponse<pond.ListComponentHistoryResponse>>; ) => Promise<AxiosResponse<pond.ListComponentHistoryResponse>>;
listHistoryBetween: (
deviceId: string | number,
componentKey: string,
start: string,
end: string,
keys?: string[],
types?: string[]
) => Promise<AxiosResponse<pond.ListComponentHistoryBetweenResponse>>;
// Old measuremnt structure functions, no longer used in codebase // Old measuremnt structure functions, no longer used in codebase
// listMeasurements: ( // listMeasurements: (
// device: number, // device: number,
@ -391,6 +399,36 @@ export default function ComponentProvider(props: PropsWithChildren<Props>) {
}) })
}; };
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<AxiosResponse<pond.ListComponentHistoryBetweenResponse>>((resolve,reject) => {
get<pond.ListComponentHistoryBetweenResponse>(url).then(resp => {
resp.data = pond.ListComponentHistoryBetweenResponse.fromObject(resp.data)
return resolve(resp)
}).catch(err => {
return reject(err)
})
})
};
// const listMeasurements = ( // const listMeasurements = (
// device: number, // device: number,
// component: string, // component: string,
@ -596,6 +634,7 @@ export default function ComponentProvider(props: PropsWithChildren<Props>) {
listForObject: listComponentsForObject, listForObject: listComponentsForObject,
listComponentCardData: listComponentCardData, listComponentCardData: listComponentCardData,
listHistory, listHistory,
listHistoryBetween,
//listMeasurements: listMeasurements, //listMeasurements: listMeasurements,
//sampleMeasurements: sampleMeasurements, //sampleMeasurements: sampleMeasurements,
sampleUnitMeasurements, sampleUnitMeasurements,