mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-07 08:19:52 +03:00
609 lines
17 KiB
TypeScript
609 lines
17 KiB
TypeScript
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
|
import { toast } from 'react-toastify';
|
|
|
|
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
|
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
|
|
import KeyboardArrowUpOutlinedIcon from '@mui/icons-material/KeyboardArrowUpOutlined';
|
|
import UnfoldMoreOutlinedIcon from '@mui/icons-material/UnfoldMoreOutlined';
|
|
import { Box, Button, Typography } from '@mui/material';
|
|
|
|
import { SortToggleType, useSort } from '@table-library/react-table-library/sort';
|
|
import {
|
|
Body,
|
|
Cell,
|
|
Header,
|
|
HeaderCell,
|
|
HeaderRow,
|
|
Row,
|
|
Table
|
|
} from '@table-library/react-table-library/table';
|
|
import { useTheme } from '@table-library/react-table-library/theme';
|
|
import type { State } from '@table-library/react-table-library/types/common';
|
|
import { useRequest } from 'alova/client';
|
|
import { SectionContent, useLayoutTitle } from 'components';
|
|
import { AuthenticatedContext } from 'contexts/authentication';
|
|
import { useI18nContext } from 'i18n/i18n-react';
|
|
import { useInterval } from 'utils';
|
|
|
|
import {
|
|
readSensorData,
|
|
writeAnalogSensor,
|
|
writeTemperatureSensor
|
|
} from '../../api/app';
|
|
import DashboardSensorsAnalogDialog from './SensorsAnalogDialog';
|
|
import DashboardSensorsTemperatureDialog from './SensorsTemperatureDialog';
|
|
import {
|
|
AnalogType,
|
|
AnalogTypeNames,
|
|
DeviceValueUOM,
|
|
DeviceValueUOM_s
|
|
} from './types';
|
|
import type {
|
|
AnalogSensor,
|
|
TemperatureSensor,
|
|
WriteAnalogSensor,
|
|
WriteTemperatureSensor
|
|
} from './types';
|
|
import {
|
|
analogSensorItemValidation,
|
|
temperatureSensorItemValidation
|
|
} from './validators';
|
|
|
|
// Constants
|
|
const MS_PER_SECOND = 1000;
|
|
const MS_PER_MINUTE = 60 * MS_PER_SECOND;
|
|
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
const MIN_TEMP_ID = -100;
|
|
const MAX_TEMP_ID = 100;
|
|
const GPIO_25 = 25;
|
|
const GPIO_26 = 26;
|
|
|
|
const HEADER_BUTTON_STYLE: React.CSSProperties = {
|
|
fontSize: '14px',
|
|
justifyContent: 'flex-start'
|
|
};
|
|
|
|
const HEADER_BUTTON_STYLE_END: React.CSSProperties = {
|
|
fontSize: '14px',
|
|
justifyContent: 'flex-end'
|
|
};
|
|
|
|
const common_theme = {
|
|
BaseRow: `
|
|
font-size: 14px;
|
|
.td {
|
|
height: 32px;
|
|
}
|
|
`,
|
|
HeaderRow: `
|
|
text-transform: uppercase;
|
|
background-color: black;
|
|
color: #90CAF9;
|
|
.th {
|
|
border-bottom: 1px solid #565656;
|
|
height: 36px;
|
|
}
|
|
`,
|
|
Row: `
|
|
background-color: #1e1e1e;
|
|
position: relative;
|
|
cursor: pointer;
|
|
.td {
|
|
padding: 8px;
|
|
border-bottom: 1px solid #565656;
|
|
}
|
|
&:hover .td {
|
|
background-color: #177ac9;
|
|
color: white;
|
|
}
|
|
`,
|
|
Cell: `
|
|
&:last-of-type {
|
|
text-align: right;
|
|
},
|
|
`
|
|
};
|
|
|
|
const temperature_theme_config = {
|
|
Table: `
|
|
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
|
|
`
|
|
};
|
|
|
|
const analog_theme_config = {
|
|
Table: `
|
|
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
|
|
`
|
|
};
|
|
|
|
const Sensors = () => {
|
|
const { LL } = useI18nContext();
|
|
const { me } = useContext(AuthenticatedContext);
|
|
|
|
const [selectedTemperatureSensor, setSelectedTemperatureSensor] =
|
|
useState<TemperatureSensor>();
|
|
const [selectedAnalogSensor, setSelectedAnalogSensor] = useState<AnalogSensor>();
|
|
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
|
|
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
|
|
const [creating, setCreating] = useState<boolean>(false);
|
|
const firstAvailableGPIO = useRef<number>(undefined);
|
|
|
|
const { data: sensorData, send: fetchSensorData } = useRequest(readSensorData, {
|
|
initialData: {
|
|
ts: [],
|
|
as: [],
|
|
analog_enabled: false,
|
|
available_gpios: [] as number[],
|
|
platform: 'ESP32'
|
|
}
|
|
}).onSuccess((event) => {
|
|
// store the first available GPIO in a ref
|
|
if (event.data.available_gpios.length > 0) {
|
|
firstAvailableGPIO.current = event.data.available_gpios[0];
|
|
}
|
|
});
|
|
|
|
const { send: sendTemperatureSensor } = useRequest(
|
|
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
|
|
{
|
|
immediate: false
|
|
}
|
|
);
|
|
|
|
const { send: sendAnalogSensor } = useRequest(
|
|
(data: WriteAnalogSensor) => writeAnalogSensor(data),
|
|
{
|
|
immediate: false
|
|
}
|
|
);
|
|
|
|
const intervalCallback = useCallback(() => {
|
|
if (!temperatureDialogOpen && !analogDialogOpen) {
|
|
void fetchSensorData();
|
|
}
|
|
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
|
|
|
|
useInterval(intervalCallback);
|
|
|
|
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
|
|
const analog_theme = useTheme([common_theme, analog_theme_config]);
|
|
|
|
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
|
|
if (state.sortKey === sortKey && state.reverse) {
|
|
return <KeyboardArrowDownOutlinedIcon />;
|
|
}
|
|
if (state.sortKey === sortKey && !state.reverse) {
|
|
return <KeyboardArrowUpOutlinedIcon />;
|
|
}
|
|
return <UnfoldMoreOutlinedIcon />;
|
|
}, []);
|
|
|
|
const analog_sort = useSort(
|
|
{ nodes: sensorData.as },
|
|
{},
|
|
{
|
|
sortIcon: {
|
|
iconDefault: <UnfoldMoreOutlinedIcon />,
|
|
iconUp: <KeyboardArrowUpOutlinedIcon />,
|
|
iconDown: <KeyboardArrowDownOutlinedIcon />
|
|
},
|
|
sortToggleType: SortToggleType.AlternateWithReset,
|
|
sortFns: {
|
|
GPIO: (array) =>
|
|
[...array].sort(
|
|
(a, b) => ((a as AnalogSensor)?.g ?? 0) - ((b as AnalogSensor)?.g ?? 0)
|
|
),
|
|
NAME: (array) =>
|
|
[...array].sort((a, b) =>
|
|
((a as AnalogSensor)?.n ?? '').localeCompare(
|
|
(b as AnalogSensor)?.n ?? ''
|
|
)
|
|
),
|
|
TYPE: (array) =>
|
|
[...array].sort((a, b) => (a as AnalogSensor).t - (b as AnalogSensor).t),
|
|
VALUE: (array) =>
|
|
[...array].sort((a, b) => (a as AnalogSensor).v - (b as AnalogSensor).v)
|
|
}
|
|
}
|
|
);
|
|
|
|
const temperature_sort = useSort(
|
|
{ nodes: sensorData.ts },
|
|
{},
|
|
{
|
|
sortIcon: {
|
|
iconDefault: <UnfoldMoreOutlinedIcon />,
|
|
iconUp: <KeyboardArrowUpOutlinedIcon />,
|
|
iconDown: <KeyboardArrowDownOutlinedIcon />
|
|
},
|
|
sortToggleType: SortToggleType.AlternateWithReset,
|
|
sortFns: {
|
|
NAME: (array) =>
|
|
[...array].sort((a, b) =>
|
|
(a as TemperatureSensor).n.localeCompare((b as TemperatureSensor).n)
|
|
),
|
|
VALUE: (array) =>
|
|
[...array].sort(
|
|
(a, b) =>
|
|
((a as TemperatureSensor).t ?? 0) - ((b as TemperatureSensor).t ?? 0)
|
|
)
|
|
}
|
|
}
|
|
);
|
|
|
|
useLayoutTitle(LL.SENSORS());
|
|
|
|
const formatDurationMin = useCallback(
|
|
(duration_min: number) => {
|
|
const totalMs = duration_min * MS_PER_MINUTE;
|
|
const days = Math.trunc(totalMs / MS_PER_DAY);
|
|
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
|
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
|
|
|
|
const parts: string[] = [];
|
|
if (days > 0) {
|
|
parts.push(LL.NUM_DAYS({ num: days }));
|
|
}
|
|
if (hours > 0) {
|
|
parts.push(LL.NUM_HOURS({ num: hours }));
|
|
}
|
|
if (minutes > 0) {
|
|
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
|
}
|
|
return parts.join(' ');
|
|
},
|
|
[LL]
|
|
);
|
|
|
|
const formatValue = useCallback(
|
|
(value: unknown, uom: DeviceValueUOM) => {
|
|
if (value === undefined) {
|
|
return '';
|
|
}
|
|
if (typeof value !== 'number') {
|
|
return value as string;
|
|
}
|
|
switch (uom) {
|
|
case DeviceValueUOM.HOURS:
|
|
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
|
case DeviceValueUOM.MINUTES:
|
|
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
|
case DeviceValueUOM.SECONDS:
|
|
return LL.NUM_SECONDS({ num: value });
|
|
case DeviceValueUOM.NONE:
|
|
return new Intl.NumberFormat().format(value);
|
|
case DeviceValueUOM.DEGREES:
|
|
case DeviceValueUOM.DEGREES_R:
|
|
case DeviceValueUOM.FAHRENHEIT:
|
|
return (
|
|
new Intl.NumberFormat(undefined, {
|
|
minimumFractionDigits: 1
|
|
}).format(value) +
|
|
' ' +
|
|
DeviceValueUOM_s[uom]
|
|
);
|
|
default:
|
|
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
|
}
|
|
},
|
|
[formatDurationMin, LL]
|
|
);
|
|
|
|
const updateTemperatureSensor = useCallback(
|
|
(ts: TemperatureSensor) => {
|
|
if (me.admin) {
|
|
ts.o_n = ts.n;
|
|
setSelectedTemperatureSensor(ts);
|
|
setTemperatureDialogOpen(true);
|
|
}
|
|
},
|
|
[me.admin]
|
|
);
|
|
|
|
const onTemperatureDialogClose = useCallback(() => {
|
|
setTemperatureDialogOpen(false);
|
|
void fetchSensorData();
|
|
}, [fetchSensorData]);
|
|
|
|
const onTemperatureDialogSave = useCallback(
|
|
async (ts: TemperatureSensor) => {
|
|
await sendTemperatureSensor({
|
|
id: ts.id,
|
|
name: ts.n,
|
|
offset: ts.o,
|
|
is_system: ts.s
|
|
})
|
|
.then(() => {
|
|
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
|
})
|
|
.catch(() => {
|
|
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
|
})
|
|
.finally(() => {
|
|
setTemperatureDialogOpen(false);
|
|
setSelectedTemperatureSensor(undefined);
|
|
void fetchSensorData();
|
|
});
|
|
},
|
|
[sendTemperatureSensor, LL, fetchSensorData]
|
|
);
|
|
|
|
const updateAnalogSensor = useCallback(
|
|
(as: AnalogSensor) => {
|
|
if (me.admin) {
|
|
setCreating(false);
|
|
as.o_n = as.n;
|
|
setSelectedAnalogSensor(as);
|
|
setAnalogDialogOpen(true);
|
|
}
|
|
},
|
|
[me.admin]
|
|
);
|
|
|
|
const onAnalogDialogClose = useCallback(() => {
|
|
setAnalogDialogOpen(false);
|
|
void fetchSensorData();
|
|
}, [fetchSensorData]);
|
|
|
|
const addAnalogSensor = useCallback(() => {
|
|
if (firstAvailableGPIO.current === undefined) {
|
|
toast.error('No available GPIO found');
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
setSelectedAnalogSensor({
|
|
id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID),
|
|
n: '',
|
|
g: firstAvailableGPIO.current,
|
|
u: DeviceValueUOM.NONE,
|
|
v: 0,
|
|
o: 0,
|
|
f: 1,
|
|
t: AnalogType.DIGITAL_IN, // default to digital in 1
|
|
d: false,
|
|
s: false,
|
|
o_n: ''
|
|
});
|
|
setAnalogDialogOpen(true);
|
|
}, []);
|
|
|
|
const onAnalogDialogSave = useCallback(
|
|
async (as: AnalogSensor) => {
|
|
await sendAnalogSensor({
|
|
id: as.id,
|
|
gpio: as.g,
|
|
name: as.n,
|
|
offset: as.o,
|
|
factor: as.f,
|
|
uom: as.u,
|
|
type: as.t,
|
|
deleted: as.d,
|
|
is_system: as.s
|
|
})
|
|
.then(() => {
|
|
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
|
})
|
|
.catch(() => {
|
|
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
|
})
|
|
.finally(() => {
|
|
setAnalogDialogOpen(false);
|
|
setSelectedAnalogSensor(undefined);
|
|
void fetchSensorData();
|
|
});
|
|
},
|
|
[sendAnalogSensor, LL, fetchSensorData]
|
|
);
|
|
|
|
const RenderAnalogSensors = useMemo(
|
|
() => (
|
|
<Table
|
|
data={{ nodes: sensorData.as }}
|
|
theme={analog_theme}
|
|
sort={analog_sort}
|
|
layout={{ custom: true }}
|
|
>
|
|
{(tableList: AnalogSensor[]) => (
|
|
<>
|
|
<Header>
|
|
<HeaderRow>
|
|
<HeaderCell stiff>
|
|
<Button
|
|
fullWidth
|
|
style={HEADER_BUTTON_STYLE}
|
|
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
|
>
|
|
GPIO
|
|
</Button>
|
|
</HeaderCell>
|
|
<HeaderCell resize>
|
|
<Button
|
|
fullWidth
|
|
style={HEADER_BUTTON_STYLE}
|
|
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
|
>
|
|
{LL.NAME(0)}
|
|
</Button>
|
|
</HeaderCell>
|
|
<HeaderCell stiff>
|
|
<Button
|
|
fullWidth
|
|
style={HEADER_BUTTON_STYLE}
|
|
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
|
>
|
|
{LL.TYPE(0)}
|
|
</Button>
|
|
</HeaderCell>
|
|
<HeaderCell stiff>
|
|
<Button
|
|
fullWidth
|
|
style={HEADER_BUTTON_STYLE_END}
|
|
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
|
onClick={() =>
|
|
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
|
}
|
|
>
|
|
{LL.VALUE(0)}
|
|
</Button>
|
|
</HeaderCell>
|
|
</HeaderRow>
|
|
</Header>
|
|
<Body>
|
|
{tableList.map((as: AnalogSensor) => (
|
|
<Row
|
|
style={{ color: as.s ? 'grey' : 'inherit' }}
|
|
key={as.id}
|
|
item={as}
|
|
onClick={() => updateAnalogSensor(as)}
|
|
>
|
|
<Cell stiff>{as.g}</Cell>
|
|
<Cell>{as.n}</Cell>
|
|
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
|
|
{(as.t === AnalogType.DIGITAL_OUT &&
|
|
as.g !== GPIO_25 &&
|
|
as.g !== GPIO_26) ||
|
|
as.t === AnalogType.DIGITAL_IN ||
|
|
as.t === AnalogType.PULSE ? (
|
|
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
|
|
) : (
|
|
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
|
|
)}
|
|
</Row>
|
|
))}
|
|
</Body>
|
|
</>
|
|
)}
|
|
</Table>
|
|
),
|
|
[
|
|
analog_sort,
|
|
analog_theme,
|
|
getSortIcon,
|
|
sensorData.as,
|
|
LL,
|
|
updateAnalogSensor,
|
|
formatValue
|
|
]
|
|
);
|
|
|
|
const RenderTemperatureSensors = useMemo(
|
|
() => (
|
|
<Table
|
|
data={{ nodes: sensorData.ts }}
|
|
theme={temperature_theme}
|
|
sort={temperature_sort}
|
|
layout={{ custom: true }}
|
|
>
|
|
{(tableList: TemperatureSensor[]) => (
|
|
<>
|
|
<Header>
|
|
<HeaderRow>
|
|
<HeaderCell resize>
|
|
<Button
|
|
fullWidth
|
|
style={HEADER_BUTTON_STYLE}
|
|
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
|
onClick={() =>
|
|
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
|
}
|
|
>
|
|
{LL.NAME(0)}
|
|
</Button>
|
|
</HeaderCell>
|
|
<HeaderCell stiff>
|
|
<Button
|
|
fullWidth
|
|
style={HEADER_BUTTON_STYLE_END}
|
|
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
|
|
onClick={() =>
|
|
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
|
}
|
|
>
|
|
{LL.VALUE(0)}
|
|
</Button>
|
|
</HeaderCell>
|
|
</HeaderRow>
|
|
</Header>
|
|
<Body>
|
|
{tableList.map((ts: TemperatureSensor) => (
|
|
<Row
|
|
style={{ color: ts.s ? 'grey' : 'inherit' }}
|
|
key={ts.id}
|
|
item={ts}
|
|
onClick={() => updateTemperatureSensor(ts)}
|
|
>
|
|
<Cell>{ts.n}</Cell>
|
|
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
|
</Row>
|
|
))}
|
|
</Body>
|
|
</>
|
|
)}
|
|
</Table>
|
|
),
|
|
[
|
|
temperature_sort,
|
|
temperature_theme,
|
|
getSortIcon,
|
|
sensorData.ts,
|
|
LL,
|
|
updateTemperatureSensor,
|
|
formatValue
|
|
]
|
|
);
|
|
|
|
return (
|
|
<SectionContent>
|
|
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
|
{LL.TEMP_SENSORS()}
|
|
</Typography>
|
|
{RenderTemperatureSensors}
|
|
{selectedTemperatureSensor && (
|
|
<DashboardSensorsTemperatureDialog
|
|
open={temperatureDialogOpen}
|
|
onClose={onTemperatureDialogClose}
|
|
onSave={onTemperatureDialogSave}
|
|
selectedItem={selectedTemperatureSensor}
|
|
validator={temperatureSensorItemValidation(
|
|
sensorData.ts,
|
|
selectedTemperatureSensor
|
|
)}
|
|
/>
|
|
)}
|
|
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="primary">
|
|
{LL.ANALOG_SENSORS()}
|
|
</Typography>
|
|
{RenderAnalogSensors}
|
|
{selectedAnalogSensor && (
|
|
<DashboardSensorsAnalogDialog
|
|
open={analogDialogOpen}
|
|
onClose={onAnalogDialogClose}
|
|
onSave={onAnalogDialogSave}
|
|
creating={creating}
|
|
selectedItem={selectedAnalogSensor}
|
|
analogGPIOList={sensorData.available_gpios}
|
|
validator={analogSensorItemValidation(sensorData.as, selectedAnalogSensor)}
|
|
/>
|
|
)}
|
|
{sensorData?.analog_enabled === true && me.admin && (
|
|
<Box mt={2} display="flex" flexWrap="wrap" justifyContent="flex-end">
|
|
<Button
|
|
variant="outlined"
|
|
color="primary"
|
|
startIcon={<AddCircleOutlineOutlinedIcon />}
|
|
onClick={addAnalogSensor}
|
|
>
|
|
{LL.ADD(0)}
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</SectionContent>
|
|
);
|
|
};
|
|
|
|
export default Sensors;
|