diff --git a/src/navigation/Router.tsx b/src/navigation/Router.tsx
index 4c3f5a3..b85a237 100644
--- a/src/navigation/Router.tsx
+++ b/src/navigation/Router.tsx
@@ -16,6 +16,7 @@ import ConstructionSiteMap from "pages/ConstructionSiteMap";
import Sites from "pages/Sites";
import Site from "pages/Site";
import Heaters from "pages/Heaters";
+import Heater from "pages/Heater";
//import Site from "pages/Site";
const DeviceHistory = lazy(() => import("pages/DeviceHistory"));
@@ -215,14 +216,14 @@ export default function Router(props: Props) {
path="" // "/settings/basic"
element={}
/>
- {/* }
+ element={}
/>
}
- /> */}
+ />
);
diff --git a/src/objectHeater/ObjectHeaterActions.tsx b/src/objectHeater/ObjectHeaterActions.tsx
new file mode 100644
index 0000000..06357ab
--- /dev/null
+++ b/src/objectHeater/ObjectHeaterActions.tsx
@@ -0,0 +1,210 @@
+import {
+ IconButton,
+ ListItemIcon,
+ ListItemText,
+ Menu,
+ MenuItem,
+ Tooltip
+} from "@mui/material";
+import SettingsIcon from "@mui/icons-material/Settings";
+import MoreIcon from "@mui/icons-material/MoreVert";
+import React, { useState } from "react";
+import ObjectHeaterSettings from "./ObjectHeaterSettings";
+import { ObjectHeater } from "models/ObjectHeater";
+import ObjectUsersIcon from "@mui/icons-material/AccountCircle";
+import ObjectTeamsIcon from "@mui/icons-material/SupervisedUserCircle";
+import ObjectUsers from "user/ObjectUsers";
+import { pond } from "protobuf-ts/pond";
+import { Component, Scope } from "models";
+import ObjectTeams from "teams/ObjectTeams";
+import RemoveSelfFromObject from "user/RemoveSelfFromObject";
+import ShareObject from "user/ShareObject";
+import { blue } from "@mui/material/colors";
+import RemoveSelfIcon from "@mui/icons-material/ExitToApp";
+import { Share } from "@mui/icons-material";
+import { makeStyles } from "@mui/styles";
+
+const useStyles = makeStyles(() => ({
+ shareIcon: {
+ color: blue["500"],
+ "&:hover": {
+ color: blue["600"]
+ }
+ },
+ removeIcon: {
+ color: "var(--status-alert)"
+ },
+ red: {
+ color: "var(--status-alert)"
+ },
+ blueIcon: {
+ color: blue["500"],
+ "&:hover": {
+ color: blue["600"]
+ }
+ }
+ })
+);
+interface OpenState {
+ users: boolean;
+ teams: boolean;
+ settings: boolean;
+ removeSelf: boolean;
+ share: boolean;
+}
+
+interface Props {
+ heater: ObjectHeater;
+ componentsByDevice?: Map;
+ refreshCallback: () => void;
+ permissions: pond.Permission[];
+ immediateUpdate: (updatedHeater: ObjectHeater) => void;
+}
+
+export default function ObjectHeaterActions(props: Props) {
+ const classes = useStyles();
+ const { heater, refreshCallback, permissions, componentsByDevice, immediateUpdate } = props;
+ const [anchorEl, setAnchorEl] = React.useState(null);
+ const [openState, setOpenState] = useState({
+ users: false,
+ teams: false,
+ settings: false,
+ removeSelf: false,
+ share: false
+ });
+
+ const groupMenu = () => {
+ return (
+
+ );
+ };
+
+ const dialogs = () => {
+ const key = heater.key;
+ const label = heater.name;
+ return (
+
+ {
+ if (newHeater) {
+ immediateUpdate(newHeater);
+ }
+ setOpenState({ ...openState, settings: false });
+ }}
+ />
+ setOpenState({ ...openState, share: false })}
+ />
+ setOpenState({ ...openState, users: false })}
+ refreshCallback={refreshCallback}
+ />
+ setOpenState({ ...openState, teams: false })}
+ refreshCallback={refreshCallback}
+ />
+ {
+ setOpenState({ ...openState, removeSelf: false });
+ }}
+ />
+
+ );
+ };
+
+ return (
+
+ {permissions.includes(pond.Permission.PERMISSION_WRITE) && (
+
+ setOpenState({ ...openState, settings: true })}>
+
+
+
+ )}
+ ) => setAnchorEl(event.currentTarget)}>
+
+
+ {dialogs()}
+ {groupMenu()}
+
+ );
+}
diff --git a/src/objectHeater/ObjectHeaterCharts.tsx b/src/objectHeater/ObjectHeaterCharts.tsx
new file mode 100644
index 0000000..8a93de6
--- /dev/null
+++ b/src/objectHeater/ObjectHeaterCharts.tsx
@@ -0,0 +1,621 @@
+import {
+ Avatar,
+ Box,
+ Card,
+ CardContent,
+ CardHeader,
+ Grid2 as Grid,
+ Theme,
+ Typography,
+ useTheme
+} from "@mui/material";
+import { useDeviceAPI, useGlobalState, useMutationAPI } from "providers";
+import { Component, Device } from "models";
+import moment from "moment";
+import { pond } from "protobuf-ts/pond";
+import { quack } from "protobuf-ts/quack";
+import { GetDefaultDateRange } from "common/time/DateRange";
+import React, { useEffect, useState } from "react";
+import MultiLineGraph, { LineData } from "charts/measurementCharts/MultiLineGraph";
+import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
+import DateSelect from "common/time/DateSelect";
+import {
+ Area,
+ AreaChart,
+ ReferenceArea,
+ ResponsiveContainer,
+ Tooltip,
+ TooltipProps,
+ XAxis,
+ YAxis
+} from "recharts";
+import FuelDark from "assets/components/fuelDark.png";
+import FuelLight from "assets/components/fuelLight.png";
+import { useThemeType } from "hooks";
+import { GetComponentIcon } from "pbHelpers/ComponentType";
+import UnitMeasurementSummary from "component/UnitMeasurementSummary";
+import { UnitMeasurement } from "models/UnitMeasurement";
+import MaterialChartTooltip from "charts/MaterialChartTooltip";
+import { roundTo } from "utils";
+import { makeStyles } from "@mui/styles";
+//import TimeBar from "common/time/TimeBar";
+
+interface Props {
+ device: pond.ComprehensiveDevice;
+ linearMutations: pond.LinearMutation[];
+ xDomain: string[] | number[];
+ changeXDomain: (domain: string[] | number[]) => void;
+ getFuelLevel?: (level: string) => void;
+}
+
+interface GraphData {
+ time: number;
+ value: number;
+}
+
+interface MutationData {
+ mutation: pond.LinearMutation;
+ data: pond.ValueAt[];
+}
+
+const useStyles = makeStyles((theme: Theme) => ({
+ avatarIcon: {
+ width: theme.spacing(3),
+ height: theme.spacing(3)
+ },
+ card: {
+ height: 550
+ },
+ cardHeader: {
+ height: "20%"
+ },
+ cardContent: {
+ height: "80%"
+ }
+ })
+);
+
+export default function ObjectHeaterCharts(props: Props) {
+ const { device, linearMutations, getFuelLevel, xDomain, changeXDomain } = props;
+ const deviceAPI = useDeviceAPI();
+ const mutationAPI = useMutationAPI();
+ const [tempHum, setTempHum] = useState();
+ const [ambientTemps, setAmbientTemps] = useState([]);
+ const [heaterTemps, setHeaterTemps] = useState([]);
+ const [heaterState, setHeaterState] = useState([]);
+ const [tempOnly, setTempOnly] = useState();
+ const [heater, setHeater] = useState();
+ const [start, setStart] = useState(GetDefaultDateRange().start);
+ const [end, setEnd] = useState(GetDefaultDateRange().end);
+ const now = moment();
+ const theme = useTheme();
+ const [mutationData, setMutationData] = useState([]);
+ const [{ user }] = useGlobalState();
+ const themeType = useThemeType();
+ const classes = useStyles();
+ const [mutationsLoading, setMutationsLoading] = useState(false);
+ const [refLeft, setRefLeft] = useState();
+ const [refRight, setRefRight] = useState();
+
+ useEffect(() => {
+ if (!mutationsLoading) {
+ setMutationsLoading(true);
+ //make call to the backend to get measurements for the mutation
+ let md: MutationData[] = [];
+ linearMutations.forEach(mut => {
+ mutationAPI.linearMutation(mut, start.toISOString(), end.toISOString()).then(resp => {
+ let d: pond.ValueAt[] = resp.data.values;
+ if (mut.mutation === pond.Mutator.MUTATOR_FUEL_LEVEL) {
+ d = [];
+ resp.data.values.forEach(valAt => {
+ d.push(
+ pond.ValueAt.create({
+ time: valAt.time,
+ value: valAt.value / 10
+ })
+ );
+ });
+ }
+ md.push({
+ mutation: mut,
+ data: d
+ });
+ if (md.length >= linearMutations.length) {
+ setMutationsLoading(false);
+ }
+ setMutationData([...md]);
+ if (getFuelLevel && mut.mutation === pond.Mutator.MUTATOR_FUEL_LEVEL) {
+ if (resp.data.values.length > 0) {
+ getFuelLevel(
+ (resp.data.values[resp.data.values.length - 1].value / 10).toFixed(2) + "%"
+ );
+ }
+ }
+ });
+ });
+ }
+ }, [linearMutations, start, end, getFuelLevel, mutationAPI]); //eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ let tempHum: Component | undefined = undefined;
+ let tempOnly: Component | undefined = undefined;
+ let heater: Component | undefined = undefined;
+
+ let dev = Device.any(device.device);
+ let components = device.components;
+ let measurementsFor: Component[] = [];
+ components.forEach(comp => {
+ let c = Component.any(comp);
+ if (c.settings.type === quack.ComponentType.COMPONENT_TYPE_DHT) {
+ tempHum = c;
+ measurementsFor.push(c);
+ }
+ if (c.settings.type === quack.ComponentType.COMPONENT_TYPE_TEMPERATURE) {
+ tempOnly = c;
+ measurementsFor.push(c);
+ }
+ if (c.settings.type === quack.ComponentType.COMPONENT_TYPE_BOOLEAN_OUTPUT) {
+ if (c.subType() === quack.BooleanOutputSubtype.BOOLEAN_OUTPUT_SUBTYPE_HEATER) {
+ heater = c;
+ measurementsFor.push(c);
+ }
+ }
+ });
+ setTempHum(tempHum);
+ setTempOnly(tempOnly);
+ setHeater(heater);
+ deviceAPI
+ .listJSONMeasurements(dev.id(), measurementsFor, start, end, 0, 0, "asc", "timestamp")
+ .then(resp => {
+ resp.data.components.forEach(comp => {
+ if (comp.componentId === tempHum?.key()) {
+ if (comp.measurementType === "Temperature") {
+ let lineData: LineData[] = [];
+ comp.exportedMeasurements.forEach(m => {
+ lineData.push({
+ timestamp: moment(m.timestamp).valueOf(),
+ points: m.measurementValue.map((t, i) => ({ value: convertTemp(t), node: i }))
+ });
+ });
+ setAmbientTemps(lineData);
+ }
+ }
+ if (comp.componentId === tempOnly?.key()) {
+ if (comp.measurementType === "Temperature") {
+ setHeaterTemps(comp.exportedMeasurements);
+ }
+ }
+ if (comp.componentId === heater?.key()) {
+ if (comp.measurementType === "State") {
+ setHeaterState(comp.exportedMeasurements);
+ }
+ }
+ });
+ });
+ }, [device, deviceAPI, start, end]); //eslint-disable-line react-hooks/exhaustive-deps
+
+ const updateDateRange = (newStartDate: any, newEndDate: any, live: boolean) => {
+ setStart(newStartDate);
+ setEnd(newEndDate);
+ // setLive(live);
+ // if (props.updateDate) {
+ // props.updateDate(newStartDate, newEndDate, live);
+ // }
+ };
+
+ const convertTemp = (temp: number) => {
+ let t = temp;
+ if (user.settings.temperatureUnit === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) {
+ t = temp * 1.8 + 32;
+ }
+ return t;
+ };
+
+ //had to make custom chart for this in order to show the heater state and the temp of the ds18
+ const heaterStateWithTemps = (tempComponent: Component, heaterComponent: Component) => {
+ let tempDescriber = describeMeasurement(
+ quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE,
+ tempComponent.type(),
+ tempComponent.subType()
+ );
+ let heaterDescriber = describeMeasurement(
+ quack.MeasurementType.MEASUREMENT_TYPE_BOOLEAN,
+ heaterComponent.type(),
+ heaterComponent.subType()
+ );
+ let tempData: LineData[] = [];
+ heaterTemps.forEach(ht => {
+ tempData.push({
+ timestamp: moment(ht.timestamp).valueOf(),
+ points: ht.measurementValue.map((t, i) => ({ value: convertTemp(t), node: i }))
+ });
+ });
+ let stateData: LineData[] = [];
+ heaterState.forEach(hs => {
+ stateData.push({
+ timestamp: moment(hs.timestamp).valueOf(),
+ points: hs.measurementValue.map((s, i) => ({ value: s, node: i }))
+ });
+ });
+ return (
+
+ {
+ changeXDomain(domain);
+ }}
+ multiGraphZoomOut
+ />
+ {
+ changeXDomain(domain);
+ }}
+ multiGraphZoomOut
+ />
+
+ );
+ };
+
+ //// TODO-CS: Make a mutator describer to put the get functions into ////
+ const getMutationIcon = (mutation: pond.Mutator, themeType: string) => {
+ if (mutation === pond.Mutator.MUTATOR_FUEL_LEVEL) {
+ return themeType === "light" ? FuelDark : FuelLight;
+ }
+ return "";
+ };
+
+ const getMutationLabel = (mutation: pond.Mutator) => {
+ if (mutation === pond.Mutator.MUTATOR_FUEL_LEVEL) {
+ return "Fuel Level";
+ }
+ return "";
+ };
+
+ const getMutationColour = (mutation: pond.Mutator) => {
+ if (mutation === pond.Mutator.MUTATOR_FUEL_LEVEL) {
+ return "lightBlue";
+ }
+ return "";
+ };
+
+ const getMutationUnit = (mutation: pond.Mutator) => {
+ if (mutation === pond.Mutator.MUTATOR_FUEL_LEVEL) {
+ return "%";
+ }
+ return "";
+ };
+
+ //// /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ /\ ////
+
+ const zoom = () => {
+ let newDomain: number[] | string[] = ["dataMin", "dataMax"];
+ if (refLeft && refRight && refLeft !== refRight) {
+ refLeft < refRight ? (newDomain = [refLeft, refRight]) : (newDomain = [refRight, refLeft]);
+ setRefLeft(undefined);
+ setRefRight(undefined);
+ changeXDomain(newDomain);
+ }
+ };
+
+ const mutationGraphCard = (mutation: pond.LinearMutation, data: pond.ValueAt[], key: string) => {
+ let graphData: GraphData[] = [];
+ let lastReading: pond.ValueAt | undefined;
+ data.forEach(d => {
+ graphData.push({
+ time: moment(d.time).valueOf(),
+ value: d.value
+ });
+ lastReading = d;
+ });
+ return (
+
+
+ {graphData.length > 0 ? (
+
+
+ }
+ title={mutation.mutationName}
+ titleTypographyProps={{ variant: "subtitle1" }}
+ subheader={
+
+
+
+
+ {getMutationLabel(mutation.mutation) + ": "}{" "}
+
+ {lastReading?.value.toFixed(2) + getMutationUnit(mutation.mutation)}
+ {" "}
+
+
+
+
+
+
+ {moment(lastReading?.time).fromNow()}
+
+
+
+ }
+ />
+
+
+ {
+ if (e) {
+ setRefLeft(e.activeLabel);
+ }
+ }}
+ onMouseMove={(e: any) => {
+ if (e) {
+ setRefRight(e.activeLabel);
+ }
+ }}
+ onMouseUp={() => {
+ setRefLeft(undefined);
+ setRefRight(undefined);
+ zoom();
+ }}>
+ moment(timestamp).format("lll")}
+ content={(props: TooltipProps) => {
+ return (
+
+ roundTo(parseFloat(String(value)), 2).toString()
+ }
+ />
+ );
+ }}
+ />
+ {
+ let t = moment(timestamp);
+ return now.isSame(t, "day") ? t.format("LT") : t.format("MMM DD");
+ }}
+ scale="time"
+ type="number"
+ tick={{ fill: theme.palette.text.primary }}
+ stroke={theme.palette.divider}
+ interval="preserveStartEnd"
+ />
+
+
+ {refLeft && refRight ? (
+
+ ) : null}
+
+
+
+
+ ) : (
+
+
+ No measurements for selected timeframe
+
+
+ )}
+
+
+ );
+ };
+
+ const mutationGraphs = () => {
+ let graphCards: JSX.Element[] = [];
+ if (mutationData.length === 0) {
+ return (
+
+
+
+ No Calculated Measurements Set
+
+
+
+ );
+ }
+ mutationData.forEach((mutation, i) => {
+ graphCards.push(mutationGraphCard(mutation.mutation, mutation.data, "mutation" + i));
+ });
+ return graphCards;
+ };
+
+ return (
+
+ {/* */}
+
+
+ {tempOnly && heater && (
+
+ {heaterTemps.length > 0 ?? heaterState.length > 0 ? (
+
+
+ {tempOnly && (
+
+
+ }
+ title={tempOnly.name()}
+ subheader={
+ UnitMeasurement.any(um, user))
+ )}
+ />
+ }
+ titleTypographyProps={{ variant: "subtitle1" }}
+ />
+
+ )}
+ {heater && (
+
+
+ }
+ title={heater.name()}
+ subheader={
+ UnitMeasurement.create(um, user))
+ )}
+ />
+ }
+ titleTypographyProps={{ variant: "subtitle1" }}
+ />
+
+ )}
+
+
+
+ {heaterStateWithTemps(tempOnly, heater)}
+
+
+ ) : (
+
+
+ No measurements for selected timeframe
+
+
+ )}
+
+ )}
+
+
+ {tempHum && (
+
+ {ambientTemps.length > 0 ? (
+
+
+ }
+ title={tempHum.name()}
+ subheader={
+ um.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE
+ )
+ .map(um => UnitMeasurement.any(um, user))
+ )}
+ />
+ }
+ titleTypographyProps={{ variant: "subtitle1" }}
+ />
+
+ {
+ changeXDomain(domain);
+ }}
+ multiGraphZoomOut
+ />
+
+
+ ) : (
+
+
+ No measurements for selected timeframe
+
+
+ )}
+
+ )}
+
+ {mutationGraphs()}
+
+
+
+
+ );
+}
diff --git a/src/pages/Heater.tsx b/src/pages/Heater.tsx
new file mode 100644
index 0000000..d4a0fa3
--- /dev/null
+++ b/src/pages/Heater.tsx
@@ -0,0 +1,534 @@
+import {
+ Box,
+ Button,
+ Card,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Grid2 as Grid,
+ Tooltip,
+ Typography
+} from "@mui/material";
+import DeviceLinkDrawer from "common/DeviceLinkDrawer";
+import ObjectControls from "common/ObjectControls";
+import ObjectHeaterActions from "objectHeater/ObjectHeaterActions";
+import { useDeviceAPI, useGlobalState, useObjectHeaterAPI, useUserAPI } from "providers";
+import React, { useEffect, useState } from "react";
+import { useLocation } from "react-router";
+import PageContainer from "./PageContainer";
+import { ObjectHeater } from "models/ObjectHeater";
+//import { MatchParams } from "navigation/Routes";
+import { pond } from "protobuf-ts/pond";
+import { Component, Device, Scope } from "models";
+import { useSnackbar, useThemeType } from "hooks";
+import { quack } from "protobuf-ts/quack";
+import ObjectHeaterCharts from "objectHeater/ObjectHeaterCharts";
+import HeaterDarkIcon from "assets/components/heaterDark.png";
+import HeaterLightIcon from "assets/components/heaterLight.png";
+import Link from "@mui/icons-material/Link";
+import { LinkOff } from "@mui/icons-material";
+import { UnitMeasurement } from "models/UnitMeasurement";
+import TemperatureIcon from "component/TemperatureIcon";
+import FuelIcon from "products/CommonIcons/fuelIcon";
+import moment from "moment";
+import Warning from "@mui/icons-material/Warning";
+import { getTemperatureUnit } from "utils";
+import ResponsiveDialog from "common/ResponsiveDialog";
+import { useParams } from "react-router-dom";
+
+export default function Heater() {
+ const [openLinkDrawer, setOpenLinkDrawer] = useState(false);
+ const { openSnack } = useSnackbar();
+ const userAPI = useUserAPI();
+ const heaterAPI = useObjectHeaterAPI();
+ const deviceAPI = useDeviceAPI();
+ const location = useLocation();
+ //const match = useRouteMatch();
+ //const heaterID = match.params.heaterID;
+ const heaterID = useParams<{ heaterKey: string }>()?.heaterKey ?? "";
+ const [{ as, user }] = useGlobalState();
+ const [permissions, setPermissions] = useState([]);
+ const [heater, setHeater] = useState(ObjectHeater.create());
+ const [loadingDevs, setLoadingDevs] = useState(false);
+ const [devices, setDevices] = useState