diff --git a/package-lock.json b/package-lock.json index 5d86a13..a140bee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "mui-tel-input": "^7.0.0", "notistack": "^3.0.1", "openweathermap-ts": "^1.2.10", - "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#master", + "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", @@ -10960,7 +10960,7 @@ }, "node_modules/protobuf-ts": { "version": "1.0.0", - "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#c08965a296f2cd0799472fd8b163186cf6885d4c", + "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#3f647071f211c4552c2acc23a8ecabb6904a4156", "dependencies": { "protobufjs": "^6.8.8" } diff --git a/src/app/UserWrapper.tsx b/src/app/UserWrapper.tsx index 8905875..9ead445 100644 --- a/src/app/UserWrapper.tsx +++ b/src/app/UserWrapper.tsx @@ -13,6 +13,7 @@ import { CssBaseline, Theme } from '@mui/material' import { AppThemeProvider } from 'theme/AppThemeProvider' import HTTPProvider from 'providers/http' import { Crisp } from "crisp-sdk-web"; +import { useSnackbar } from 'hooks' // import FirmwareLoader from './FirmwareLoader' const reducer = (state: GlobalState, action: GlobalStateAction): GlobalState => { @@ -65,6 +66,7 @@ export default function UserWrapper(props: Props) { const useAuth = useAuth0(); const hasFetched = useRef(false); const [global, setGlobal] = useState(undefined) + const snackbar = useSnackbar() const user_id = or(useAuth.user?.sub, "") @@ -85,7 +87,8 @@ export default function UserWrapper(props: Props) { backgroundTasksComplete: false, firmware: new Map() }) - }).catch(() => { + }).catch((err) => { + snackbar.error("get user and team: "+err) userAPI.getUser(user_id).then(user => { setGlobal({ user: user ? user : User.create(), @@ -96,7 +99,8 @@ export default function UserWrapper(props: Props) { backgroundTasksComplete: false, firmware: new Map() }) - }).catch(() => { + }).catch((err) => { + snackbar.error("get user: "+err) setGlobal(globalDefault) }) diff --git a/src/bin/BinCardV2.tsx b/src/bin/BinCardV2.tsx index a4afc33..776af57 100644 --- a/src/bin/BinCardV2.tsx +++ b/src/bin/BinCardV2.tsx @@ -261,32 +261,39 @@ export default function BinCard(props: Props) { let val; let useEMC = false; - switch (valDisplay) { - case "average": - if (average?.emc) { - useEMC = true; - val = average.emc; - } else { - val = average?.humid; - } - break; - case "low": - if (coldNode?.emc) { - useEMC = true; - val = coldNode.emc; - } else { - val = coldNode?.humid; - } - break; - case "high": - if (hotNode?.emc) { - useEMC = true; - val = hotNode.emc; - } else { - val = hotNode?.humid; - } - break; + //taking the switch cases out so that the node always displays the average for the moisture/humidity + if (average?.emc) { + useEMC = true; + val = average.emc; + } else { + val = average?.humid; } + // switch (valDisplay) { + // case "average": + // if (average?.emc) { + // useEMC = true; + // val = average.emc; + // } else { + // val = average?.humid; + // } + // break; + // case "low": + // if (coldNode?.emc) { + // useEMC = true; + // val = coldNode.emc; + // } else { + // val = coldNode?.humid; + // } + // break; + // case "high": + // if (hotNode?.emc) { + // useEMC = true; + // val = hotNode.emc; + // } else { + // val = hotNode?.humid; + // } + // break; + // } if (val !== undefined && !isNaN(val)) { display = val.toFixed(2); @@ -332,7 +339,8 @@ export default function BinCard(props: Props) { color="textSecondary" noWrap style={{ fontSize: "0.5rem" }}> - {valDisplay === "high" ? "High" : valDisplay === "low" ? "Low" : "Average"} + {/* {valDisplay === "high" ? "High" : valDisplay === "low" ? "Low" : "Average"} */} + Average diff --git a/src/bin/BinTour.tsx b/src/bin/BinTour.tsx index a685ada..e885527 100644 --- a/src/bin/BinTour.tsx +++ b/src/bin/BinTour.tsx @@ -1,6 +1,6 @@ // import { useAuth0 } from "@auth0/auth0-react"; import { Typography } from "@mui/material"; -import Tour from "common/Tour"; +import Tour, { TourStep } from "common/Tour"; import { random } from "lodash"; import moment from "moment"; import { useGlobalState, useSnackbar, useUserAPI } from "providers"; @@ -8,8 +8,13 @@ import React, { useEffect, useState } from "react"; import { Step } from "react-joyride"; // import Emoji from "react-emoji-render"; -export default function BinTour() { +interface Props { + setDetailTabState?: ( detail: "inventory" | "sensors" | "analytics" | "presets" | "alerts" | "transactions", buttonIndex: number) => void +} + +export default function BinTour(props: Props) { // const { userID } = useAuth0(); + const {setDetailTabState} = props const [{ user }, dispatch] = useGlobalState(); const userID = user.id() const { error } = useSnackbar(); @@ -35,8 +40,8 @@ export default function BinTour() { } }; - const getTourSteps = (): Step[] => { - let steps: Step[] = [ + const getTourSteps = (): TourStep[] => { + let steps: TourStep[] = [ { title: ( @@ -103,24 +108,12 @@ export default function BinTour() { disableBeacon: false }, { - title: "Graphs", + title: "View Other Data", content: ( - Bin related analytics are displayed here. - - - ), - target: "#tour-graphs", - placement: "left", - disableBeacon: false - }, - { - title: "Choose your graphs", - content: ( - - - Use this tab to view other sets of data. Sensors must be attached to view sensor data. + Use these tabs to view other Bin Details such as inventory levels over time, + alerts and interactions for connected components, and data for attached sensors ), @@ -128,6 +121,122 @@ export default function BinTour() { placement: "bottom", disableBeacon: false }, + { + title: "Inventory", + content: ( + + + The inventory tab shows your bins inventory level over a specified window as well as the times the bin mode was changed. + + + ), + target: "#tour-details", + placement: "left-start", + disableBeacon: false, + onNext: () => { + if (setDetailTabState) setDetailTabState("sensors", 1) + } + }, + { + title: "Sensors", + content: ( + + + The sensors tab shows readings from attached sensors over time + + + ), + target: "#tour-details", + placement: "left-start", + disableBeacon: false, + onNext: () => { + if (setDetailTabState) setDetailTabState("analytics", 2) + } + }, + { + title: "Analytics", + content: ( + + + The analysis tab shows analytic bin data using attached sensors + + + ), + target: "#tour-details", + placement: "left-start", + disableBeacon: false, + onNext: () => { + if (setDetailTabState) setDetailTabState("alerts", 3) + } + }, + { + title: "Alerts and Interactions", + content: ( + + + The Alerts tab allows you to view or add any new interactions or alerts for connected components + and displays recent notifications from those alerts + + + ), + target: "#tour-details", + placement: "left-start", + disableBeacon: false, + onNext: () => { + if (setDetailTabState) setDetailTabState("presets", 4) + } + }, + { + title: "Bin Mode Presets", + content: ( + + + The Presets tab allows you to create custom presets for the interactions for a device when changing bin modes + + + ), + target: "#tour-details", + placement: "left-start", + disableBeacon: false, + onNext: () => { + if (setDetailTabState) setDetailTabState("transactions", 5) + } + }, + { + title: "Bin Transactions", + content: ( + + + The transactions tab allows view any pending transaction when using a hybrid inventory control + + + ), + target: "#tour-details", + placement: "left-start", + disableBeacon: false, + onNext: () => { + if (setDetailTabState) setDetailTabState("inventory", 0) + } + }, + { + title: "Bin Conditions", + content: ( + + + This section will display conditions based on the bin mode + + + Storage Mode: simply displays the Storage conditions by itself with the target temperature and moisture set in the bin settings + + + Other Modes: will display the interactions for the bins devices that are controlling a fan or heater on the bin as well as the storage conditions + + + ), + target: "#tour-conditions", + placement: "right", + disableBeacon: false + }, { title: "Change Mode", content: ( @@ -138,12 +247,15 @@ export default function BinTour() { diff --git a/src/bin/BinVisualizerV2.tsx b/src/bin/BinVisualizerV2.tsx index 5b3dc62..9036b91 100644 --- a/src/bin/BinVisualizerV2.tsx +++ b/src/bin/BinVisualizerV2.tsx @@ -1727,32 +1727,34 @@ export default function BinVisualizer(props: Props) { {modeChangeInProgress && } - { - setModeStorage() + + { + setModeStorage() + } + }, + { + title: "Cooldown", + function: () => { + setModeCooldown() + } + }, + { + title: bin.settings.inventory && bin.settings.inventory?.initialMoisture < bin.settings.inventory?.targetMoisture ? "Hydrating" : "Drying", + function: () => { + // setShowInputMoisture(true) + setModeConditioning() //will set it to either drying or hydrating based on the initial and target moisture + } } - }, - { - title: "Cooldown", - function: () => { - setModeCooldown() - } - }, - { - title: bin.settings.inventory && bin.settings.inventory?.initialMoisture < bin.settings.inventory?.targetMoisture ? "Hydrating" : "Drying", - function: () => { - // setShowInputMoisture(true) - setModeConditioning() //will set it to either drying or hydrating based on the initial and target moisture - } - } - ]} - toggledButtons={determineToggle(mode)} - toggle - /> + ]} + toggledButtons={determineToggle(mode)} + toggle + /> + ); }; diff --git a/src/bin/BinsList.tsx b/src/bin/BinsList.tsx index 483df50..ad7ae64 100644 --- a/src/bin/BinsList.tsx +++ b/src/bin/BinsList.tsx @@ -23,6 +23,12 @@ interface Props { //startingTranslate: number; valDisplay?: "high" | "low" | "average"; insert?: boolean + /** + * optional function for when you want clicking the card to do something other than navigate to the bin page + * @param bin the bin model for the card that was clicked + * @returns void + */ + cardClickFunction?: (bin: Bin) => void } const useStyles = makeStyles((_theme) => { @@ -40,7 +46,7 @@ const useStyles = makeStyles((_theme) => { }); export default function BinsList(props: Props) { - const { bins, duplicateBin, title, gridView, loadMore, valDisplay, insert } = props; + const { bins, duplicateBin, title, gridView, loadMore, valDisplay, insert, cardClickFunction } = props; const classes = useStyles(); // const history = useHistory(); const navigate = useNavigate() @@ -53,6 +59,14 @@ export default function BinsList(props: Props) { navigate(path, { state: {bin: bins[i]} }); }; + const cardClicked = (index: number) => { + if(cardClickFunction) { + cardClickFunction(bins[index]) + }else{ + goToBin(index) + } + } + const leftArrow = () => { const visibility = useContext(VisibilityContext); //const isFirstVisible = visibility.useIsVisible('first', false) @@ -95,7 +109,7 @@ export default function BinsList(props: Props) { key={i} className={classes.gridListTile} onClick={() => { - !duplicate && goToBin(i) + !duplicate && cardClicked(i) }}> { - !duplicate && goToBin(i) + !duplicate && cardClicked(i) }}> void } const useStyles = makeStyles((theme: Theme) => ({ @@ -82,7 +87,7 @@ const useStyles = makeStyles((theme: Theme) => ({ })); export default function BinyardDisplay(props: Props) { - const { yardKey, insert } = props; + const { yardKey, insert, binClicked } = props; const classes = useStyles(); const binAPI = useBinAPI(); const theme = useTheme(); @@ -321,6 +326,7 @@ export default function BinyardDisplay(props: Props) { bins={grainBins} duplicateBin={duplicateBin} title={"Grain Bins"} + cardClickFunction={binClicked} /> @@ -338,6 +344,7 @@ export default function BinyardDisplay(props: Props) { bins={fertBins} duplicateBin={duplicateBin} title={"Fertilizer Bins"} + cardClickFunction={binClicked} /> @@ -355,6 +362,7 @@ export default function BinyardDisplay(props: Props) { bins={emptyBins} duplicateBin={duplicateBin} title={"Empty Bins"} + cardClickFunction={binClicked} // loadMore={newTranslation => { // //only triggered in the scroll view so this will never trigger in grid view // if (yardBins.length < binTotal) { diff --git a/src/common/ButtonGroup.tsx b/src/common/ButtonGroup.tsx index 3c2c64c..e326d64 100644 --- a/src/common/ButtonGroup.tsx +++ b/src/common/ButtonGroup.tsx @@ -57,6 +57,7 @@ interface Props { * When true will disable all of the buttons in the group, will be overridded by individual buttons disabled state in the button data */ disableAll?: boolean + } const useStyles = makeStyles((theme: Theme) => { diff --git a/src/device/DeviceWizard.tsx b/src/device/DeviceWizard.tsx index 32387eb..3bb7fb9 100644 --- a/src/device/DeviceWizard.tsx +++ b/src/device/DeviceWizard.tsx @@ -141,6 +141,28 @@ export default function DeviceWizard(props: Props) { )} + {selectedPort && + selectedPort.addressType === quack.AddressType.ADDRESS_TYPE_CONFIGURABLE_PIN_ARRAY && + device.featureSupported("detectOneWire") && + selectedPort.autoDetectable && ( + + { + setButtonClicked(true) + console.log(selectedPort) + deviceAPI.detectOneWire(device.id(), selectedPort.address) + .then(resp => { + openSnack("Device will scan port on next check-in") + }).catch(err => { + openSnack("Command failed to send to device") + }) + }}> + Scan Port + + + )} {portComponents.length > 0 && ( Current Components @@ -204,7 +226,6 @@ export default function DeviceWizard(props: Props) { */ const adjustAvailablePositions = (port: PortInformation, availability: DeviceAvailabilityMap) => { let currentAvailability = availability; - console.log(port) switch (port.addressType) { case quack.AddressType.ADDRESS_TYPE_CONFIGURABLE_PIN_ARRAY: currentAvailability.set(port.addressType, [{ address: port.address, label: port.label }]); diff --git a/src/device/autoDetect/DeviceScannedComponents.tsx b/src/device/autoDetect/DeviceScannedComponents.tsx index 0381281..2be89a9 100644 --- a/src/device/autoDetect/DeviceScannedComponents.tsx +++ b/src/device/autoDetect/DeviceScannedComponents.tsx @@ -7,9 +7,11 @@ import { useEffect, useState } from "react"; import ScannedI2C from "./ScannedI2C"; import ComponentForm from "component/ComponentForm"; import { Component, Device } from "models"; -import { DeviceAvailabilityMap, OffsetAvailabilityMap } from "pbHelpers/DeviceAvailability"; +import { DeviceAvailabilityMap, FindAvailablePositions, OffsetAvailabilityMap } from "pbHelpers/DeviceAvailability"; import ResponsiveDialog from "common/ResponsiveDialog"; import { useComponentAPI, useDeviceAPI, useSnackbar } from "hooks"; +import { ConfigurablePin } from "pbHelpers/AddressTypes"; +import ScannedOneWirePort from "./OneWire/ScannedOneWirePort"; interface Props { scannedComponents: pond.ComponentAddressMap @@ -31,17 +33,19 @@ export default function DeviceScannedComponents(props: Props){ const compAPI = useComponentAPI(); const deviceAPI = useDeviceAPI() const [scannedI2C, setScannedI2C] = useState() //the unmodified scan of addresses - const [validCompAddresses, setValidComponentAddresses] = useState([]) //the filtered array of address data + const [scannedOneWire, setScannedOneWire] = useState([]) + const [validI2CCompAddresses, setValidI2CComponentAddresses] = useState([]) //the filtered array of address data const [components, setComponents] = useState([]) const [steps, setSteps] = useState([]); const { error, success } = useSnackbar(); const [currentStep, setCurrentStep] = useState(0) const [settingsValid, setSettingsValid] = useState(false); const [openDialog, setOpenDialog] = useState(false); + const [clearOpen, setClearOpen] = useState(false); useEffect(()=>{ - //console.log(scannedComponents) if (scannedComponents.i2c) setScannedI2C(scannedComponents.i2c) + if (scannedComponents.oneWire) setScannedOneWire(scannedComponents.oneWire) //makes the array empty for testing // if (scannedComponents.i2c) { // let clone = cloneDeep(scannedComponents.i2c) @@ -76,7 +80,7 @@ export default function DeviceScannedComponents(props: Props){ } }) } - setValidComponentAddresses(valid) + setValidI2CComponentAddresses(valid) },[scannedI2C]) const stepper = () => { @@ -200,11 +204,12 @@ export default function DeviceScannedComponents(props: Props){ }) }) } + console.log(cloneList) setComponents(cloneList) } - const removeScan = (key: string) => { - deviceAPI.removeFoundComponents(device.settings.deviceId, key) + const removeScan = (key: string, type: pond.ObjectType) => { + deviceAPI.removeFoundComponents(device.settings.deviceId, key, type) .then(resp => { console.log("Cleared this scan") }).catch(err => { @@ -212,20 +217,51 @@ export default function DeviceScannedComponents(props: Props){ }) } + const removeAllScans = () => { + deviceAPI.removeAllFoundComponents(device.settings.deviceId) + .then(resp => { + console.log("Cleared all scans") + refreshCallback() + }).catch(err => { + console.log("There was a problem clearing scans") + }) + } + + const clearWarning = () => { + return ( + {setClearOpen(false)}}> + Clear All Scans + This action will clear all scans for this device. + + + + + + ) + } + return ( + {clearWarning()} + + Sensor Scan + + {scannedI2C !== undefined && - + I2C Sensors - + - {validCompAddresses.length > 0 ? validCompAddresses.map((addr, index) => { + {validI2CCompAddresses.length > 0 ? validI2CCompAddresses.map((addr, index) => { return ( ()}/> ) @@ -240,7 +276,39 @@ export default function DeviceScannedComponents(props: Props){ } - + + } + {scannedOneWire.length > 0 && + + + + Pin Port Sensors + + + {scannedOneWire.map((scan,i) => { + let map = FindAvailablePositions([], device.settings.product).availability.get(quack.AddressType.ADDRESS_TYPE_CONFIGURABLE_PIN_ARRAY) + let portAddress = scan.settings?.oneWireData?.port + let portLabel = "" + + map?.forEach(entry => { + let pin = entry as ConfigurablePin + if (pin.address && pin.address === portAddress) { + portLabel = pin.label + } + }) + return( + + + Port: {portLabel} + + + + + ) + })} + } diff --git a/src/device/autoDetect/OneWire/PortComponent.tsx b/src/device/autoDetect/OneWire/PortComponent.tsx new file mode 100644 index 0000000..b1e26e1 --- /dev/null +++ b/src/device/autoDetect/OneWire/PortComponent.tsx @@ -0,0 +1,150 @@ +import { Box, Checkbox, Grid2, MenuItem, TextField, Tooltip, Typography } from "@mui/material" +import { useMobile } from "hooks" +import { getFriendlyName, getSubtypes, Subtype } from "pbHelpers/ComponentType" +import { quack } from "protobuf-ts/quack" +import React, { useEffect, useState } from "react" + +interface Props { + compData: quack.OneWireComponentData + componentSelect: (selected: boolean, componentData: quack.OneWireComponentData) => void +} + +export default function PortComponent(props: Props){ + const {compData, componentSelect} = props + // const [selectedSubtype, setSelectedSubtype] = useState(0) + const [subtypeVal, setSubtypeVal] = useState("no subtype")//note that this is the friendly name and not the key or the value because it needs to be different from the other alias options + const [subtypeMap, setSubtypeMap] = useState>(new Map()) + const [subtypeOptions, setSubtypeOptions] = useState([]) + const [added, setAdded] = useState(false) + const isMobile = useMobile() + + useEffect(()=>{ + // setSelectedSubtype(compData.subtype) + let validSubtypes: Subtype[] = [] + let subMap: Map = new Map() + let subVal: string = "no subtype" + getSubtypes(compData.type).forEach(option => { + if(compData.subtype === option.key || compData.subtype === 0){ + validSubtypes.push(option) + if(subVal === "no subtype"){ + subVal = option.friendlyName + } + } + subMap.set(option.friendlyName, option.key) + }) + setSubtypeOptions(validSubtypes) + setSubtypeMap(subMap) + setSubtypeVal(subVal) + },[compData]) + + const subtypeSelector = () => { + return ( + { + setSubtypeVal(event.target.value) + // setSelectedSubtype(subtypeMap.get(event.target.value) ?? 0) + compData.subtype = subtypeMap.get(event.target.value) ?? 0 + }} + select> + Select the Component Subtype + {subtypeOptions.map((s, i) => ( + {s.friendlyName} + ))} + + ) + } + + const selectCompCheckbox = () => { + return ( + { + setAdded(checked) + componentSelect(checked, compData) + }} + /> + } + /> + ) + } + + const desktopDisplay = () => { + return ( + + + + + {getFriendlyName(compData.type)} + + + + {subtypeOptions.length < 2 + ? + + {subtypeVal} + + : + subtypeSelector()} + + + + Cable ID: {compData.cableId} + + + + + Nodes: {compData.nodeCount} + + + + {selectCompCheckbox()} + + + + ) + } + + const mobileDisplay = () => { + return( + + + + + {getFriendlyName(compData.type)} + + + Cable ID: {compData.cableId} + + + Nodes: {compData.nodeCount} + + + + {subtypeOptions.length < 2 + ? + + {subtypeVal} + + : + subtypeSelector()} + + + {selectCompCheckbox()} + + + + ) + } + + return ( + + {isMobile ? mobileDisplay() : desktopDisplay()} + + ) +} \ No newline at end of file diff --git a/src/device/autoDetect/OneWire/ScannedOneWirePort.tsx b/src/device/autoDetect/OneWire/ScannedOneWirePort.tsx new file mode 100644 index 0000000..e035660 --- /dev/null +++ b/src/device/autoDetect/OneWire/ScannedOneWirePort.tsx @@ -0,0 +1,57 @@ +import { Box, MenuItem, TextField, Typography } from "@mui/material" +import { extension, getFriendlyName, getSubtypes, Subtype } from "pbHelpers/ComponentType" +import { pond } from "protobuf-ts/pond" +import { quack } from "protobuf-ts/quack" +import React, { useEffect, useState } from "react" +import PortComponent from "./PortComponent" +import { Component } from "models" + +interface Props { + scan: pond.DetectOneWire + componentSelectionCallback: (newComponents: Component[], checked: boolean) => void +} + +export default function ScannedOneWirePort(props: Props){ + const {scan, componentSelectionCallback} = props + const [portComponents, setPortComponents] = useState([]) + + useEffect(()=>{ + setPortComponents(scan.settings?.oneWireData?.components ?? []) + },[scan]) + + const componentSelected = (checked: boolean, componentData: quack.OneWireComponentData) => { + let newComponents: Component[] = [] + //use the component data to create a new Component + //build a component with given information and defaults for the rest + let primaryComponent = Component.create() + primaryComponent.settings.type = componentData.type + primaryComponent.settings.subtype = componentData.subtype + primaryComponent.settings.address = scan.settings?.oneWireData?.port ?? 0 + primaryComponent.settings.addressType = quack.AddressType.ADDRESS_TYPE_CONFIGURABLE_PIN_ARRAY + primaryComponent.settings.name = getFriendlyName(componentData.type, componentData.subtype) + + //need to check if the component is chainable, meaning the address type will reflect the cable id + let ext = extension(componentData.type, componentData.subtype) + if(ext.isChainable){ + primaryComponent.settings.addressType = componentData.cableId + 8 + } + newComponents.push(primaryComponent) + + componentSelectionCallback(newComponents, checked) + } + + return ( + + {portComponents.length > 0 ? portComponents.map((compData, i) => { + return ( + { + componentSelected(checked, component) + }}/> + ) + }): + + No Components Found + } + + ) +} \ No newline at end of file diff --git a/src/fans/fans_client.json b/src/fans/fans_client.json index 58f4040..cdc21f4 100644 --- a/src/fans/fans_client.json +++ b/src/fans/fans_client.json @@ -3125,6 +3125,14 @@ "rpm": 1750, "diameter": 0, "horsepower": 30 + }, + { + "id": 445, + "name": "CALDWELL GGL-81011", + "kind": "centrifugal(low speed)", + "rpm": 0, + "diameter": 0, + "horsepower": 10 } ] } diff --git a/src/gate/GateDevice.tsx b/src/gate/GateDevice.tsx index cbf87e6..f7a8746 100644 --- a/src/gate/GateDevice.tsx +++ b/src/gate/GateDevice.tsx @@ -56,55 +56,6 @@ export default function GateDevice(props: Props) { const [lastTemps, setLastTemps] = useState({ t1: 0, t2: 0 }); const [lastPressures, setLastPressures] = useState({ p1: 0, p2: 0 }); - // const StyledToggleButtonGroup = withStyles(theme => ({ - // grouped: { - // margin: theme.spacing(-0.5), - // border: "none", - // padding: theme.spacing(1), - // "&:not(:first-child):not(:last-child)": { - // borderRadius: 24, - // marginRight: theme.spacing(0.5), - // marginLeft: theme.spacing(0.5) - // }, - // "&:first-child": { - // borderRadius: 24, - // marginLeft: theme.spacing(0.25) - // }, - // "&:last-child": { - // borderRadius: 24, - // marginRight: theme.spacing(0.25) - // } - // }, - // root: { - // backgroundColor: darken( - // theme.palette.background.paper, - // getThemeType() === "light" ? 0.05 : 0.25 - // ), - // borderRadius: 24, - // content: "border-box" - // } - // }))(ToggleButtonGroup); - - // const StyledToggle = withStyles({ - // root: { - // backgroundColor: "transparent", - // overflow: "visible", - // content: "content-box", - // "&$selected": { - // backgroundColor: "gold", - // color: "black", - // borderRadius: 24, - // fontWeight: "bold" - // }, - // "&$selected:hover": { - // backgroundColor: "rgb(255, 255, 0)", - // color: "black", - // borderRadius: 24 - // } - // }, - // selected: {} - // })(ToggleButton); - useEffect(() => { if (comprehensiveDevice.device) { setDevice(Device.any(comprehensiveDevice.device)); @@ -141,7 +92,7 @@ export default function GateDevice(props: Props) { c.status.measurement[0].values[0] && c.status.measurement[0].values[0].values[1] ) { - setPCAFanOn(c.status.measurement[0].values[0].values[1] > 250); //apx 1 iwg + setPCAFanOn(c.status.measurement[0].values[0].values[1] > 996); //apx 4 iwg } break; default: @@ -338,11 +289,11 @@ export default function GateDevice(props: Props) { return ( - + - + ([]); const [eventsLoading, setEventsLoading] = useState(false); - const eventThreshold = 5; - const idleFlow = 2.44; + //these two constants could be entered by the user at time of retrieval + const eventThreshold = 5; //the threshold that if crossed from one measurement to the next during a flow event will end the current flow event and start a new one + const idleFlow = 3.5; //the mass air flow that must be crossed in order to start a flow event, anything below this will not start an event but must go below this to end an event useEffect(() => { if (loadingChartData) return; @@ -102,7 +103,6 @@ export default function GateFlowGraph(props: Props) { timestamp: time.valueOf(), value: val.airFlow ?? 0 }; - data.push(newPoint); if (!recent || recent.timestamp < newPoint.timestamp) { recent = newPoint; diff --git a/src/gate/GateGraphs.tsx b/src/gate/GateGraphs.tsx index 911d525..40c47d8 100644 --- a/src/gate/GateGraphs.tsx +++ b/src/gate/GateGraphs.tsx @@ -27,6 +27,7 @@ import React, { useEffect, useState } from "react"; import { avg } from "utils"; import GateFlowGraph from "./GateFlowGraph"; import { makeStyles } from "@mui/styles"; +import { cloneDeep } from "lodash"; interface Props { gate: Gate; @@ -245,7 +246,9 @@ export default function GateGraphs(props: Props) { const graphHeader = (comp: Component) => { const componentIcon = GetComponentIcon(comp.settings.type, comp.settings.subtype, themeType); - + console.log(comp) + let measurement = cloneDeep(comp.status.measurement) + let umArray = measurement.map(um => UnitMeasurement.create(um, user)) return ( UnitMeasurement.create(um, user)) ?? [] - )} + reading={UnitMeasurement.convertLastMeasurement(umArray)} /> } /> diff --git a/src/gate/GateSVG.tsx b/src/gate/GateSVG.tsx index 64b47d4..6e9e669 100644 --- a/src/gate/GateSVG.tsx +++ b/src/gate/GateSVG.tsx @@ -333,7 +333,7 @@ export default function GateSVG(props: Props) { const deviceCheckIn = () => { return ( - + Last Checked In: {moment(checkInTime).fromNow()} @@ -342,7 +342,7 @@ export default function GateSVG(props: Props) { return ( - + void; updateMarker: (key: string, objectSettings: pond.BinYardSettings) => void; moveMap: (long: number, lat: number) => void; + binClicked: (bin: Bin) => void } export default function BinYardDrawer(props: Props) { - const { open, onClose, selectedYard, yards, removeMarker, updateMarker, moveMap } = props; + const { open, onClose, selectedYard, yards, removeMarker, updateMarker, moveMap, binClicked } = props; const [{as}] = useGlobalState(); const [yard, setYard] = useState(pond.BinYardSettings.create()); const [openMarkerSettings, setOpenMarkerSettings] = useState(false); @@ -85,7 +87,12 @@ export default function BinYardDrawer(props: Props) { const drawerBody = () => { //return ; - return ; + return ( + + ) }; /** diff --git a/src/maps/mapObjectControllers/AgMapController.tsx b/src/maps/mapObjectControllers/AgMapController.tsx index 752992d..71e91a5 100644 --- a/src/maps/mapObjectControllers/AgMapController.tsx +++ b/src/maps/mapObjectControllers/AgMapController.tsx @@ -1659,6 +1659,18 @@ import { Result } from "@mapbox/mapbox-gl-geocoder"; { + //move the map to the correct bin location, assuming the bin was plotted on the map + let location = bin.location() + if(location !== null && location !== undefined){ + moveMap(location.latitude, location.longitude, zoomLevels.close, isMobile) + } + //close the yarddrawer + setYardDrawer(false) + //open the bin drawer using the correct bin + setObjectKey(bin.key()) + setBinDrawer(true) + }} onClose={() => { if (new Date().valueOf() > endTime) { setYardDrawer(false); diff --git a/src/models/Device.ts b/src/models/Device.ts index 2577618..f4ea4ee 100644 --- a/src/models/Device.ts +++ b/src/models/Device.ts @@ -64,6 +64,21 @@ const featureVersions: Map = new Map([ v2CellBlue: "2.1.7", v2EthBlue: "2.1.7" } + ],[ + "detectOneWire", + { + photon: "N/A", + electron: "N/A", + v2Wifi: "N/A", + v2Cell: "N/A", + v2WifiS3: "N/A", + v2CellS3: "N/A", + v2CellBlack: "2.1.10", + v2CellGreen: "N/A", + v2WifiBlue: "2.1.10", + v2CellBlue: "2.1.10", + v2EthBlue: "2.1.10" + } ] ]); export class Device { @@ -166,6 +181,59 @@ export class Device { return max } + private versionComparison(deviceVersion: string, requiredVersion: string): boolean { + const parse = (v: string) => { + const [core, pre] = v.split("-"); + const parts = core.split(".").map(n => parseInt(n, 10)); + + let preLabel = ""; + let preNum = 0; + + if (pre) { + const match = pre.match(/^([a-zA-Z]+)(\d*)$/); + if (match) { + preLabel = match[1].toLowerCase(); + preNum = match[2] ? parseInt(match[2], 10) : 0; + } + } + + return { parts, preLabel, preNum }; + }; + + const rank = (label: string): number => { + switch (label) { + case "alpha": return 1; + case "beta": return 2; + case "rc": return 3; + default: return 0; // release + } + }; + + const a = parse(deviceVersion); + const b = parse(requiredVersion); + + // Compare major/minor/patch + const len = Math.max(a.parts.length, b.parts.length); + for (let i = 0; i < len; i++) { + const av = a.parts[i] ?? 0; + const bv = b.parts[i] ?? 0; + if (av > bv) return true; + if (av < bv) return false; + } + + // Handle prerelease vs release + if (!a.preLabel && b.preLabel) return true; // release > prerelease + if (a.preLabel && !b.preLabel) return false; + + // Both prereleases → compare rank + if (a.preLabel !== b.preLabel) { + return rank(a.preLabel) >= rank(b.preLabel); + } + + // Same prerelease label → compare numeric suffix + return a.preNum >= b.preNum; + } + public featureSupported(feature: string): boolean { let versions = featureVersions.get(feature); if (!versions) { @@ -173,27 +241,27 @@ export class Device { } switch (this.settings.platform) { case pond.DevicePlatform.DEVICE_PLATFORM_PHOTON: - return this.status.firmwareVersion >= versions.photon; + return this.versionComparison(this.status.firmwareVersion, versions.photon); case pond.DevicePlatform.DEVICE_PLATFORM_ELECTRON: - return this.status.firmwareVersion >= versions.electron; + return this.versionComparison(this.status.firmwareVersion, versions.electron); case pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR: - return this.status.firmwareVersion >= versions.v2Cell; + return this.versionComparison(this.status.firmwareVersion, versions.v2Cell); case pond.DevicePlatform.DEVICE_PLATFORM_V2_WIFI: - return this.status.firmwareVersion >= versions.v2Wifi; + return this.versionComparison(this.status.firmwareVersion, versions.v2Wifi); case pond.DevicePlatform.DEVICE_PLATFORM_V2_WIFI_S3: - return this.status.firmwareVersion >= versions.v2WifiS3; + return this.versionComparison(this.status.firmwareVersion, versions.v2WifiS3); case pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR_S3: - return this.status.firmwareVersion >= versions.v2CellS3; + return this.versionComparison(this.status.firmwareVersion, versions.v2CellS3); case pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR_BLACK: - return this.status.firmwareVersion >= versions.v2CellBlack; + return this.versionComparison(this.status.firmwareVersion, versions.v2CellBlack); case pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR_GREEN: - return this.status.firmwareVersion >= versions.v2CellGreen; + return this.versionComparison(this.status.firmwareVersion, versions.v2CellGreen); case pond.DevicePlatform.DEVICE_PLATFORM_V2_WIFI_BLUE: - return this.status.firmwareVersion >= versions.v2WifiBlue; + return this.versionComparison(this.status.firmwareVersion, versions.v2WifiBlue); case pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR_BLUE: - return this.status.firmwareVersion >= versions.v2CellBlue; + return this.versionComparison(this.status.firmwareVersion, versions.v2CellBlue); case pond.DevicePlatform.DEVICE_PLATFORM_V2_ETHERNET_BLUE: - return this.status.firmwareVersion >= versions.v2EthBlue; + return this.versionComparison(this.status.firmwareVersion, versions.v2EthBlue); default: return false; } 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..8546363 --- /dev/null +++ b/src/objects/objectInteractions/Alerts.tsx @@ -0,0 +1,89 @@ +import { Add, ExpandMore } from "@mui/icons-material"; +import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Grid2, IconButton, 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"; +import { Option } from "common/SearchSelect"; + +export interface Alert { + conditions: pond.InteractionCondition[]; + sourceComponents: Component[]; +} + +interface Props { + alerts: Alert[] + permissions: pond.Permission[] + addNew?: () => void +} + +export default function Alerts(props: Props){ + const {alerts, addNew, permissions} = 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 + + {addNew && + + } + + {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..0346c87 --- /dev/null +++ b/src/objects/objectInteractions/Controls.tsx @@ -0,0 +1,152 @@ +import { Add, ExpandMore } from "@mui/icons-material"; +import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Divider, IconButton, List, ListItem, ListSubheader, Menu, MenuItem, Typography } from "@mui/material"; +import { Component, Device, Interaction } from "models"; +import { interactionResultText } from "pbHelpers/Interaction"; +import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; +import { pond, quack } 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 { + devices: Device[] + controls: Control[] + permissions: pond.Permission[] + addNew?: (device: Device) => void +} + +export default function Controls(props: Props){ + const { devices, controls, addNew, permissions } = props + const [deviceControls, setDeviceControls] = useState>(new Map()) + const [openDeviceMenu, setOpenDeviceMenu] = useState(false) + const [anchor, setAnchor] = useState(null) + + + useEffect(()=>{ + let map: 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]) + } + }) + setDeviceControls(map) + },[controls]) + + const readableConditions = (control: Control) => { + let conditions: string = "" + let firstSource = control.sourceComponents[0] + control.conditions.forEach((condition, index) => { + let describer = describeMeasurement(condition.measurementType, firstSource.type(), firstSource.subType()); + 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); + conditions = conditions + (index !== 0 ? " and " : "") + type + " " + comparison + " " + value + describer.GetUnit() + }) + return conditions + }; + + const controlAccordion = (control: Control) => { + let tempInteraction = Interaction.create() + tempInteraction.settings.result = control.result + let resultText = interactionResultText(tempInteraction, control.sinkComponent) + + return ( + + }> + + + {resultText}: + + + {readableConditions(control)} + + + + + + Controlling Components + {control.sourceComponents.map((comp, i) => ( + {comp.name()} + ))} + + + + ) + } + + const deviceMenu = () => { + return ( + { + setAnchor(null) + setOpenDeviceMenu(false) + }}> + {devices.map(dev => ( + { + setAnchor(null) + setOpenDeviceMenu(false) + if (addNew) addNew(dev) + + }}> + {dev.name()} + + ))} + + ) + } + + return ( + + {deviceMenu()} + + + + Controls + + {addNew && + + } + + {devices.map(device => ( + + {device.name()} + + {deviceControls.get(device.id()) ? deviceControls.get(device.id())?.map((control, i) => ( + {controlAccordion(control)} + )) + : + No Control Interactions For Device + } + + ))} + + + ) +} \ No newline at end of file diff --git a/src/objects/ObjectAlerts.tsx b/src/objects/objectInteractions/NewObjectInteraction.tsx similarity index 51% rename from src/objects/ObjectAlerts.tsx rename to src/objects/objectInteractions/NewObjectInteraction.tsx index ef443dc..8e76c25 100644 --- a/src/objects/ObjectAlerts.tsx +++ b/src/objects/objectInteractions/NewObjectInteraction.tsx @@ -1,71 +1,40 @@ -import { - Accordion, - AccordionDetails, - AccordionSummary, - Box, - Button, - Card, - Checkbox, - DialogActions, - DialogContent, - DialogTitle, - FormControl, - FormControlLabel, - FormHelperText, - FormLabel, - Grid2 as Grid, - IconButton, - InputAdornment, - List, - ListItem, - ListItemIcon, - ListItemText, - ListSubheader, - MenuItem, - Select, - TextField, - Theme, - Tooltip, - Typography -} from "@mui/material"; -import { ExpandMore, RemoveCircle as RemoveIcon, AddCircle as AddIcon } from "@mui/icons-material"; +import { Button, Checkbox, DialogActions, DialogContent, DialogTitle, FormControl, FormControlLabel, FormHelperText, FormLabel, Grid2, IconButton, InputAdornment, List, ListItem, ListItemIcon, ListItemText, MenuItem, Select, Switch, TextField, Theme, Tooltip, Typography } from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import classNames from "classnames"; import ResponsiveDialog from "common/ResponsiveDialog"; import { Option } from "common/SearchSelect"; -import classNames from "classnames"; -import { Component, Interaction } from "models"; -import moment from "moment"; -import { getFriendlyName, getMeasurements } from "pbHelpers/ComponentType"; -import { Measurement, Operator } from "pbHelpers/Enums"; -import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; +import { capitalize, cloneDeep } from "lodash"; +import { Component, Device, Interaction } from "models"; +import { extension, getMeasurements } from "pbHelpers/ComponentType"; +import { ComponentType, Measurement, Operator } from "pbHelpers/Enums"; +import { describeMeasurement, MeasurementDescriber } from "pbHelpers/MeasurementDescriber"; import { pond } from "protobuf-ts/pond"; import { quack } from "protobuf-ts/quack"; -import { useGlobalState, useInteractionsAPI, useSnackbar } from "providers"; -import { useNotificationAPI } from "providers/pond/notificationAPI"; -import React, { useEffect, useState } from "react"; -import { capitalize, cloneDeep } from "lodash"; +import React from "react"; +import { useEffect, useState } from "react"; +import AddIcon from "@mui/icons-material/AddCircle"; +import RemoveIcon from "@mui/icons-material/RemoveCircle"; +import { green, red } from "@mui/material/colors"; +import { or } from "utils"; +import moment from "moment"; import { timeOfDayDescriptor } from "pbHelpers/Interaction"; import { TimePicker } from "@mui/x-date-pickers"; -import { getThemeType } from "theme/themeType"; -import { green, red } from "@mui/material/colors"; -import { makeStyles } from "@mui/styles"; - - +import { useInteractionsAPI, useSnackbar } from "hooks"; +import { useGlobalState } from "providers"; interface Props { - linkedComponents: Map; - componentDevices: Map; - objectType: pond.ObjectType; - objectKey: string; -} - -interface Alert { - conditions: pond.InteractionCondition[]; - components: Component[]; + open: boolean + onClose: (refreshCallback: boolean) => void + linkedComponents: Map + componentDevices: Map + //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?: Device } const useStyles = makeStyles((theme: Theme) => { - return ({ - borderedContainer: { + return ( + { + borderedContainer: { padding: theme.spacing(1), marginBottom: theme.spacing(2), border: "1px solid rgba(255, 255, 255, 0.12)", @@ -80,176 +49,104 @@ const useStyles = makeStyles((theme: Theme) => { noPadding: { padding: 0 }, + forPrompt: { + fontSize: "0.875em", + marginTop: "10px" + }, timeRange: { fontSize: "0.875em", marginTop: "20px" }, - dark: { - backgroundColor: getThemeType() === "light" ? "rgb(245, 245, 245)" : "rgb(50, 50, 50)", - padding: 5 - }, - light: { - backgroundColor: getThemeType() === "light" ? "rgb(235, 235, 235)" : "rgb(60, 60, 60)", - padding: 5 } - }); -}); + ) +}) -export default function ObjectAlerts(props: Props) { - const { linkedComponents, componentDevices, objectKey, objectType } = props; - const classes = useStyles(); - const { openSnack } = useSnackbar(); +export default function NewObjectInteraction(props: Props){ + const { open, onClose, device, linkedComponents, componentDevices } = props + const classes = useStyles() const [{ as }] = useGlobalState(); + const {openSnack} = useSnackbar(); const interactionsAPI = useInteractionsAPI(); - const [interactions, setInteractions] = useState([]); - const [typeOptions, setTypeOptions] = useState([]); - const [alerts, setAlerts] = useState([]); - //the interaction key to the component it is set on - const [interactionComponent, setInteractionComponent] = useState>(new Map()); - - //state variables for notifications - const notificationAPI = useNotificationAPI(); - const [recentNotifications, setRecentNotifications] = useState([]); - const [notificationsLoading, setNotificationsLoading] = useState(false); - const [totalNotifications, setTotalNotifications] = useState(0); - - //stat variables for adding new alerts - const [newAlertOpen, setNewAlertOpen] = useState(false); - const [selectedAlertComponents, setSelectedAlertComponents] = useState([]); const [newAlertComponentType, setNewAlertComponentType] = useState( quack.ComponentType.COMPONENT_TYPE_INVALID ); const [conditions, setConditions] = useState([]); + const [selectedAlertComponents, setSelectedAlertComponents] = useState([]); + const [validOptions, setValidOptions] = useState([]) + const [validComponents, setValidComponents] = useState([]) + //condition value strings - so that decimals are easier to type into the field + const [valStrings, setValStrings] = useState(["0", "0"]); + const [numConditions, setNumConditions] = useState(0) const [nodeOptions, setNodeOptions] = useState([]); const [subtypeDropdown, setSubtypeDropdown] = useState(0); - //condition value strings - so that decimals are easier to type into the field - const [valStrings, setValStrings] = useState(["0", "0"]); - //the nodes for the subnode interactions - const [nodeOne, setNodeOne] = useState(0); - const [nodeTwo, setNodeTwo] = useState(0); - + const [nodeOne, setNodeOne] = useState(0) + const [nodeTwo, setNodeTwo] = useState(0) + const [maxConditions, setMaxConditions] = useState(2) + const [resultType, setResultType] = useState(quack.InteractionResultType.INTERACTION_RESULT_TYPE_REPORT) + const [sinkMotor, setSinkMotor] = useState(false) + const [selectedSink, setSelectedSink] = useState("")//the key of the component to be the sink + const [sinkOptions, setSinkOptions] = useState([]) + const [resultMode, setResultMode] = useState(0) + const [resultValue, setResultValue] = useState("") + const [dutyCycleEnabled, setDutyCycleEnabled] = useState(false) + const [dutyCycle, setDutyCycle] = useState(""); + const [reporting, setReporting] = useState(true) + const [notify, setNotify] = useState(true) //schedule variables const [schedule, setSchedule] = useState( pond.InteractionSchedule.create({ weekdays: ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] }) ); + + useEffect(() => { - //loop through the linked components to get each of their interactions - //TODO-CS: consider making an api function to get the interactions for multiple components, possibly based off objects, in one call, - //to get all interactions for bin components this would solve the issue of making multiple calls as well as not having to use async await - let newInteractions: Interaction[] = []; - let icMap: Map = new Map(); - let includedOps: quack.ComponentType[] = []; - let typeOps: Option[] = []; - let index = 0; - //use async to make sure to wait for the response from the api call and have the interactions set before going to get the next one - //this prevents seperate responses from assigning to the array without knowing about interactions from those seperate responses - //for example: without async two responses come back at the same time, one has two interactions and another has one but, neither have put their interactions - //into the array yet so they both reference an empty array, the first response puts its two into an empty array and sets it but then the next one also puts - //its response into an empty array as well and sets it causing the first two to be lost - linkedComponents.forEach(async (comp, key) => { - index++; - //get the interactions for the component - let device = componentDevices.get(key); - if (device !== undefined) { - await interactionsAPI.listInteractionsByComponent(device, comp.location(), undefined, as).then(resp => { - if (resp.length > 0) { - resp.forEach(interaction => { - icMap.set(interaction.key(), key); - }); - newInteractions = newInteractions.concat(resp); - } - }); - } - //set the state variables if it is the last one in the list - if (index === linkedComponents.size) { - setInteractionComponent(icMap); - setInteractions([...newInteractions]); - } - //use the component type to the type options if it is not already present - if (!includedOps.includes(comp.type())) { - includedOps.push(comp.type()); - typeOps.push({ - label: getFriendlyName(comp.type()), - value: comp.type() - }); - } - }); - setTypeOptions(typeOps); - }, [linkedComponents, interactionsAPI, componentDevices]); + //if the device is passed in need to filter out possible components + let validCompList: Component[] = [] + let validOptions: Option[] = [] + let sinkOptions: Component[] = [] - const matchConditions = (interaction: Interaction, alert: Alert) => { - //if the number of conditions are not the same they are different - if (interaction.settings.conditions.length !== alert.conditions.length) { - return false; + if(device){ + setMaxConditions(device.maxConditions()) } - //continure with the comparison - let matching = true; - interaction.settings.conditions.forEach((condition, i) => { - if ( - condition.comparison !== alert.conditions[i].comparison || - condition.measurementType !== alert.conditions[i].measurementType || - condition.value !== alert.conditions[i].value - ) { - matching = false; - } - }); - return matching; - }; - - //build the alerts to display - useEffect(() => { - //loop through the interactions - let currentAlerts: Alert[] = []; - interactions.forEach(interaction => { - //if the interaction sends notifications - if (interaction.settings.notifications && interaction.settings.notifications.notify) { - //determine if the interaction already has an alert in the alerts array - let similarAlert: number = -1; - currentAlerts.forEach((alert, i) => { - if (matchConditions(interaction, alert)) { - similarAlert = i; - } - }); - - //if no current alert matched the conditions add a new alert to the array - let compKey = interactionComponent.get(interaction.key()); - if (compKey) { - let component = linkedComponents.get(compKey); - if (component) { - if (similarAlert === -1) { - let newAlert: Alert = { - conditions: interaction.settings.conditions, - components: [component] - }; - currentAlerts.push(newAlert); - } else { - currentAlerts[similarAlert].components.push(component); - } - } + + linkedComponents.forEach((comp, key) => { + if(componentDevices.get(key) === device?.id() || !device){ + let ext = extension(comp.type(), comp.subType()) + if(ext.isController){ + sinkOptions.push(comp) } + validCompList.push(comp) + } + }) + + let included: quack.ComponentType[] = [] + validCompList.forEach(comp => { + if(included.includes(comp.type())) return + let ext = extension(comp.type(), comp.subType()) + if(device){ + if(!ext.isController){ + included.push(comp.type()) + validOptions.push({ + label: ext.friendlyName, + value: comp.type() + }) + } + }else{ + included.push(comp.type()) + validOptions.push({ + label: ext.friendlyName, + value: comp.type() + }) } - }); - setAlerts(currentAlerts); - }, [interactions, interactionComponent, linkedComponents]); - //get the notifications for the components on an object - useEffect(() => { - if (notificationsLoading) return; - setNotificationsLoading(true); - notificationAPI - .listObjectNotifications(objectKey, objectType, 10, 0, undefined, undefined, undefined, as) - .then(resp => { - setRecentNotifications(resp.data.notifications); - setTotalNotifications(resp.data.total); - }) - .catch(_err => {}) - .finally(() => { - setNotificationsLoading(false); - }); - }, [objectKey, objectType, notificationAPI]); //eslint-disable-line react-hooks/exhaustive-deps + }) + + setValidOptions(validOptions) + setValidComponents(validCompList) + setSinkOptions(sinkOptions) + },[device, linkedComponents, componentDevices]) //use the selected components to determine the lowest amount of nodes for the options useEffect(() => { @@ -257,7 +154,7 @@ export default function ObjectAlerts(props: Props) { setNodeTwo(0); setSubtypeDropdown(0); let nodeCount = 12; //start with the maximum number of nodes for a cable - linkedComponents.forEach(comp => { + validComponents.forEach(comp => { if (selectedAlertComponents.includes(comp.key())) { if (comp.lastMeasurement[0] && comp.lastMeasurement[0].values[0]) { let nodes = comp.lastMeasurement[0].values[0].values.length; @@ -274,112 +171,53 @@ export default function ObjectAlerts(props: Props) { ); } setNodeOptions(options); - }, [linkedComponents, selectedAlertComponents]); + }, [validComponents, selectedAlertComponents]); + - const loadMoreNotifications = () => { - console.log("load more"); - let current = recentNotifications; - setNotificationsLoading(true); - notificationAPI - .listObjectNotifications( - objectKey, - objectType, - 10, - current.length, - undefined, - undefined, - undefined, - as - ) - .then(resp => { - if (resp.data.notifications.length > 0) { - let c = current.concat(resp.data.notifications); - setRecentNotifications([...c]); - } - }) - .catch(_err => {}) - .finally(() => { - setNotificationsLoading(false); - }); + const close = (refresh: boolean) => { + //reset the state variables to their default values + setNewAlertComponentType(quack.ComponentType.COMPONENT_TYPE_INVALID); + setConditions([]); + setSelectedAlertComponents([]); + setValidOptions([]) + setValidComponents([]) + setValStrings(["0", "0"]); + setNumConditions(0) + setNodeOptions([]); + setSubtypeDropdown(0); + setNodeOne(0) + setNodeTwo(0) + setMaxConditions(2) + setResultType(quack.InteractionResultType.INTERACTION_RESULT_TYPE_REPORT) + setSinkMotor(false) + setSelectedSink("")//the key of the component to be the sink + setSinkOptions([]) + setResultMode(0) + setResultValue("") + setDutyCycleEnabled(false) + setDutyCycle(""); + setReporting(true) + setNotify(true) + onClose(refresh) + } + const availableMeasurementTypes = (type: quack.ComponentType): quack.MeasurementType[] => { + return getMeasurements(type).map(m => m.measurementType); }; - - 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.components.map((comp, i) => ( - {comp.name()} - ))} - - - - ); - }; - - const addInteractionToComponents = () => { - //array of device and component ids ie, [4:9-5-12, 4:9-5-13, 3:9-5-12] - let compIds: string[] = []; - selectedAlertComponents.forEach(comp => { - let devID = componentDevices.get(comp); - let component = linkedComponents.get(comp); - if (devID && component) { - compIds.push(devID + ":" + component.locationString()); - } + + 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 }); - let newAlert = pond.InteractionSettings.create(); - newAlert.conditions = conditions; - newAlert.instance = 1; - newAlert.schedule = schedule; - newAlert.subtype = Interaction.create().subtypeFromNodes(nodeOne, nodeTwo); - newAlert.result = pond.InteractionResult.create({ - type: quack.InteractionResultType.INTERACTION_RESULT_TYPE_REPORT - }); - newAlert.notifications = pond.InteractionNotifications.create({ - notify: true - }); - interactionsAPI - .addInteractionToComponents(compIds, newAlert, as) - .then(_resp => { - openSnack("interaction added to selected components"); - }) - .catch(_err => { - openSnack("there was a problem adding the interaction to the selected components"); - }); - setNewAlertOpen(false); + return [condition]; }; //display the components that match the type that was selected for the new alert const listMatchingComponents = () => { let matching: Component[] = []; - linkedComponents.forEach(comp => { + validComponents.forEach(comp => { if (comp.type() === newAlertComponentType) { matching.push(comp); } @@ -409,20 +247,6 @@ export default function ObjectAlerts(props: Props) { ); }; - 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]; - }; - const measurementTypeMenuItems = () => { return availableMeasurementTypes(newAlertComponentType).map(measurementType => ( @@ -443,18 +267,18 @@ export default function ObjectAlerts(props: Props) { let isBoolean = condition.measurementType === Measurement.boolean; return ( - {index > 0 && ( - + AND - + )} - + {measurementTypeMenuItems()} - - + + - - + + ]} - - - {index === 0 ? ( - - 1} - aria-label="Add another condition" - onClick={() => { - let c = cloneDeep(conditions); - let newConditions = c.concat(initialConditions(newAlertComponentType)); - setConditions(newConditions); - }} - className={classNames(classes.greenButton, classes.noPadding)}> - - - - ) : ( - - { - let c = conditions; - c.splice(index, 1); - setConditions([...c]); - }} - className={classNames(classes.redButton, classes.noPadding)}> - - - - )} - - + + + + { + conditions.splice(index, 1) + setNumConditions(conditions.length) + }} + className={classNames(classes.redButton, classes.noPadding)}> + + + + + {nodeOptions.length > 2 && ( - - + - - + + {(subtypeDropdown === 2 || subtypeDropdown === 3 || subtypeDropdown === 4) && ( )} - - + + {subtypeDropdown === 3 && ( )} - - + + )} ); }; const conditionInput = () => { - // const conditionGroups: JSX.Element[] = []; - // for (let i = 0; i < conditions.length; i++) { - // conditionGroups[i] = conditionGroup(i); - // } return ( You must select a source before adding conditions )} + + = maxConditions} + aria-label="Add another condition" + onClick={() => { + let newConditions = conditions.concat(initialConditions(newAlertComponentType)); + setConditions(newConditions); + setNumConditions(newConditions.length) + }} + className={classNames(classes.greenButton, classes.noPadding)}> + + + ); }; + const sinkSelector = () => { + return ( + { + setSelectedSink(e.target.value) + setSinkMotor(linkedComponents.get(e.target.value)?.type() === ComponentType.stepperMotor) + }} + fullWidth + autoFocus={false} + margin="dense" + variant="standard" + InputLabelProps={{ shrink: true }}> + + Select Controller + + {sinkOptions.map(comp => ( + + {comp.name()} + + ))} + + ); + } + + const describeSink = (): MeasurementDescriber => { + const sink = linkedComponents.get(selectedSink); + return describeMeasurement( + Measurement.boolean, + or(sink, Component.create()).settings.type, + or(sink, Component.create()).settings.subtype + ); + }; + + const dutyCycleInput = () => { + return ( + + + { + setDutyCycleEnabled(checked) + }} + value="enableDutyCycle" + color="secondary" + /> + } + label="Once every" + /> + + + { + setDutyCycle(e.target.value) + }} + fullWidth + autoFocus={false} + margin="dense" + variant="standard" + disabled={!dutyCycleEnabled} + error={isNaN(Number(dutyCycle)) || Number(dutyCycle) < 0} + InputLabelProps={{ + shrink: true + }} + InputProps={{ + endAdornment: seconds + }}> + + + ); + }; + + const notificationInput = () => { + return ( + + {resultType !== quack.InteractionResultType.INTERACTION_RESULT_TYPE_REPORT && ( + + { + setReporting(checked) + }} + value="notificationReports" + color="secondary" + /> + } + label="Report" + /> + + )} + + { + setNotify(checked) + }} + value="interactionNotification" + color="secondary" + /> + } + label="Notify" + disabled={!reporting} + /> + + {notify && ( + + + Everyone with notifications enabled for this device will receive an email and/or SMS + each time this result occurs + + + )} + + ); + }; + + const resultInput = () => { + return ( + + Result + + + { + setResultType(+e.target.value as quack.InteractionResultType) + }} + autoFocus={false} + margin="dense" + variant="standard" + fullWidth + InputLabelProps={{ shrink: true }}> + Report + Set + Toggle + Run + + + {resultType === quack.InteractionResultType.INTERACTION_RESULT_TYPE_RUN && ( + + + {sinkSelector()} + + {sinkMotor && ( + + { + setResultMode(+e.target.value) + }} + fullWidth + autoFocus={false} + margin="dense" + variant="standard" + InputLabelProps={{ shrink: true }}> + + Clockwise + + + Counter Clockwise + + + + )} + + + for + + + + { + setResultValue(e.target.value) + }} + autoFocus={false} + margin="dense" + variant="standard" + fullWidth + InputProps={{ + endAdornment: sec + }} + InputLabelProps={{ shrink: true }} + /> + + + )} + {resultType === quack.InteractionResultType.INTERACTION_RESULT_TYPE_SET && ( + + + {sinkSelector()} + + {linkedComponents.get(selectedSink)?.type() !== + quack.ComponentType.COMPONENT_TYPE_INTERNAL_FUNCTION && ( + + + to + + + )} + {linkedComponents.get(selectedSink)?.type() !== + quack.ComponentType.COMPONENT_TYPE_INTERNAL_FUNCTION && ( + + { + setResultValue(e.target.value) + }} + fullWidth + autoFocus={false} + margin="dense" + variant="standard" + InputLabelProps={{ shrink: true }}> + {extension(linkedComponents.get(selectedSink)?.type() ?? quack.ComponentType.COMPONENT_TYPE_INVALID).states.map((state, i) => ( + + {state} + + ))} + + + )} + + )} + {resultType === quack.InteractionResultType.INTERACTION_RESULT_TYPE_TOGGLE && ( + + + {sinkSelector()} + + + { + if(checked){ + setResultValue('1') + }else{ + setResultValue('0') + } + }} + color="secondary" + /> + } + label={describeSink().enumerations()[isNaN(parseFloat(resultValue))?0:parseFloat(resultValue)]} + /> + + + )} + {resultType === quack.InteractionResultType.INTERACTION_RESULT_TYPE_RUN && + dutyCycleInput()} + {notificationInput()} + + + ); + }; + + const toggleDaySelected = (day: string, event: any) => { + let updatedSchedule = cloneDeep(schedule); + if (event.target.checked) { + if (!updatedSchedule.weekdays.includes(day)) { + updatedSchedule.weekdays.push(day); + } + } else { + updatedSchedule.weekdays.splice(updatedSchedule.weekdays.indexOf(day), 1); + } + setSchedule(updatedSchedule); + }; + + const daySelector = (day: string, size: any) => { + //const schedule = or(interaction.settings.schedule, pond.InteractionSchedule.create()); + return ( + + toggleDaySelected(day, event)} + value={day} + color="secondary" + /> + } + label={capitalize(day.substr(0, 2))} + labelPlacement="bottom" + /> + + ); + }; + const setScheduleTime = (id: string, event: any) => { let updatedSchedule = cloneDeep(schedule); if (id === "shortcut") { @@ -732,39 +870,6 @@ export default function ObjectAlerts(props: Props) { setSchedule(updatedSchedule); }; - const toggleDaySelected = (day: string, event: any) => { - let updatedSchedule = cloneDeep(schedule); - if (event.target.checked) { - if (!updatedSchedule.weekdays.includes(day)) { - updatedSchedule.weekdays.push(day); - } - } else { - updatedSchedule.weekdays.splice(updatedSchedule.weekdays.indexOf(day), 1); - } - setSchedule(updatedSchedule); - }; - - const daySelector = (day: string, size: any) => { - //const schedule = or(interaction.settings.schedule, pond.InteractionSchedule.create()); - return ( - - toggleDaySelected(day, event)} - value={day} - color="secondary" - /> - } - label={capitalize(day.substr(0, 2))} - labelPlacement="bottom" - /> - - ); - }; - const scheduler = () => { //let schedule = pond.InteractionSchedule.create(); let timezone = moment.tz.guess(); @@ -789,7 +894,7 @@ export default function ObjectAlerts(props: Props) { return ( Schedule - + {daySelector("sunday", 3)} {daySelector("monday", 3)} {daySelector("tuesday", 3)} @@ -797,9 +902,9 @@ export default function ObjectAlerts(props: Props) { {daySelector("thursday", 4)} {daySelector("friday", 4)} {daySelector("saturday", 4)} - - - + + + - + {showStart && ( - + } value={start} onChange={(event: any) => setScheduleTime("start", event)} /> - + )} {showBoth && ( - + to - + )} {showEnd && ( - + } value={end} onChange={(event: any) => setScheduleTime("end", event)} /> - + )} - + ); }; - const newAlertDialog = () => { - return ( + const addInteractionToComponents = () => { + //array of device and component ids ie, [4:9-5-12, 4:9-5-13, 3:9-5-12] + let compIds: string[] = []; + selectedAlertComponents.forEach(comp => { + let devID = componentDevices.get(comp); + let component = linkedComponents.get(comp); + if (devID && component) { + compIds.push(devID + ":" + component.locationString()); + } + }); + let newInteraction = pond.InteractionSettings.create(); + newInteraction.conditions = conditions; + newInteraction.instance = 1; + newInteraction.schedule = schedule; + newInteraction.subtype = Interaction.create().subtypeFromNodes(nodeOne, nodeTwo); + newInteraction.sink = linkedComponents.get(selectedSink)?.location() + newInteraction.result = pond.InteractionResult.create({ + type: resultType, + mode: resultMode, + value: isNaN(parseFloat(resultValue)) ? 0 : parseFloat(resultValue), + dutyCycle: isNaN(parseFloat(dutyCycle)) ? 0 : parseFloat(dutyCycle) + }); + newInteraction.notifications = pond.InteractionNotifications.create({ + reports: reporting, + notify: reporting && notify + }); + interactionsAPI + .addInteractionToComponents(compIds, newInteraction, as) + .then(_resp => { + openSnack("interaction added to selected components"); + }) + .catch(_err => { + openSnack("there was a problem adding the interaction to the selected components"); + }).finally(() => { + close(true) + }); + }; + + return ( { - setNewAlertOpen(false); + close(false); }}> Set New Alert @@ -882,25 +1024,23 @@ export default function ObjectAlerts(props: Props) { Select Component Type - {typeOptions.map(option => ( + {validOptions.map(option => ( {option.label} ))} - {/* list of checkboxes for the components that match that type */} {listMatchingComponents()} - {/* have the conditions set here */} {conditionInput()} - {/* have the schedule set here */} + {device && resultInput()} {scheduler()} - - ); - }; - - const alertDisplay = () => { - return ( - - Alerts - {alerts.map((alert, i) => ( - {alertAccordion(alert)} - ))} - - ); - }; - - const notificationList = () => { - return ( - - Notifications - {recentNotifications && ( - - {recentNotifications.map((notification, i) => ( - - - {moment(notification.status?.timestamp).format("MMMM DD, YYYY - h:mm a")} - {" "} - :{" "} - - {notification.settings?.title + " - " + notification.settings?.subtitle} - - - ))} - {recentNotifications.length < totalNotifications && ( - - - - )} - - )} - - ); - }; - - return ( - - - Alerts & Notifications - - - {alertDisplay()} - {notificationList()} - {newAlertDialog()} - - ); -} + ) +} \ 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..6cc65e8 --- /dev/null +++ b/src/objects/objectInteractions/Notifications.tsx @@ -0,0 +1,111 @@ +import { Button, List, ListItem, ListSubheader, Typography } from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import moment from "moment"; +import { pond } from "protobuf-ts/pond"; +import { useGlobalState, useNotificationAPI } from "providers"; +import React, { useEffect, useRef, useState } from "react"; +import { getThemeType } from "theme/themeType"; + +interface Props { + objectKey: string + objectType: pond.ObjectType +} + +const useStyles = makeStyles(() => { + return ({ + dark: { + backgroundColor: getThemeType() === "light" ? "rgb(245, 245, 245)" : "rgb(50, 50, 50)", + padding: 5 + }, + light: { + backgroundColor: getThemeType() === "light" ? "rgb(235, 235, 235)" : "rgb(60, 60, 60)", + padding: 5 + }, + }) +}) + +export default function Notifications(props: Props){ + const {objectKey, objectType} = props + const classes = useStyles(); + const [recentNotifications, setRecentNotifications] = useState([]); + const [notificationsLoading, setNotificationsLoading] = useState(false) + const loadingRef = useRef(false) + const [{as}] = useGlobalState(); + const notificationAPI = useNotificationAPI(); + const [totalNotifications, setTotalNotifications] = useState(0) + + + //get the notifications for the components on an object + useEffect(() => { + if (loadingRef.current) return + loadingRef.current = true + notificationAPI + .listObjectNotifications(objectKey, objectType, 10, 0, undefined, undefined, undefined, as) + .then(resp => { + setRecentNotifications(resp.data.notifications); + setTotalNotifications(resp.data.total); + }) + .catch(_err => {}) + .finally(() => { + loadingRef.current = true; + }); + }, [objectKey, objectType, notificationAPI]); + + const loadMoreNotifications = () => { + let current = recentNotifications; + setNotificationsLoading(true); + notificationAPI + .listObjectNotifications( + objectKey, + objectType, + 10, + current.length, + undefined, + undefined, + undefined, + as + ) + .then(resp => { + if (resp.data.notifications.length > 0) { + let c = current.concat(resp.data.notifications); + setRecentNotifications([...c]); + } + }) + .catch(_err => {}) + .finally(() => { + setNotificationsLoading(false); + }); + }; + + return ( + + + + Notifications + + + {recentNotifications && ( + + {recentNotifications.map((notification, i) => ( + + + {moment(notification.status?.timestamp).format("MMMM DD, YYYY - h:mm a")} + {" "} + :{" "} + + {notification.settings?.title + " - " + notification.settings?.subtitle} + + + ))} + {recentNotifications.length < totalNotifications && ( + + + + )} + + )} + + ); +} \ No newline at end of file diff --git a/src/objects/objectInteractions/ObjectInteractions.tsx b/src/objects/objectInteractions/ObjectInteractions.tsx new file mode 100644 index 0000000..d50adb9 --- /dev/null +++ b/src/objects/objectInteractions/ObjectInteractions.tsx @@ -0,0 +1,229 @@ +import { Box, Button, Card, Typography } from "@mui/material"; +import { useCallback, 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"; +import Notifications from "./Notifications"; +import { pond } from "protobuf-ts/pond"; +import React from "react"; +import NewObjectInteraction from "./NewObjectInteraction"; + +interface Props { + linkedComponents: Map; //the component key to the component object + componentDevices: Map; + objectKey: string + objectType: pond.ObjectType + devices: Device[]; + permissions: pond.Permission[] +} + +export default function ObjectInteractions(props: Props) { + const { linkedComponents, componentDevices, devices, objectKey, objectType, permissions } = 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 [openNewInteraction, setOpenNewInteraction] = useState(false) + const [allSinks, setAllSinks] = useState([]); + // const [typeOptions, setTypeOptions] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(undefined) + + + 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 + } + } + } + + const load = useCallback(()=>{ +const newInteractions: Interaction[] = []; + const interactionSourceMap = new Map(); + 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 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); + } + } + } + }); + setAllSinks(sinkOptions); + setAlerts(alerts); + setControls(controls); + }).catch(err => { + console.error("Interaction fetch error:", err) + }) + },[linkedComponents, interactionsAPI, componentDevices, as]) + + useEffect(() => { + load() + }, [load]); + + + + return ( + + { + setOpenNewInteraction(false) + if(refresh){ + load() + } + }} + linkedComponents={linkedComponents} + componentDevices={componentDevices} + device={selectedDevice}/> + + + Interactions + { + setSelectedDevice(device) + setOpenNewInteraction(true) + }}/> + { + setSelectedDevice(undefined) + setOpenNewInteraction(true) + }}/> + + + + + ) +} \ No newline at end of file diff --git a/src/pages/Bin.tsx b/src/pages/Bin.tsx index 1fb51fb..cc667d8 100644 --- a/src/pages/Bin.tsx +++ b/src/pages/Bin.tsx @@ -50,11 +50,9 @@ import { GrainCable } from "models/GrainCable"; // import { Controller } from "models/Controller"; import { Pressure } from "models/Pressure"; import { CheckCircle, ExpandMore, Warning } from "@mui/icons-material"; -import { getThemeType } from "theme"; import BinGraphs from "bin/graphs/BinGraphs"; import BinTour from "bin/BinTour"; import { getBinModel } from "common/DataImports/BinCables/BinCableImporter"; -import ObjectAlerts from "objects/ObjectAlerts"; import DevicePresetController from "device/presets/devicePresetController"; import { DevicePreset } from "models/DevicePreset"; import BinVisualizerV2 from "bin/BinVisualizerV2"; @@ -69,6 +67,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; @@ -196,6 +195,7 @@ export default function Bin(props: Props) { const [headspaceCO2, setHeadspaceCO2] = useState([]); const [heaters, setHeaters] = useState([]); const [fans, setFans] = useState([]); + const [activeDetails, setActiveDetails] = useState([0]) const [detail, setDetail] = useState< "inventory" | "sensors" | "analytics" | "presets" | "alerts" | "transactions" >("inventory"); @@ -806,7 +806,7 @@ export default function Bin(props: Props) { {overview()} {preferences && binComponents(preferences)} - + {(bin.settings.mode === pond.BinMode.BIN_MODE_DRYING || bin.settings.mode === pond.BinMode.BIN_MODE_HYDRATING || bin.settings.mode === pond.BinMode.BIN_MODE_COOLDOWN) && ( @@ -825,82 +825,66 @@ export default function Bin(props: Props) { - - - + + setDetail("inventory") + function: () => { + setDetail("inventory") + setActiveDetails([0]) + } }, { title: "Sensors", - function: () => setDetail("sensors") + function: () => { + setDetail("sensors") + setActiveDetails([1]) + } }, { title: "Analysis", - function: () => setDetail("analytics") + function: () => { + setDetail("analytics") + setActiveDetails([2]) + } }, { title: "Alerts", - function: () => setDetail("alerts") + function: () => { + setDetail("alerts") + setActiveDetails([3]) + } }, { title: "Presets", - function: () => setDetail("presets") + function: () => { + setDetail("presets") + setActiveDetails([4]) + } }, { title: "Transactions", - function: () => setDetail("transactions") + function: () => { + setDetail("transactions") + setActiveDetails([5]) + } } ]} /> - {/* - setDetail("inventory")}> - Inventory - - setDetail("sensors")} - value={"sensors"} - aria-label="Bin Sensor Graphs"> - Sensors - - setDetail("analytics")} - value={"analytics"} - aria-label="Bin Analysis Graphs"> - Analysis - - setDetail("alerts")} - value={"alerts"} - aria-label="Device Alerts"> - Alerts - - setDetail("presets")} - value={"presets"} - aria-label="Bin Mode Presets"> - Presets - - */} {detail === "alerts" && ( - )} @@ -916,23 +900,27 @@ export default function Bin(props: Props) { /> )} - {(detail === "inventory" || detail === "sensors" || detail === "analytics") && ( - - )} {detail === "transactions" && + + } + {(detail === "inventory" || detail === "sensors" || detail === "analytics") && ( + + + + )} @@ -1033,11 +1021,13 @@ export default function Bin(props: Props) { - @@ -1175,7 +1165,11 @@ export default function Bin(props: Props) { {objectTeams()} {deviceMenu()} - + { + setDetail(detail) + setActiveDetails([buttonIndex]) + }}/> ); } diff --git a/src/pages/Bins.tsx b/src/pages/Bins.tsx index 085d382..f17125e 100644 --- a/src/pages/Bins.tsx +++ b/src/pages/Bins.tsx @@ -199,6 +199,7 @@ export default function Bins(props: Props) { const [cardValDisplay, setCardValDisplay] = useState<"high" | "low" | "average">("high"); const [teamsDialog, setTeamsDialog] = useState(false) + const snackbar = useSnackbar() // const [scrollTranslations, setScrollTranslations] = useState({ // bins: 0, @@ -265,6 +266,8 @@ export default function Bins(props: Props) { // console.log("should?") setTeamsDialog(true) } + }).catch(err => { + snackbar.error("error listing teams: "+err) }) } }, [doneLoadingPage, grainBins, grainBags]) diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 5acc7aa..ee4ae56 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -1,4 +1,4 @@ -import { Button, Card, Divider, List, ListItem, Typography } from "@mui/material"; +import { Box, Button, Card, Divider, List, ListItem, Typography } from "@mui/material"; import Grid from '@mui/material/Grid2'; import { Component, Device, Interaction, User } from "models"; import { getContextKeys, getContextTypes } from "pbHelpers/Context"; @@ -158,9 +158,9 @@ export default function DevicePage() { if (length > 0) { warningString = warningString + " through " + getContextTypes()[length - 1]; } - snackbar.warning(warningString); + snackbar.warning("page data warning: "+warningString); } else { - snackbar.error(err); + snackbar.error("page data error: "+err); } }) .finally(() => setLoading(false)); @@ -169,12 +169,12 @@ export default function DevicePage() { const loadPortScan = () => { deviceAPI.listFoundComponents(parseInt(deviceID)) .then(resp => { - if (resp.data.foundComponents?.i2c){ - setScannedAddresses(resp.data.foundComponents ?? undefined) + if (resp.data.foundComponents?.i2c || resp.data.foundComponents?.oneWire){ + setScannedAddresses(pond.ListFoundComponentsResponse.fromObject(resp.data).foundComponents ?? undefined) } }) .catch(err => { - console.log(err) + console.log("load port scan: "+err) }) } @@ -411,7 +411,16 @@ export default function DevicePage() { /> {/* add grid card here for I2C detected components */} - + {(scannedAddresses?.i2c || scannedAddresses?.oneWire) && + + + + } {diagnosticComponents.map(comp => ( - {scannedAddresses && - + {(scannedAddresses?.i2c || scannedAddresses?.oneWire) && + (globalState.user); useEffect(() => { setLoading(true) @@ -55,6 +58,124 @@ export default function SignupCallback () { return loading === false && doneBins && doneTeams && doneDevices } + const changePressureUnit = (value: any) => { + let updatedUser = User.clone(user) + let pressureUnit: pond.PressureUnit; + switch (value) { + case 1: + pressureUnit = pond.PressureUnit.PRESSURE_UNIT_KILOPASCALS; + break; + case 2: + pressureUnit = pond.PressureUnit.PRESSURE_UNIT_INCHES_OF_WATER; + break; + default: + pressureUnit = pond.PressureUnit.PRESSURE_UNIT_UNKNOWN; + break; + } + updatedUser.settings.pressureUnit = pressureUnit; + setUser(updatedUser) + }; + + const changeTemperatureUnit = (value: any) => { + let updatedUser = User.clone(user); + let temperatureUnit: pond.TemperatureUnit; + switch (value) { + case 1: + temperatureUnit = pond.TemperatureUnit.TEMPERATURE_UNIT_CELSIUS; + break; + case 2: + temperatureUnit = pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT; + break; + default: + temperatureUnit = pond.TemperatureUnit.TEMPERATURE_UNIT_UNKNOWN; + break; + } + updatedUser.settings.temperatureUnit = temperatureUnit; + setUser(updatedUser); + }; + + const unitPreferences = () => { + const { pressureUnit, temperatureUnit, distanceUnit, grainUnit } = user.settings; + return ( + + + changePressureUnit(event.target.value)} + margin="normal" + variant="outlined" + InputLabelProps={{ shrink: true }}> + + Kilopascals (kPa) + + + Inches of Water (iwg) + + + + + changeTemperatureUnit(event.target.value)} + margin="normal" + variant="outlined" + InputLabelProps={{ shrink: true }}> + Celsius (°C) + + Fahrenheit (°F) + + + + + changeDistanceUnit(event.target.value)} + margin="normal" + variant="outlined" + InputLabelProps={{ shrink: true }}> + Meters (m) + Feet (ft) + + + {IsAdaptiveAgriculture() && ( + + changeGrainUnit(event.target.value)} + margin="normal" + variant="outlined" + InputLabelProps={{ shrink: true }}> + Bushels (bu) + Tonnes (mT) + + + )} + + ); + }; + const content = () => { return ( @@ -104,12 +225,17 @@ export default function SignupCallback () { content: ( <> - In the user menu, you can make changes to your profile, and adjust unit preferences. + In the user menu, you can make changes to your profile, such as adjusting unit preferences.
If you are part of a team, you can select it here as well. +
+ + Set your unit preferences here now, they can be changed later from this menu: + + {unitPreferences()} ), target: "#tour-user-menu", @@ -150,35 +276,104 @@ export default function SignupCallback () { target: "#tour-dashboard", placement: "right" }) - if (IsAdaptiveAgriculture()) steps.push({ - title: "Bins", + steps.push({ + title: "Tasks", content: ( <> - You can view your bins and their inventory here. + You can view and create tasks here and assign a start and end time for them.
- If you are viewing as a team, your team's bins will be displayed here instead. + If you are viewing as a team you will see the teams tasks, you can even assign tasks to team members. ), - target: "#tour-bins", - placement: "right", - disableBeacon: true, + target: "#tour-tasks", + placement: "right" }) + //tasks step + if (IsAdaptiveAgriculture()) { + steps.push({ + title: "Visual Farm", + content: ( + <> + + You can view your Visual Farm here, it will allow tou to draw fields and plot bins an a map. + +
+ + If you are viewing as a team, your team's fields and bins will be displayed here instead. + + + ), + target: "#tour-visual-farm", + placement: "right", + disableBeacon: true, + }) + steps.push({ + title: "Fields", + content: ( + <> + + You can view your fields you have drawn on the map here. + +
+ + If you are viewing as a team, your team's fields will be displayed here instead. + + + ), + target: "#tour-field-list", + placement: "right", + disableBeacon: true, + }) + steps.push({ + title: "Bins", + content: ( + <> + + You can view your bins and their inventory here. + +
+ + If you are viewing as a team, your team's bins will be displayed here instead. + + + ), + target: "#tour-bins", + placement: "right", + disableBeacon: true, + }) + steps.push({ + title: "Contracts", + content: ( + <> + + You can view and track any contracts you have created here. + +
+ + If you are viewing as a team, your team's contracts will be displayed here instead. + + + ), + target: "#tour-contracts", + placement: "right", + disableBeacon: true, + }) + } return steps; }; const endTour = () => { setIsTourRunning(false); - // if (user) { - // user.status.finishedBinIntro = moment().toJSON(); - // userAPI - // .updateUser(userID, user.protobuf()) - // .then(() => dispatch({ key: "user", value: user })) - // .catch((err: any) => error(err)); - // } + if (user) { + userAPI + .updateUser(user.id(), user.protobuf()) + .then(() => dispatch({ key: "user", value: user })) + .catch((err: any) => console.error(err)); + } }; const closeDialog = () => { diff --git a/src/pbHelpers/ComponentType.tsx b/src/pbHelpers/ComponentType.tsx index 60f14cd..03be638 100644 --- a/src/pbHelpers/ComponentType.tsx +++ b/src/pbHelpers/ComponentType.tsx @@ -168,6 +168,7 @@ export interface ComponentTypeExtension { //if the map does not exist then just use the array from the device availability, //if the map does exist filter the availability after claims to find matching address between them and use that subtypeI2CMap?: Map + isChainable?: boolean } export interface Summary { diff --git a/src/pbHelpers/ComponentTypes/CapacitorCable.ts b/src/pbHelpers/ComponentTypes/CapacitorCable.ts index cc56e27..8843952 100644 --- a/src/pbHelpers/ComponentTypes/CapacitorCable.ts +++ b/src/pbHelpers/ComponentTypes/CapacitorCable.ts @@ -68,6 +68,7 @@ export function CapacitorCable(subtype: number = 0): ComponentTypeExtension { isSource: true, isArray: true, isCalibratable: false, + isChainable: true, addressTypes: addressTypes, interactionResultTypes: [], states: [], diff --git a/src/pbHelpers/ComponentTypes/GrainCable.ts b/src/pbHelpers/ComponentTypes/GrainCable.ts index c715611..bacc514 100644 --- a/src/pbHelpers/ComponentTypes/GrainCable.ts +++ b/src/pbHelpers/ComponentTypes/GrainCable.ts @@ -176,6 +176,7 @@ export function GrainCable(subtype: number = 0): ComponentTypeExtension { isSource: true, isArray: true, isCalibratable: false, + isChainable: true, addressTypes: [quack.AddressType.ADDRESS_TYPE_CONFIGURABLE_PIN_ARRAY, quack.AddressType.ADDRESS_TYPE_I2C], interactionResultTypes: [], states: [], diff --git a/src/pbHelpers/ComponentTypes/PressureCable.ts b/src/pbHelpers/ComponentTypes/PressureCable.ts index f03cc9e..3a22362 100644 --- a/src/pbHelpers/ComponentTypes/PressureCable.ts +++ b/src/pbHelpers/ComponentTypes/PressureCable.ts @@ -64,6 +64,7 @@ export function PressureCable(subtype: number = 0): ComponentTypeExtension { isArray: true, hasFan: true, isCalibratable: false, + isChainable: true, addressTypes: [quack.AddressType.ADDRESS_TYPE_CONFIGURABLE_PIN_ARRAY], interactionResultTypes: [], states: [], diff --git a/src/providers/pond/deviceAPI.tsx b/src/providers/pond/deviceAPI.tsx index fbaefc0..241781b 100644 --- a/src/providers/pond/deviceAPI.tsx +++ b/src/providers/pond/deviceAPI.tsx @@ -23,6 +23,7 @@ export interface IDeviceAPIContext { ) => Promise>; getMulti: (ids: number[] | string[], otherTeam?: string) => Promise>; detectI2C: (id: number, otherTeam?: string) => Promise>; + detectOneWire: (id: number, port: number, otherTeam?: string) => Promise> listFoundComponents: (id: number, otherTeam?: string) => Promise> getGeoJson: (id: number | string, demo?: boolean, otherTeam?: string) => Promise; getMultiGeoJson: (ids: number[] | string[], otherTeam?: string) => Promise; @@ -148,7 +149,8 @@ export interface IDeviceAPIContext { keys?: string[], types?: string[] ) => Promise; - removeFoundComponents: (id: number, key: string, otherTeam?: string) => Promise>; + removeAllFoundComponents: (id: number,otherTeam?: string) => Promise>; + removeFoundComponents: (id: number, key: string, type: pond.ObjectType, otherTeam?: string) => Promise>; } export const DeviceAPIContext = createContext({} as IDeviceAPIContext); @@ -962,6 +964,19 @@ export default function DeviceProvider(props: PropsWithChildren) { }) } + const detectOneWire = (id: number, port: number, otherTeam?: string) => { + let url = "/devices/" + id + "/detectOneWire?port=" + port; + const view = otherTeam ? otherTeam : as + if(view) url = url + "?as=" + view + return new Promise>((resolve, reject) => { + put(pondURL(url)).then(resp => { + return resolve(resp) + }).catch(err => { + return reject(err) + }) + }) + } + const listFoundComponents = (id: number, otherTeam?: string) => { let url = "/devices/" + id + "/listScannedComponents"; const view = otherTeam ? otherTeam : as @@ -975,10 +990,10 @@ export default function DeviceProvider(props: PropsWithChildren) { }) } - const removeFoundComponents = (id: number, key: string, otherTeam?: string) => { - let url = "/devices/" + id + "/removeFoundComponents/" + key; + const removeFoundComponents = (id: number, key: string, type: pond.ObjectType, otherTeam?: string) => { + let url = "/devices/" + id + "/removeFoundComponents/" + key + "?scanType=" + type; const view = otherTeam ? otherTeam : as - if(view) url = url + "?as=" + view + if(view) url = url + "&as=" + view return new Promise>((resolve, reject) => { del(pondURL(url)).then(resp => { return resolve(resp) @@ -988,6 +1003,19 @@ export default function DeviceProvider(props: PropsWithChildren) { }) } + const removeAllFoundComponents = (id: number, otherTeam?: string) => { + let url = "/devices/" + id + "/removeAllFoundComponents"; + const view = otherTeam ? otherTeam : as + if(view) url = url + "?as=" + view + return new Promise>((resolve, reject) => { + del(pondURL(url)).then(resp => { + return resolve(resp) + }).catch(err => { + return reject(err) + }) + }) + } + return ( ) { updateComponentPreferences, listDeviceComponentPreferences, detectI2C, + detectOneWire, listFoundComponents, - removeFoundComponents + removeFoundComponents, + removeAllFoundComponents }}> {children}