diff --git a/package-lock.json b/package-lock.json index a6c8aad..19352f1 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#9c0f668d4a56b8216dd71a44c3110a818aaf3541", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#100126cc7f3cdf18f68af6372e5db4113db012b7", "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", diff --git a/src/bin/BinPlayer.tsx b/src/bin/BinPlayer.tsx index 88bc45c..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[]; @@ -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). * These are the playback frames — not derived from measurement data. @@ -360,6 +458,12 @@ 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, + /** 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 @@ -416,7 +520,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 @@ -437,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 => @@ -471,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); @@ -529,6 +651,17 @@ 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; + 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 +674,27 @@ 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 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, ]); + // 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); + }); + + // Build sorted bushel history from bin object measurements. + const bushelHistory = buildBushelHistory(binMeasurementsResponse.data); + const checkpoints = buildStatusCheckpoints( bin.status.grainCables, bin.status.fans, @@ -554,6 +702,9 @@ export default function BinPlayer(props: Props) { cableResponses.map(r => r.data), fanResponses.map(r => r.data), heaterResponses.map(r => r.data), + topNodeHistories, + bushelHistory, + bin.status.grainBushels, startDate, endDate, checkpointCountFromRange(startDate, endDate) @@ -574,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); @@ -715,4 +867,4 @@ export default function BinPlayer(props: Props) { ); -} +} \ No newline at end of file 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) { + + + {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..a779692 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); @@ -213,7 +217,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]); @@ -498,6 +503,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); @@ -507,11 +513,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 +584,8 @@ export default function ComponentSettings(props: Props) { )} {supportsExpansion() && setExpLine()} - {(isCableComponent() && !supportsExpansion())&& setComponentAddrType()} + + {(hasCableID() && !supportsExpansion())&& setComponentAddrType()} ) : activeStep === 1 ? ( { + 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)}> + + + + + ))} + + + 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
+
+
+ ); + }; + const actions = () => { return ( @@ -1042,36 +1211,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()} )} 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/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/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 = () => { diff --git a/src/interactions/InteractionsOverview.tsx b/src/interactions/InteractionsOverview.tsx index 600c88a..450c44f 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,11 +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 [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(); @@ -95,6 +106,31 @@ export default function InteractionsOverview(props: Props) { } 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)); } }, [components, prevComponents, props.interactions, prevInteractions]); @@ -204,11 +240,28 @@ export default function InteractionsOverview(props: Props) { 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()); - refreshCallback(); }) .catch((_err: any) => { 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 source = mappedComponents.get(componentIDToString(interaction.settings.source)); let sink = mappedComponents.get(componentIDToString(interaction.settings.sink)); - let statusText = - !interaction.status.synced && interaction.status.lastUpdate - ? "Pending " + - moment(interaction.status.lastUpdate && interaction.status.lastUpdate).fromNow() + const isAccepted = acceptedInteractions.has(interaction.key()); + const isPending = !interaction.status.synced; + let statusText = isAccepted + ? "Pending change accepted" + : isPending + ? "Pending " + moment(interaction.status.lastUpdate).fromNow() : ""; let schedule = pond.InteractionSchedule.create( interaction.settings.schedule !== null ? interaction.settings.schedule : undefined @@ -272,7 +327,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..733d621 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -282,6 +282,64 @@ 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; + 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 = () => { deviceAPI.listFoundComponents(parseInt(deviceID)) .then(resp => { @@ -395,7 +453,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 +470,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() } 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..7921b38 --- /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_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> { + 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/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; 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("!")) { + 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) { 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; } 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"], 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]] ]) ], 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,