From 3cdddb928f9c36eca3db2a701013e928bc555314 Mon Sep 17 00:00:00 2001 From: csawatzky Date: Wed, 26 Nov 2025 16:50:09 -0600 Subject: [PATCH] refactoring object interactions into multiple components and taking into account controller interactions having to be on the same device where the alerts dont matter --- src/models/Interaction.ts | 13 ++ src/objects/objectInteractions/Alerts.tsx | 74 +++++++ src/objects/objectInteractions/Controls.tsx | 61 ++++++ .../NewObjectInteraction.tsx | 94 ++++++++ .../objectInteractions/Notifications.tsx | 0 .../objectInteractions/ObjectInteractions.tsx | 204 ++++++++++++++++++ src/pages/Bin.tsx | 15 +- 7 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 src/objects/objectInteractions/Alerts.tsx create mode 100644 src/objects/objectInteractions/Controls.tsx create mode 100644 src/objects/objectInteractions/NewObjectInteraction.tsx create mode 100644 src/objects/objectInteractions/Notifications.tsx create mode 100644 src/objects/objectInteractions/ObjectInteractions.tsx diff --git a/src/models/Interaction.ts b/src/models/Interaction.ts index 659ca70..f9edba9 100644 --- a/src/models/Interaction.ts +++ b/src/models/Interaction.ts @@ -1,6 +1,7 @@ import { pond } from "protobuf-ts/pond"; import { or } from "utils/types"; import { cloneDeep } from "lodash"; +import { Result } from "pbHelpers/Enums"; export class Interaction { public settings: pond.InteractionSettings = pond.InteractionSettings.create(); @@ -53,6 +54,18 @@ export class Interaction { return subtype; } + //these two are not mutually exclusive, it is possible to be an alert and a control + public isAlert(): boolean { + if(this.settings.result?.type === Result.report) return true + if(this.settings.notifications?.reports && this.settings.notifications.notify) return true + return false + } + + public isControl(): boolean { + if(this.settings.result?.type !== Result.report) return true //as of right now all interactions that are not report are some sort of control + return false + } + public static upToSubtype(nodeNumber: number): number { let subtype = 0; switch (nodeNumber) { diff --git a/src/objects/objectInteractions/Alerts.tsx b/src/objects/objectInteractions/Alerts.tsx new file mode 100644 index 0000000..0c1ab01 --- /dev/null +++ b/src/objects/objectInteractions/Alerts.tsx @@ -0,0 +1,74 @@ +import { ExpandMore } from "@mui/icons-material"; +import { Accordion, AccordionDetails, AccordionSummary, Box, Grid2, List, ListItem, ListSubheader, Typography } from "@mui/material"; +import { Component } from "models"; +import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; +import { pond, quack } from "protobuf-ts/pond"; +import React, { useState } from "react"; +import NewObjectInteraction from "./NewObjectInteraction"; + +export interface Alert { + conditions: pond.InteractionCondition[]; + sourceComponents: Component[]; +} + +interface Props { + alerts: Alert[] +} + +export default function Alerts(props: Props){ + const {alerts} = props + const [openNewAlert, setOpenNewAlert] = useState(false) + + const readableCondition = (condition: pond.InteractionCondition) => { + let describer = describeMeasurement(condition.measurementType); + let type = describer.label(); + let comparison = + condition.comparison === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO + ? "Exactly" + : condition.comparison === quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN + ? "Above" + : "Below"; + let value = describer.toDisplay(condition.value); + return ( + + {type + " " + comparison + " " + value + describer.GetUnit()} + + ); + }; + + const alertAccordion = (alert: Alert) => { + return ( + + }> + + {alert.conditions.map((condition, i) => ( + + {readableCondition(condition)} + + ))} + + + + + Reporting Components + {alert.sourceComponents.map((comp, i) => ( + {comp.name()} + ))} + + + + ); + }; + + return ( + + {}}/> + + Alerts + {alerts.map((alert, i) => ( + {alertAccordion(alert)} + ))} + + + ); +} \ No newline at end of file diff --git a/src/objects/objectInteractions/Controls.tsx b/src/objects/objectInteractions/Controls.tsx new file mode 100644 index 0000000..096c9ca --- /dev/null +++ b/src/objects/objectInteractions/Controls.tsx @@ -0,0 +1,61 @@ +import { Accordion, AccordionDetails, AccordionSummary, List, ListItem, ListSubheader } from "@mui/material"; +import { Component, Device } from "models"; +import { pond } from "protobuf-ts/pond"; +import React, { useEffect, useState } from "react"; + +export interface Control { + conditions: pond.InteractionCondition[]; + sourceComponents: Component[]; + result: pond.InteractionResult | null; + sinkComponent: Component + device: Device +} + +interface Props { + controls: Control[] +} + +export default function Controls(props: Props){ + const { controls } = props + const [deviceControls, setDeviceControls] = useState>(new Map()) + const [deviceNameMap, setDeviceNameMap] = useState>(new Map()) + + useEffect(()=>{ + let map: Map = new Map() + let nameMap: Map = new Map() + controls.forEach(control => { + let mappedControls = map.get(control.device.id()) + if(mappedControls){ + mappedControls.push(control) + }else{ + map.set(control.device.id(), [control]) + } + if(!nameMap.get(control.device.id())){ + nameMap.set(control.device.id(), control.device.name()) + } + }) + setDeviceControls(map) + setDeviceNameMap(nameMap) + },[controls]) + + const controlAccordion = (control: Control) => { + return ( + + {control.device.name()} + + + ) + } + + + return ( + + + Controls + {controls.map((control, i) => ( + {controlAccordion(control)} + ))} + + + ) +} \ No newline at end of file diff --git a/src/objects/objectInteractions/NewObjectInteraction.tsx b/src/objects/objectInteractions/NewObjectInteraction.tsx new file mode 100644 index 0000000..31170e3 --- /dev/null +++ b/src/objects/objectInteractions/NewObjectInteraction.tsx @@ -0,0 +1,94 @@ +import { Button, DialogActions, DialogContent, DialogTitle, MenuItem, Select } from "@mui/material"; +import ResponsiveDialog from "common/ResponsiveDialog"; +import { getMeasurements } from "pbHelpers/ComponentType"; +import { Measurement, Operator } from "pbHelpers/Enums"; +import { pond } from "protobuf-ts/pond"; +import { quack } from "protobuf-ts/quack"; +import { useEffect, useState } from "react"; + +interface Props { + open: boolean + onClose: (refreshCallback: boolean) => void + //if we pass in a device id we could filter the components to only be for that device, this could be used for the control part + device?: number +} + +export default function NewObjectInteraction(props: Props){ + const { open, onClose } = props + const [newAlertComponentType, setNewAlertComponentType] = useState( + quack.ComponentType.COMPONENT_TYPE_INVALID + ); + const [conditions, setConditions] = useState([]); + const [selectedAlertComponents, setSelectedAlertComponents] = useState([]); + + useEffect(() => {},[]) + + + const close = (refresh: boolean) => { + onClose(refresh) + } + const availableMeasurementTypes = (type: quack.ComponentType): quack.MeasurementType[] => { + return getMeasurements(type).map(m => m.measurementType); + }; + + const initialConditions = (type: quack.ComponentType): pond.InteractionCondition[] => { + let measurementType = availableMeasurementTypes(type)[0]; + let condition = pond.InteractionCondition.create({ + measurementType: measurementType, + comparison: measurementType === Measurement.boolean ? Operator.equals : Operator.less, + value: 0 + }); + return [condition]; + }; + + return ( + { + close(false); + }}> + Set New Alert + + {/* Dropdown to select the component type to add the interaction to */} + + {/* list of checkboxes for the components that match that type */} + {listMatchingComponents()} + {/* have the conditions set here */} + {conditionInput()} + {/* have the schedule set here */} + {scheduler()} + + + + + + + ) +} \ No newline at end of file diff --git a/src/objects/objectInteractions/Notifications.tsx b/src/objects/objectInteractions/Notifications.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/objects/objectInteractions/ObjectInteractions.tsx b/src/objects/objectInteractions/ObjectInteractions.tsx new file mode 100644 index 0000000..aa9f269 --- /dev/null +++ b/src/objects/objectInteractions/ObjectInteractions.tsx @@ -0,0 +1,204 @@ +import { Box, Button, Card, Typography } from "@mui/material"; +import { useEffect, useState } from "react"; +import Alerts, { Alert } from "./Alerts"; +import Controls, { Control } from "./Controls"; +import { Component, Device, Interaction } from "models"; +import { quack } from "protobuf-ts/quack"; +import { Option } from "common/SearchSelect"; +import { useInteractionsAPI } from "hooks"; +import { useGlobalState } from "providers"; +import { extension, getFriendlyName } from "pbHelpers/ComponentType"; +import { sameComponentID } from "pbHelpers/Component"; + +interface Props { + linkedComponents: Map; //the component key to the component object + componentDevices: Map; + devices: Device[]; +} + +export default function ObjectInteractions(props: Props) { + const { linkedComponents, componentDevices, devices } = props + const [{as}] = useGlobalState() + //list of alerts, each alert contains a list of interactions with matching conditions + const [alerts, setAlerts] = useState([]); + //list of interactions relating to a controller + const [controls, setControls] = useState([]); + const interactionsAPI = useInteractionsAPI(); + const [sinkOptions, setSinkOptions] = useState([]) + const [typeOptions, setTypeOptions] = useState([]); + + + const matchConditions = (interaction: Interaction, set: Alert | Control) => { + //if the number of conditions are not the same they are different + if (interaction.settings.conditions.length !== set.conditions.length) { + return false; + } + //continue with the comparison + let matching = true; + interaction.settings.conditions.forEach((condition, i) => { + if ( + condition.comparison !== set.conditions[i].comparison || + condition.measurementType !== set.conditions[i].measurementType || + condition.value !== set.conditions[i].value + ) { + matching = false; + } + }); + return matching; + }; + + const matchResult = (interaction: Interaction, set: Control) => { + let interactionResult = interaction.settings.result + let setResult = set.result + let matching = true + // if anything in the result for the interaction is different from the set make matching false + if(interactionResult?.type !== setResult?.type || + interactionResult?.mode !== setResult?.mode || + interactionResult?.value !== setResult?.value || + interactionResult?.dutyCycle !== setResult?.dutyCycle + ){ + matching = false + } + return matching + } + + const findDevice = (deviceID: number) => { + for (let device of devices) { + if (device.id() === deviceID) return device + } + } + + const findSinkComponent = (sinks: Component[], interactionSink: quack.ComponentID, sourceDevice: number) => { + for (let component of sinks) { + if (sameComponentID(component.location(), interactionSink) && sourceDevice === componentDevices.get(component.key())){ + return component + } + } + } + + useEffect(() => { + const newInteractions: Interaction[] = []; + const interactionSourceMap = new Map(); + const includedOps: quack.ComponentType[] = []; + const typeOps: Option[] = []; + const sinkOptions: Component[] = []; + const alerts: Alert[] = []; + const controls: Control[] = []; + + const run = async () => { + // Run all component API fetches in parallel + await Promise.all( + [...linkedComponents].map(async ([key, comp]) => { + const device = componentDevices.get(key); + + if (device) { + const resp = await interactionsAPI.listInteractionsByComponent( + device, + comp.location(), + undefined, + as + ); + + resp.forEach(interaction => interactionSourceMap.set(interaction.key(), key)); + newInteractions.push(...resp); + } + + // Collect component types + if (!includedOps.includes(comp.type())) { + includedOps.push(comp.type()); + typeOps.push({ + label: getFriendlyName(comp.type()), + value: comp.type(), + }); + } + + // Collect controller components + if (extension(comp.type()).isController) { + sinkOptions.push(comp); + } + }) + ); + }; + + run().then(() => { + newInteractions.forEach(interaction => { + //alert and control are not mutally exclusive, it is possible to be both if the control is sending notifications + if (interaction.isAlert()) { + // Find matching alert + let similarAlertIndex = alerts.findIndex(alert => + matchConditions(interaction, alert) + ); + + const compKey = interactionSourceMap.get(interaction.key()); + const component = compKey ? linkedComponents.get(compKey) : undefined; + + if (component) { + if (similarAlertIndex === -1) { + alerts.push({ + conditions: interaction.settings.conditions, + sourceComponents: [component], + }); + } else { + alerts[similarAlertIndex].sourceComponents.push(component); + } + } + + } + if (interaction.isControl()) { + if (!interaction.settings.sink) return //if there is no sink it cant be a control so move on to the next + const compKey = interactionSourceMap.get(interaction.key()); + if (!compKey) return //we have to have a valid component key + const component = linkedComponents.get(compKey); + const deviceID = componentDevices.get(compKey); + if (!deviceID) return //we have to know which device it is for + const controlDevice = findDevice(deviceID) + if (!controlDevice) return //failed to get the device from the array + const sinkComponent = findSinkComponent(sinkOptions, interaction.settings.sink, deviceID) + if (!sinkComponent) return //failed to get the correct sink component + + let similarControlIndex = controls.findIndex(control => + matchConditions(interaction, control) && + matchResult(interaction, control) && + sameComponentID(interaction.settings.sink, control.sinkComponent.location()) && //if the sink for the interaction is the same address as the existing control + deviceID === control.device.id() //if the device id for source component is the same as the device id in the control + ); + + if (component && interaction.settings.result) { + if (similarControlIndex === -1) { + controls.push({ + conditions: interaction.settings.conditions, + result: interaction.settings.result, + sourceComponents: [component], + sinkComponent: sinkComponent, + device: controlDevice + }); + } else { + controls[similarControlIndex].sourceComponents.push(component); + } + } + } + }); + setTypeOptions(typeOps); + setSinkOptions(sinkOptions); + setAlerts(alerts); + console.log(alerts) + setControls(controls); + console.log(controls) + }).catch(err => { + console.error("Interaction fetch error:", err) + }) + }, [linkedComponents, interactionsAPI, componentDevices, as]); + + + + return ( + + + Interactions + + + {/* */} + + + ) +} \ No newline at end of file diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 1fb51fb..4776ec7 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -69,6 +69,7 @@ import TaskViewer from "tasks/TaskViewer"; import ButtonGroup from "common/ButtonGroup"; import BinTransactions from "bin/BinTransactions"; import { CO2 } from "models/CO2"; +import ObjectInteractions from "objects/objectInteractions/ObjectInteractions"; interface TabPanelProps { children?: React.ReactNode; @@ -896,11 +897,16 @@ export default function Bin(props: Props) { {detail === "alerts" && ( - */} + )} @@ -1033,11 +1039,16 @@ export default function Bin(props: Props) { - */} +