mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-06 15:59:52 +03:00
This commit is contained in:
578
interface/src/project/Customization.tsx
Normal file
578
interface/src/project/Customization.tsx
Normal file
@@ -0,0 +1,578 @@
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
MenuItem,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Grid,
|
||||
TextField,
|
||||
Link,
|
||||
InputAdornment
|
||||
} from '@mui/material';
|
||||
import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table';
|
||||
import { useTheme } from '@table-library/react-table-library/theme';
|
||||
import { useRequest } from 'alova';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useBlocker, useLocation } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import SettingsCustomizationDialog from './CustomizationDialog';
|
||||
import EntityMaskToggle from './EntityMaskToggle';
|
||||
import OptionIcon from './OptionIcon';
|
||||
|
||||
import * as EMSESP from './api';
|
||||
|
||||
import { DeviceEntityMask } from './types';
|
||||
import type { DeviceShort, DeviceEntity } from './types';
|
||||
import type { FC } from 'react';
|
||||
import { dialogStyle } from 'CustomTheme';
|
||||
import * as SystemApi from 'api/system';
|
||||
import { ButtonRow, SectionContent, MessageBox, BlockNavigation, useLayoutTitle } from 'components';
|
||||
|
||||
import RestartMonitor from 'framework/system/RestartMonitor';
|
||||
import { useI18nContext } from 'i18n/i18n-react';
|
||||
|
||||
export const APIURL = window.location.origin + '/api/';
|
||||
|
||||
const Customization: FC = () => {
|
||||
const { LL } = useI18nContext();
|
||||
const [numChanges, setNumChanges] = useState<number>(0);
|
||||
const blocker = useBlocker(numChanges !== 0);
|
||||
|
||||
const [restarting, setRestarting] = useState<boolean>(false);
|
||||
const [restartNeeded, setRestartNeeded] = useState<boolean>(false);
|
||||
const [deviceEntities, setDeviceEntities] = useState<DeviceEntity[]>([]);
|
||||
const [confirmReset, setConfirmReset] = useState<boolean>(false);
|
||||
const [selectedFilters, setSelectedFilters] = useState<number>(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedDeviceEntity, setSelectedDeviceEntity] = useState<DeviceEntity>();
|
||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
|
||||
useLayoutTitle(LL.CUSTOMIZATIONS());
|
||||
|
||||
// fetch devices first
|
||||
const { data: devices } = useRequest(EMSESP.readDevices);
|
||||
|
||||
// const { state } = useLocation();
|
||||
const [selectedDevice, setSelectedDevice] = useState<number>(useLocation().state || -1);
|
||||
const [selectedDeviceName, setSelectedDeviceName] = useState<string>('');
|
||||
|
||||
const { send: resetCustomizations } = useRequest(EMSESP.resetCustomizations(), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const { send: writeCustomizationEntities } = useRequest((data) => EMSESP.writeCustomizationEntities(data), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const { send: readDeviceEntities, onSuccess: onSuccess } = useRequest((data) => EMSESP.readDeviceEntities(data), {
|
||||
initialData: [],
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const setOriginalSettings = (data: DeviceEntity[]) => {
|
||||
setDeviceEntities(data.map((de) => ({ ...de, o_m: de.m, o_cn: de.cn, o_mi: de.mi, o_ma: de.ma })));
|
||||
};
|
||||
|
||||
onSuccess((event) => {
|
||||
setOriginalSettings(event.data);
|
||||
});
|
||||
|
||||
const { send: restartCommand } = useRequest(SystemApi.restart(), {
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const entities_theme = useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
|
||||
`,
|
||||
BaseRow: `
|
||||
font-size: 14px;
|
||||
.td {
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
BaseCell: `
|
||||
&:nth-of-type(3) {
|
||||
text-align: right;
|
||||
}
|
||||
&:nth-of-type(4) {
|
||||
text-align: right;
|
||||
}
|
||||
&:last-of-type {
|
||||
text-align: right;
|
||||
}
|
||||
`,
|
||||
HeaderRow: `
|
||||
text-transform: uppercase;
|
||||
background-color: black;
|
||||
color: #90CAF9;
|
||||
.th {
|
||||
border-bottom: 1px solid #565656;
|
||||
height: 36px;
|
||||
}
|
||||
&:nth-of-type(1) .th {
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
Row: `
|
||||
background-color: #1e1e1e;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
.td {
|
||||
border-top: 1px solid #565656;
|
||||
border-bottom: 1px solid #565656;
|
||||
}
|
||||
&.tr.tr-body.row-select.row-select-single-selected {
|
||||
background-color: #3d4752;
|
||||
}
|
||||
&:hover .td {
|
||||
border-top: 1px solid #177ac9;
|
||||
border-bottom: 1px solid #177ac9;
|
||||
}
|
||||
&:nth-of-type(odd) .td {
|
||||
background-color: #303030;
|
||||
}
|
||||
`,
|
||||
Cell: `
|
||||
&:nth-of-type(2) {
|
||||
padding: 8px;
|
||||
}
|
||||
&:nth-of-type(3) {
|
||||
padding-right: 4px;
|
||||
}
|
||||
&:nth-of-type(4) {
|
||||
padding-right: 4px;
|
||||
}
|
||||
&:last-of-type {
|
||||
padding-right: 8px;
|
||||
}
|
||||
`
|
||||
});
|
||||
|
||||
function hasEntityChanged(de: DeviceEntity) {
|
||||
return (de?.cn || '') !== (de?.o_cn || '') || de.m !== de.o_m || de.ma !== de.o_ma || de.mi !== de.o_mi;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (deviceEntities.length) {
|
||||
setNumChanges(
|
||||
deviceEntities
|
||||
.filter((de) => hasEntityChanged(de))
|
||||
.map(
|
||||
(new_de) =>
|
||||
new_de.m.toString(16).padStart(2, '0') +
|
||||
new_de.id +
|
||||
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
|
||||
(new_de.cn ? new_de.cn : '') +
|
||||
(new_de.mi ? '>' + new_de.mi : '') +
|
||||
(new_de.ma ? '<' + new_de.ma : '')
|
||||
).length
|
||||
);
|
||||
}
|
||||
}, [deviceEntities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (devices && selectedDevice !== -1) {
|
||||
void readDeviceEntities(selectedDevice);
|
||||
const id = devices.devices.findIndex((d) => d.i === selectedDevice);
|
||||
if (id === -1) {
|
||||
setSelectedDevice(-1);
|
||||
setSelectedDeviceName('');
|
||||
} else {
|
||||
setSelectedDeviceName(devices.devices[id].tn || '');
|
||||
setNumChanges(0);
|
||||
setRestartNeeded(false);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [devices, selectedDevice]);
|
||||
|
||||
const restart = async () => {
|
||||
await restartCommand().catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
setRestarting(true);
|
||||
};
|
||||
|
||||
function formatValue(value: any) {
|
||||
if (typeof value === 'number') {
|
||||
return new Intl.NumberFormat().format(value);
|
||||
} else if (value === undefined) {
|
||||
return '';
|
||||
} else if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const formatName = (de: DeviceEntity, withShortname: boolean) =>
|
||||
(de.n && de.n[0] === '!' ? LL.COMMAND(1) + ': ' + de.n.slice(1) : de.cn && de.cn !== '' ? de.cn : de.n) +
|
||||
(withShortname ? ' ' + de.id : '');
|
||||
|
||||
const getMaskNumber = (newMask: string[]) => {
|
||||
let new_mask = 0;
|
||||
for (const entry of newMask) {
|
||||
new_mask |= Number(entry);
|
||||
}
|
||||
return new_mask;
|
||||
};
|
||||
|
||||
const getMaskString = (m: number) => {
|
||||
const new_masks: string[] = [];
|
||||
if ((m & 1) === 1) {
|
||||
new_masks.push('1');
|
||||
}
|
||||
if ((m & 2) === 2) {
|
||||
new_masks.push('2');
|
||||
}
|
||||
if ((m & 4) === 4) {
|
||||
new_masks.push('4');
|
||||
}
|
||||
if ((m & 8) === 8) {
|
||||
new_masks.push('8');
|
||||
}
|
||||
if ((m & 128) === 128) {
|
||||
new_masks.push('128');
|
||||
}
|
||||
return new_masks;
|
||||
};
|
||||
|
||||
const filter_entity = (de: DeviceEntity) =>
|
||||
(de.m & selectedFilters || !selectedFilters) && formatName(de, true).includes(search.toLocaleLowerCase());
|
||||
|
||||
const maskDisabled = (set: boolean) => {
|
||||
setDeviceEntities(
|
||||
deviceEntities.map(function (de) {
|
||||
if (filter_entity(de)) {
|
||||
return {
|
||||
...de,
|
||||
m: set
|
||||
? de.m | (DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE)
|
||||
: de.m & ~(DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE)
|
||||
};
|
||||
} else {
|
||||
return de;
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const resetCustomization = async () => {
|
||||
try {
|
||||
await resetCustomizations();
|
||||
toast.info(LL.CUSTOMIZATIONS_RESTART());
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setConfirmReset(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
||||
setDeviceEntities(deviceEntities?.map((de) => (de.id === updatedItem.id ? { ...de, ...updatedItem } : de)));
|
||||
};
|
||||
|
||||
const onDialogSave = (updatedItem: DeviceEntity) => {
|
||||
setDialogOpen(false);
|
||||
updateDeviceEntity(updatedItem);
|
||||
};
|
||||
|
||||
const editDeviceEntity = useCallback((de: DeviceEntity) => {
|
||||
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (de.cn === undefined) {
|
||||
de.cn = '';
|
||||
}
|
||||
|
||||
setSelectedDeviceEntity(de);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const saveCustomization = async () => {
|
||||
if (devices && deviceEntities && selectedDevice !== -1) {
|
||||
const masked_entities = deviceEntities
|
||||
.filter((de: DeviceEntity) => hasEntityChanged(de))
|
||||
.map(
|
||||
(new_de) =>
|
||||
new_de.m.toString(16).padStart(2, '0') +
|
||||
new_de.id +
|
||||
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
|
||||
(new_de.cn ? new_de.cn : '') +
|
||||
(new_de.mi ? '>' + new_de.mi : '') +
|
||||
(new_de.ma ? '<' + new_de.ma : '')
|
||||
);
|
||||
|
||||
// check size in bytes to match buffer in CPP, which is 2048
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
|
||||
if (bytes > 2000) {
|
||||
toast.warning(LL.CUSTOMIZATIONS_FULL());
|
||||
return;
|
||||
}
|
||||
|
||||
await writeCustomizationEntities({ id: selectedDevice, entity_ids: masked_entities }).catch((error) => {
|
||||
if (error.message === 'Reboot required') {
|
||||
setRestartNeeded(true);
|
||||
} else {
|
||||
toast.error(error.message);
|
||||
}
|
||||
});
|
||||
setOriginalSettings(deviceEntities);
|
||||
}
|
||||
};
|
||||
|
||||
const renderDeviceList = () => (
|
||||
<>
|
||||
<Box mb={1} color="warning.main">
|
||||
<Typography variant="body2">{LL.CUSTOMIZATIONS_HELP_1()}.</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
name="device"
|
||||
label={LL.EMS_DEVICE()}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={selectedDevice}
|
||||
disabled={numChanges !== 0}
|
||||
onChange={(e) => setSelectedDevice(parseInt(e.target.value))}
|
||||
margin="normal"
|
||||
select
|
||||
>
|
||||
<MenuItem disabled key={-1} value={-1}>
|
||||
{LL.SELECT_DEVICE()}...
|
||||
</MenuItem>
|
||||
{devices.devices.map((device: DeviceShort) => (
|
||||
<MenuItem key={device.i} value={device.i}>
|
||||
{device.s}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderDeviceData = () => {
|
||||
const shown_data = deviceEntities.filter((de) => filter_entity(de));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box color="warning.main">
|
||||
<Typography variant="body2" mt={1}>
|
||||
<OptionIcon type="favorite" isSet={true} />={LL.CUSTOMIZATIONS_HELP_2()}
|
||||
<OptionIcon type="readonly" isSet={true} />={LL.CUSTOMIZATIONS_HELP_3()}
|
||||
<OptionIcon type="api_mqtt_exclude" isSet={true} />={LL.CUSTOMIZATIONS_HELP_4()}
|
||||
<OptionIcon type="web_exclude" isSet={true} />={LL.CUSTOMIZATIONS_HELP_5()}
|
||||
<OptionIcon type="deleted" isSet={true} />={LL.CUSTOMIZATIONS_HELP_6()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container mb={1} mt={0} spacing={1} direction="row" justifyContent="flex-start" alignItems="center">
|
||||
<Grid item xs={2}>
|
||||
<TextField
|
||||
size="small"
|
||||
variant="outlined"
|
||||
placeholder={LL.SEARCH()}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon color="primary" sx={{ fontSize: 16 }} />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
color="secondary"
|
||||
value={getMaskString(selectedFilters)}
|
||||
onChange={(event, mask) => {
|
||||
setSelectedFilters(getMaskNumber(mask));
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="8">
|
||||
<OptionIcon type="favorite" isSet={true} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="4">
|
||||
<OptionIcon type="readonly" isSet={true} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="2">
|
||||
<OptionIcon type="api_mqtt_exclude" isSet={true} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="1">
|
||||
<OptionIcon type="web_exclude" isSet={true} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="128">
|
||||
<OptionIcon type="deleted" isSet={true} />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ fontSize: 10 }}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => maskDisabled(false)}
|
||||
>
|
||||
{LL.SET_ALL()}
|
||||
<OptionIcon type="api_mqtt_exclude" isSet={false} />
|
||||
<OptionIcon type="web_exclude" isSet={false} />
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ fontSize: 10 }}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => maskDisabled(true)}
|
||||
>
|
||||
{LL.SET_ALL()}
|
||||
<OptionIcon type="api_mqtt_exclude" isSet={true} />
|
||||
<OptionIcon type="web_exclude" isSet={true} />
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="subtitle2" color="primary">
|
||||
{LL.SHOWING()} {shown_data.length}/{deviceEntities.length} {LL.ENTITIES(deviceEntities.length)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Table data={{ nodes: shown_data }} theme={entities_theme} layout={{ custom: true }}>
|
||||
{(tableList: any) => (
|
||||
<>
|
||||
<Header>
|
||||
<HeaderRow>
|
||||
<HeaderCell stiff>{LL.OPTIONS()}</HeaderCell>
|
||||
<HeaderCell resize>{LL.NAME(1)}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.MIN()}</HeaderCell>
|
||||
<HeaderCell stiff>{LL.MAX()}</HeaderCell>
|
||||
<HeaderCell resize>{LL.VALUE(0)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
</Header>
|
||||
<Body>
|
||||
{tableList.map((de: DeviceEntity) => (
|
||||
<Row key={de.id} item={de} onClick={() => editDeviceEntity(de)}>
|
||||
<Cell stiff>
|
||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
|
||||
</Cell>
|
||||
<Cell>
|
||||
{formatName(de, false)} (
|
||||
<Link target="_blank" href={APIURL + selectedDeviceName + '/' + de.id}>
|
||||
{de.id}
|
||||
</Link>
|
||||
)
|
||||
</Cell>
|
||||
<Cell>{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)}</Cell>
|
||||
<Cell>{!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.ma)}</Cell>
|
||||
<Cell>{formatValue(de.v)}</Cell>
|
||||
</Row>
|
||||
))}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderResetDialog = () => (
|
||||
<Dialog sx={dialogStyle} open={confirmReset} onClose={() => setConfirmReset(false)}>
|
||||
<DialogTitle>{LL.RESET(1)}</DialogTitle>
|
||||
<DialogContent dividers>{LL.CUSTOMIZATIONS_RESET()}</DialogContent>
|
||||
<DialogActions>
|
||||
<Button startIcon={<CancelIcon />} variant="outlined" onClick={() => setConfirmReset(false)} color="secondary">
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button startIcon={<SettingsBackupRestoreIcon />} variant="outlined" onClick={resetCustomization} color="error">
|
||||
{LL.RESET(0)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const renderContent = () => (
|
||||
<>
|
||||
{devices && renderDeviceList()}
|
||||
{deviceEntities && renderDeviceData()}
|
||||
{restartNeeded && (
|
||||
<MessageBox my={2} level="warning" message={LL.RESTART_TEXT(0)}>
|
||||
<Button startIcon={<PowerSettingsNewIcon />} variant="contained" color="error" onClick={restart}>
|
||||
{LL.RESTART()}
|
||||
</Button>
|
||||
</MessageBox>
|
||||
)}
|
||||
{!restartNeeded && (
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1}>
|
||||
{numChanges !== 0 && (
|
||||
<ButtonRow>
|
||||
<Button
|
||||
startIcon={<CancelIcon />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => devices && readDeviceEntities(selectedDevice)}
|
||||
>
|
||||
{LL.CANCEL()}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<WarningIcon color="warning" />}
|
||||
variant="contained"
|
||||
color="info"
|
||||
onClick={saveCustomization}
|
||||
>
|
||||
{LL.APPLY_CHANGES(numChanges)}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
)}
|
||||
</Box>
|
||||
<ButtonRow>
|
||||
<Button
|
||||
startIcon={<SettingsBackupRestoreIcon />}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setConfirmReset(true)}
|
||||
>
|
||||
{LL.RESET(0)}
|
||||
</Button>
|
||||
</ButtonRow>
|
||||
</Box>
|
||||
)}
|
||||
{renderResetDialog()}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionContent>
|
||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||
{restarting ? <RestartMonitor /> : renderContent()}
|
||||
{selectedDeviceEntity && (
|
||||
<SettingsCustomizationDialog
|
||||
open={dialogOpen}
|
||||
onClose={onDialogClose}
|
||||
onSave={onDialogSave}
|
||||
selectedItem={selectedDeviceEntity}
|
||||
/>
|
||||
)}
|
||||
</SectionContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default Customization;
|
||||
Reference in New Issue
Block a user