refactoring object interactions into multiple components and taking into account controller interactions having to be on the same device where the alerts dont matter

This commit is contained in:
csawatzky 2025-11-26 16:50:09 -06:00
parent bdddcfc103
commit 3cdddb928f
7 changed files with 459 additions and 2 deletions

View file

@ -1,6 +1,7 @@
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { or } from "utils/types"; import { or } from "utils/types";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { Result } from "pbHelpers/Enums";
export class Interaction { export class Interaction {
public settings: pond.InteractionSettings = pond.InteractionSettings.create(); public settings: pond.InteractionSettings = pond.InteractionSettings.create();
@ -53,6 +54,18 @@ export class Interaction {
return subtype; 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 { public static upToSubtype(nodeNumber: number): number {
let subtype = 0; let subtype = 0;
switch (nodeNumber) { switch (nodeNumber) {

View file

@ -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 (
<Box>
<Typography>{type + " " + comparison + " " + value + describer.GetUnit()}</Typography>
</Box>
);
};
const alertAccordion = (alert: Alert) => {
return (
<Accordion style={{ width: "100%" }}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Grid2 container direction="column">
{alert.conditions.map((condition, i) => (
<Grid2 key={i}>
{readableCondition(condition)}
</Grid2>
))}
</Grid2>
</AccordionSummary>
<AccordionDetails>
<List>
<ListSubheader>Reporting Components</ListSubheader>
{alert.sourceComponents.map((comp, i) => (
<ListItem key={i}>{comp.name()}</ListItem>
))}
</List>
</AccordionDetails>
</Accordion>
);
};
return (
<React.Fragment>
<NewObjectInteraction open={openNewAlert} onClose={()=>{}}/>
<List style={{ margin: 5 }}>
<ListSubheader>Alerts</ListSubheader>
{alerts.map((alert, i) => (
<ListItem key={i}>{alertAccordion(alert)}</ListItem>
))}
</List>
</React.Fragment>
);
}

View file

@ -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<Map<number, Control[]>>(new Map())
const [deviceNameMap, setDeviceNameMap] = useState<Map<number, string>>(new Map())
useEffect(()=>{
let map: Map<number, Control[]> = new Map()
let nameMap: Map<number, string> = 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 (
<Accordion>
<AccordionSummary>{control.device.name()}</AccordionSummary>
<AccordionDetails></AccordionDetails>
</Accordion>
)
}
return (
<React.Fragment>
<List style={{ margin: 5 }}>
<ListSubheader>Controls</ListSubheader>
{controls.map((control, i) => (
<ListItem key={i}>{controlAccordion(control)}</ListItem>
))}
</List>
</React.Fragment>
)
}

View file

@ -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>(
quack.ComponentType.COMPONENT_TYPE_INVALID
);
const [conditions, setConditions] = useState<pond.InteractionCondition[]>([]);
const [selectedAlertComponents, setSelectedAlertComponents] = useState<string[]>([]);
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 (
<ResponsiveDialog
open={open}
onClose={() => {
close(false);
}}>
<DialogTitle>Set New Alert</DialogTitle>
<DialogContent>
{/* Dropdown to select the component type to add the interaction to */}
<Select
id="componentType"
label="Component Type"
fullWidth
displayEmpty
value={newAlertComponentType}
onChange={e => {
setNewAlertComponentType(e.target.value as quack.ComponentType);
setSelectedAlertComponents([]);
setConditions(initialConditions(e.target.value as quack.ComponentType));
}}>
<MenuItem key={0} value={0}>
Select Component Type
</MenuItem>
{typeOptions.map(option => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
{/* list of checkboxes for the components that match that type */}
{listMatchingComponents()}
{/* have the conditions set here */}
{conditionInput()}
{/* have the schedule set here */}
{scheduler()}
</DialogContent>
<DialogActions>
<Button onClick={() => close(false)} variant="contained">
Cancel
</Button>
<Button
disabled={selectedAlertComponents.length === 0}
onClick={addInteractionToComponents}
variant="contained"
color="primary">
Confirm
</Button>
</DialogActions>
</ResponsiveDialog>
)
}

View file

@ -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<string, Component>; //the component key to the component object
componentDevices: Map<string, number>;
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<Alert[]>([]);
//list of interactions relating to a controller
const [controls, setControls] = useState<Control[]>([]);
const interactionsAPI = useInteractionsAPI();
const [sinkOptions, setSinkOptions] = useState<Component[]>([])
const [typeOptions, setTypeOptions] = useState<Option[]>([]);
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<string, string>();
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 (
<Card raised style={{ margin: 5 }}>
<Box padding={1}>
<Typography style={{ fontWeight: 650, fontSize: 25 }}>Interactions</Typography>
<Controls controls={controls}/>
<Alerts alerts={alerts}/>
{/* <Notifications /> */}
</Box>
</Card>
)
}

View file

@ -69,6 +69,7 @@ import TaskViewer from "tasks/TaskViewer";
import ButtonGroup from "common/ButtonGroup"; import ButtonGroup from "common/ButtonGroup";
import BinTransactions from "bin/BinTransactions"; import BinTransactions from "bin/BinTransactions";
import { CO2 } from "models/CO2"; import { CO2 } from "models/CO2";
import ObjectInteractions from "objects/objectInteractions/ObjectInteractions";
interface TabPanelProps { interface TabPanelProps {
children?: React.ReactNode; children?: React.ReactNode;
@ -896,11 +897,16 @@ export default function Bin(props: Props) {
</Box> </Box>
{detail === "alerts" && ( {detail === "alerts" && (
<Box> <Box>
<ObjectAlerts {/* <ObjectAlerts
linkedComponents={components} linkedComponents={components}
componentDevices={componentDevices} componentDevices={componentDevices}
objectType={pond.ObjectType.OBJECT_TYPE_BIN} objectType={pond.ObjectType.OBJECT_TYPE_BIN}
objectKey={bin.key()} objectKey={bin.key()}
/> */}
<ObjectInteractions
linkedComponents={components}
componentDevices={componentDevices}
devices={devices}
/> />
</Box> </Box>
)} )}
@ -1033,11 +1039,16 @@ export default function Bin(props: Props) {
</TabPanelMine> </TabPanelMine>
<TabPanelMine value={mobileTab} index={4}> <TabPanelMine value={mobileTab} index={4}>
<Box> <Box>
<ObjectAlerts {/* <ObjectAlerts
linkedComponents={components} linkedComponents={components}
componentDevices={componentDevices} componentDevices={componentDevices}
objectType={pond.ObjectType.OBJECT_TYPE_BIN} objectType={pond.ObjectType.OBJECT_TYPE_BIN}
objectKey={bin.key()} objectKey={bin.key()}
/> */}
<ObjectInteractions
linkedComponents={components}
componentDevices={componentDevices}
devices={devices}
/> />
</Box> </Box>
</TabPanelMine> </TabPanelMine>