import { FC, useState, useContext, useCallback, useEffect } from 'react'; import { Button, Typography, Box, Dialog, DialogTitle, DialogContent, DialogActions, MenuItem, InputAdornment, FormHelperText, IconButton, List, ListItem, ListItemText, Grid, FormControlLabel, Checkbox } from '@mui/material'; import { useSnackbar } from 'notistack'; import { Table } from '@table-library/react-table-library/table'; import { useTheme } from '@table-library/react-table-library/theme'; import { useSort, SortToggleType } from '@table-library/react-table-library/sort'; import { Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table'; import { useRowSelect } from '@table-library/react-table-library/select'; import DownloadIcon from '@mui/icons-material/GetApp'; import RefreshIcon from '@mui/icons-material/Refresh'; import EditIcon from '@mui/icons-material/Edit'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import CancelIcon from '@mui/icons-material/Cancel'; import SendIcon from '@mui/icons-material/TrendingFlat'; import SaveIcon from '@mui/icons-material/Save'; import RemoveIcon from '@mui/icons-material/RemoveCircleOutline'; import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined'; import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined'; import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined'; import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined'; import UnfoldMoreOutlinedIcon from '@mui/icons-material/UnfoldMoreOutlined'; import StarIcon from '@mui/icons-material/Star'; import DeviceIcon from './DeviceIcon'; import { IconContext } from 'react-icons'; import { formatDurationMin, pluralize } from '../utils'; import { AuthenticatedContext } from '../contexts/authentication'; import { ButtonRow, ValidatedTextField, SectionContent, MessageBox } from '../components'; import * as EMSESP from './api'; import { numberValue, updateValue, extractErrorMessage } from '../utils'; import { SensorData, Device, CoreData, DeviceData, DeviceValue, DeviceValueUOM, DeviceValueUOM_s, AnalogType, AnalogTypeNames, Sensor, Analog, DeviceEntityMask } from './types'; const DashboardData: FC = () => { const { me } = useContext(AuthenticatedContext); const { enqueueSnackbar } = useSnackbar(); const [coreData, setCoreData] = useState({ devices: [], active_sensors: 0, analog_enabled: false }); const [deviceData, setDeviceData] = useState({ label: '', data: [] }); const [sensorData, setSensorData] = useState({ sensors: [], analogs: [] }); const [deviceValue, setDeviceValue] = useState(); const [sensor, setSensor] = useState(); const [analog, setAnalog] = useState(); const [deviceDialog, setDeviceDialog] = useState(-1); const [onlyFav, setOnlyFav] = useState(false); const device_theme = useTheme({ BaseRow: ` font-size: 14px; color: white; height: 46px; &:focus { z-index: 2; border-top: 1px solid #177ac9; border-bottom: 1px solid #177ac9; } `, HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; border-bottom: 1px solid #e0e0e0; font-weight: 500; `, Row: ` background-color: #1e1e1e; border-top: 1px solid #565656; border-bottom: 1px solid #565656; position: relative; z-index: 1; &:not(:last-of-type) { margin-bottom: -1px; } &:not(:first-of-type) { margin-top: -1px; } &:hover { z-index: 2; color: white; border-top: 1px solid #177ac9; border-bottom: 1px solid #177ac9; }, &.tr.tr-body.row-select.row-select-single-selected, &.tr.tr-body.row-select.row-select-selected { background-color: #3d4752; color: white; font-weight: normal; z-index: 2; border-top: 1px solid #177ac9; border-bottom: 1px solid #177ac9; } `, BaseCell: ` border-top: 1px solid transparent; border-right: 1px solid transparent; border-bottom: 1px solid transparent; &:nth-of-type(1) { min-width: 42px; width: 42px; div { width: 100%; } } &:nth-of-type(2) { min-width: 120px; width: 120px; } &:nth-of-type(3) { flex: 1; } &:nth-of-type(4) { text-align: center; max-width: 100px; } &:last-of-type { text-align: right; min-width: 64px; } ` }); const data_theme = useTheme({ BaseRow: ` font-size: 14px; color: white; height: 32px; `, HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; border-bottom: 1px solid #e0e0e0; font-weight: 500; `, Row: ` &:nth-of-type(odd) { background-color: #303030; } &:nth-of-type(even) { background-color: #1e1e1e; } border-top: 1px solid #565656; border-bottom: 1px solid #565656; position: relative; z-index: 1; &:not(:last-of-type) { margin-bottom: -1px; } &:not(:first-of-type) { margin-top: -1px; } &:hover { z-index: 2; border-top: 1px solid #177ac9; border-bottom: 1px solid #177ac9; color: white; } `, BaseCell: ` cursor: pointer; border-top: 1px solid transparent; border-right: 1px solid transparent; border-bottom: 1px solid transparent; padding-left: 16px; width: 124px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; &:last-of-type { text-align: right; } `, HeaderCell: ` &:nth-of-type(1) { padding-left: 8px; } &:not(:last-of-type) { border-right: 1px solid #565656; } ` }); const getSortIcon = (state: any, sortKey: any) => { if (state.sortKey === sortKey && state.reverse) { return ; } if (state.sortKey === sortKey && !state.reverse) { return ; } return ; }; const analog_sort = useSort( { nodes: sensorData.analogs }, {}, { sortIcon: { iconDefault: , iconUp: , iconDown: }, sortFns: { GPIO: (array) => array.sort((a, b) => a.g - b.g), NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)), TYPE: (array) => array.sort((a, b) => a.t - b.t) } } ); const sensor_sort = useSort( { nodes: sensorData.sensors }, {}, { sortIcon: { iconDefault: , iconUp: , iconDown: }, sortFns: { NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)), TEMPERATURE: (array) => array.sort((a, b) => a.t - b.t) } } ); const dv_sort = useSort( { nodes: deviceData.data }, {}, { sortIcon: { iconDefault: , iconUp: , iconDown: }, sortToggleType: SortToggleType.AlternateWithReset, sortFns: { NAME: (array) => array.sort((a, b) => a.id.slice(2).localeCompare(b.id.slice(2))) } } ); const device_select = useRowSelect( { nodes: coreData.devices }, { onChange: onSelectChange } ); function onSelectChange(action: any, state: any) { if (action.type === 'ADD_BY_ID_EXCLUSIVELY') { refreshData(); } else { setSensorData({ sensors: [], analogs: [] }); } } const escapeCsvCell = (cell: any) => { if (cell == null) { return ''; } const sc = cell.toString().trim(); if (sc === '' || sc === '""') { return sc; } if (sc.includes('"') || sc.includes(',') || sc.includes('\n') || sc.includes('\r')) { return '"' + sc.replace(/"/g, '""') + '"'; } return sc; }; const makeCsvData = (columns: any, data: any) => { return data.reduce((csvString: any, rowItem: any) => { return csvString + columns.map(({ accessor }: any) => escapeCsvCell(accessor(rowItem))).join(',') + '\r\n'; }, columns.map(({ name }: any) => escapeCsvCell(name)).join(',') + '\r\n'); }; const downloadAsCsv = (columns: any, data: any, filename: string) => { const csvData = makeCsvData(columns, data); const csvFile = new Blob([csvData], { type: 'text/csv' }); const downloadLink = document.createElement('a'); downloadLink.download = filename; downloadLink.href = window.URL.createObjectURL(csvFile); document.body.appendChild(downloadLink); downloadLink.click(); document.body.removeChild(downloadLink); }; const hasMask = (id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask; const handleDownloadCsv = () => { const columns = [ { accessor: (dv: any) => dv.id.slice(2), name: 'Entity' }, { accessor: (dv: any) => dv.v, name: 'Value' }, { accessor: (dv: any) => (dv.u >= 1 && dv.u <= 2 ? 'C' : DeviceValueUOM_s[dv.u]), name: 'UoM' } ]; downloadAsCsv( columns, onlyFav ? deviceData.data.filter((dv) => hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)) : deviceData.data, 'device_entities' ); }; const refreshDataIndex = (selectedDevice: string) => { if (selectedDevice === 'sensor') { fetchSensorData(); return; } setSensorData({ sensors: [], analogs: [] }); if (selectedDevice) { fetchDeviceData(selectedDevice); } else { fetchCoreData(); } }; const refreshData = () => { refreshDataIndex(device_select.state.id); }; const fetchCoreData = useCallback(async () => { try { setCoreData((await EMSESP.readCoreData()).data); } catch (error: unknown) { enqueueSnackbar(extractErrorMessage(error, 'Failed to fetch core data'), { variant: 'error' }); } }, [enqueueSnackbar]); useEffect(() => { fetchCoreData(); }, [fetchCoreData]); useEffect(() => { const timer = setInterval(() => refreshData(), 60000); return () => { clearInterval(timer); }; // eslint-disable-next-line }, [analog, sensor, deviceValue, sensorData]); const fetchDeviceData = async (id: string) => { const unique_id = parseInt(id); try { setDeviceData((await EMSESP.readDeviceData({ id: unique_id })).data); } catch (error: unknown) { enqueueSnackbar(extractErrorMessage(error, 'Problem fetching device data'), { variant: 'error' }); } }; const fetchSensorData = async () => { try { setSensorData((await EMSESP.readSensorData()).data); } catch (error: unknown) { enqueueSnackbar(extractErrorMessage(error, 'Problem fetching sensor data'), { variant: 'error' }); } }; const isCmdOnly = (dv: DeviceValue) => dv.v === undefined && dv.c; function formatValue(value: any, uom: number) { if (value === undefined) { return ''; } switch (uom) { case DeviceValueUOM.HOURS: return value ? formatDurationMin(value * 60) : '0 hours'; case DeviceValueUOM.MINUTES: return value ? formatDurationMin(value) : '0 minutes'; case DeviceValueUOM.NONE: if (typeof value === 'number') { return new Intl.NumberFormat().format(value); } return value; case DeviceValueUOM.DEGREES: case DeviceValueUOM.DEGREES_R: case DeviceValueUOM.FAHRENHEIT: return ( new Intl.NumberFormat(undefined, { minimumFractionDigits: 1 }).format(value) + ' ' + DeviceValueUOM_s[uom] ); case DeviceValueUOM.SECONDS: return pluralize(value, DeviceValueUOM_s[uom]); default: return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; } } const sendDeviceValue = async () => { if (deviceValue) { try { const response = await EMSESP.writeValue({ id: Number(device_select.state.id), devicevalue: deviceValue }); if (response.status === 204) { enqueueSnackbar('Write command failed', { variant: 'error' }); } else if (response.status === 403) { enqueueSnackbar('Write access denied', { variant: 'error' }); } else { enqueueSnackbar('Write command sent', { variant: 'success' }); } setDeviceValue(undefined); } catch (error: unknown) { enqueueSnackbar(extractErrorMessage(error, 'Problem writing value'), { variant: 'error' }); } finally { refreshData(); setDeviceValue(undefined); } } }; const renderDeviceValueDialog = () => { if (deviceValue) { return ( setDeviceValue(undefined)}> {isCmdOnly(deviceValue) ? 'Run Command' : 'Change Value'} {deviceValue.l && ( {deviceValue.l.map((val) => ( {val} ))} )} {!deviceValue.l && ( {DeviceValueUOM_s[deviceValue.u]} }} /> )} {deviceValue.h && {deviceValue.h}} ); } }; const addAnalogSensor = () => { setAnalog({ id: '0', g: 0, n: '', u: 0, v: 0, o: 0, t: 0, f: 1 }); }; const sendSensor = async () => { if (sensor) { try { const response = await EMSESP.writeSensor({ id: sensor.id, name: sensor.n, offset: sensor.o }); if (response.status === 204) { enqueueSnackbar('Sensor change failed', { variant: 'error' }); } else if (response.status === 403) { enqueueSnackbar('Access denied', { variant: 'error' }); } else { enqueueSnackbar('Sensor updated', { variant: 'success' }); } setSensor(undefined); } catch (error: unknown) { enqueueSnackbar(extractErrorMessage(error, 'Problem updating sensor'), { variant: 'error' }); } finally { setSensor(undefined); fetchSensorData(); } } }; const renderSensorDialog = () => { if (sensor) { return ( setSensor(undefined)}> Edit Temperature Sensor Sensor ID {sensor.id} °C }} /> ); } }; const renderDeviceDialog = () => { if (coreData && coreData.devices.length > 0 && deviceDialog !== -1) { return ( setDeviceDialog(-1)}> Device Details ); } }; const renderCoreData = () => ( {coreData.devices.length === 0 && } {(tableList: any) => ( <>
TYPE DESCRIPTION ENTITIES
{tableList.map((device: Device, index: number) => ( {/* refreshDataIndex(device.id)}> */} {/* */} {device.t} {device.n} {device.e} setDeviceDialog(index)}> ))} {(coreData.active_sensors > 0 || coreData.analog_enabled) && ( {/* refreshDataIndex('sensor')}> */} {/* */} Sensors Attached EMS-ESP Sensors {coreData.active_sensors} addAnalogSensor()}> )} )}
); const renderDeviceData = () => { if (!device_select.state.id || device_select.state.id === 'sensor') { return; } const sendCommand = (dv: DeviceValue) => { if (dv.c && me.admin && !hasMask(dv.id, DeviceEntityMask.DV_READONLY)) { setDeviceValue(dv); } }; const renderNameCell = (dv: DeviceValue) => ( <> {dv.id.slice(2)}  {hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && } {hasMask(dv.id, DeviceEntityMask.DV_READONLY) && } {hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && ( )} ); return ( <> {deviceData.label} setOnlyFav(!onlyFav)} />} label={show only favorites} /> hasMask(dv.id, DeviceEntityMask.DV_FAVORITE)) : deviceData.data }} theme={data_theme} sort={dv_sort} // layout={{ custom: true }} > {(tableList: any) => ( <>
VALUE
{tableList.map((dv: DeviceValue) => ( sendCommand(dv)}> {renderNameCell(dv)} {formatValue(dv.v, dv.u)} {dv.c && me.admin && !hasMask(dv.id, DeviceEntityMask.DV_READONLY) && ( sendCommand(dv)}> {isCmdOnly(dv) ? ( ) : ( )} )} ))} )}
); }; const updateSensor = (s: Sensor) => { if (s && me.admin) { setSensor(s); } }; const updateAnalog = (a: Analog) => { if (me.admin) { setAnalog(a); } }; const renderDallasData = () => ( <> Temperature Sensors {(tableList: any) => ( <>
{tableList.map((s: Sensor) => ( updateSensor(s)}> {s.n} {formatValue(s.t, s.u)} {me.admin && ( updateSensor(s)}> )} ))} )}
); const renderAnalogData = () => ( <> Analog Sensors {(tableList: any) => ( <>
VALUE
{tableList.map((a: Analog) => ( updateAnalog(a)}> {a.g} {a.n} {AnalogTypeNames[a.t]} {a.t ? formatValue(a.v, a.u) : ''} {me.admin && ( updateAnalog(a)}> )} ))} )}
); const sendRemoveAnalog = async () => { if (analog) { try { const response = await EMSESP.writeAnalog({ gpio: analog.g, name: analog.n, offset: analog.o, factor: analog.f, uom: analog.u, type: -1 }); if (response.status === 204) { enqueueSnackbar('Analog deletion failed', { variant: 'error' }); } else if (response.status === 403) { enqueueSnackbar('Access denied', { variant: 'error' }); } else { enqueueSnackbar('Analog sensor removed', { variant: 'success' }); } } catch (error: unknown) { enqueueSnackbar(extractErrorMessage(error, 'Problem updating analog sensor'), { variant: 'error' }); } finally { setAnalog(undefined); fetchSensorData(); } } }; const sendAnalog = async () => { if (analog) { try { const response = await EMSESP.writeAnalog({ gpio: analog.g, name: analog.n, offset: analog.o, factor: analog.f, uom: analog.u, type: analog.t }); if (response.status === 204) { enqueueSnackbar('Analog sensor update failed', { variant: 'error' }); } else if (response.status === 403) { enqueueSnackbar('Access denied', { variant: 'error' }); } else { enqueueSnackbar('Analog sensor updated', { variant: 'success' }); } } catch (error: unknown) { enqueueSnackbar(extractErrorMessage(error, 'Problem updating analog'), { variant: 'error' }); } finally { setAnalog(undefined); fetchSensorData(); } } }; const renderAnalogDialog = () => { if (analog) { return ( setAnalog(undefined)}> Edit Analog Sensor {AnalogTypeNames.map((val, i) => ( {val} ))} {analog.t >= AnalogType.COUNTER && analog.t <= AnalogType.RATE && ( <> {DeviceValueUOM_s.map((val, i) => ( {val} ))} {analog.t === AnalogType.ADC && ( mV }} /> )} {analog.t === AnalogType.COUNTER && ( )} )} {analog.t === AnalogType.DIGITAL_OUT && (analog.id === '25' || analog.id === '26') && ( <> )} {analog.t === AnalogType.DIGITAL_OUT && analog.id !== '25' && analog.id !== '26' && ( <> )} {analog.t >= AnalogType.PWM_0 && ( <> Hz }} /> % }} /> )} Warning: be careful when assigning a GPIO! ); } }; return ( {renderCoreData()} {renderDeviceData()} {renderDeviceDialog()} {sensorData.sensors.length !== 0 && renderDallasData()} {sensorData.analogs.length !== 0 && renderAnalogData()} {renderDeviceValueDialog()} {renderSensorDialog()} {renderAnalogDialog()} {device_select.state.id && device_select.state.id !== 'sensor' && ( )} ); }; export default DashboardData;