import { useCallback, useEffect, useMemo, useState } from 'react'; import { useBlocker, useLocation } from 'react-router'; import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; import EditIcon from '@mui/icons-material/Edit'; import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; import SaveIcon from '@mui/icons-material/Save'; import SearchIcon from '@mui/icons-material/Search'; import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; import WarningIcon from '@mui/icons-material/Warning'; import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Grid, InputAdornment, Link, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'; 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 { dialogStyle } from 'CustomTheme'; import { useRequest } from 'alova/client'; import SystemMonitor from 'app/status/SystemMonitor'; import { BlockNavigation, ButtonRow, MessageBox, SectionContent, useLayoutTitle } from 'components'; import { useI18nContext } from 'i18n/i18n-react'; import { API, readCoreData, readDeviceEntities, resetCustomizations, writeCustomizationEntities, writeDeviceName } from '../../api/app'; import SettingsCustomizationsDialog from './CustomizationsDialog'; import EntityMaskToggle from './EntityMaskToggle'; import OptionIcon from './OptionIcon'; import { DeviceEntityMask } from './types'; import type { APIcall, Device, DeviceEntity } from './types'; export const APIURL = `${window.location.origin}/api/`; const MAX_BUFFER_SIZE = 2000; // Helper function to create masked entity ID - extracted to avoid duplication const createMaskedEntityId = (de: DeviceEntity): string => { const maskHex = de.m.toString(16).padStart(2, '0'); const hasCustomizations = !!(de.cn || de.mi || de.ma); const customizations = [ de.cn || '', de.mi ? `>${de.mi}` : '', de.ma ? `<${de.ma}` : '' ] .filter(Boolean) .join(''); return `${maskHex}${de.id}${hasCustomizations ? `|${customizations}` : ''}`; }; const Customizations = () => { const { LL } = useI18nContext(); const [numChanges, setNumChanges] = useState(0); const blocker = useBlocker(numChanges !== 0); const [restarting, setRestarting] = useState(false); const [restartNeeded, setRestartNeeded] = useState(false); const [deviceEntities, setDeviceEntities] = useState([]); const [confirmReset, setConfirmReset] = useState(false); const [selectedFilters, setSelectedFilters] = useState(0); const [search, setSearch] = useState(''); const [selectedDeviceEntity, setSelectedDeviceEntity] = useState(); const [dialogOpen, setDialogOpen] = useState(false); const [rename, setRename] = useState(false); useLayoutTitle(LL.CUSTOMIZATIONS()); // fetch devices first from coreData const { data: devices, send: fetchCoreData } = useRequest(readCoreData); const { send: sendAPI } = useRequest((data: APIcall) => API(data), { immediate: false }); const [selectedDevice, setSelectedDevice] = useState( Number(useLocation().state) || -1 ); const [selectedDeviceTypeNameURL, setSelectedDeviceTypeNameURL] = useState(''); // needed for API URL const [selectedDeviceName, setSelectedDeviceName] = useState(''); const { send: sendResetCustomizations } = useRequest(resetCustomizations(), { immediate: false }); const { send: sendDeviceName } = useRequest( (data: { id: number; name: string }) => writeDeviceName(data), { immediate: false } ); const { send: sendCustomizationEntities } = useRequest( (data: { id: number; entity_ids: string[] }) => writeCustomizationEntities(data), { immediate: false } ); const { send: sendDeviceEntities } = useRequest( (data: number) => readDeviceEntities(data), { initialData: [], immediate: false } ).onSuccess((event) => { setOriginalSettings(event.data); }); const setOriginalSettings = (data: DeviceEntity[]) => { setDeviceEntities( data.map((de) => { const result: DeviceEntity = { ...de, o_m: de.m }; if (de.cn !== undefined) { result.o_cn = de.cn; } if (de.mi !== undefined) { result.o_mi = de.mi; } if (de.ma !== undefined) { result.o_ma = de.ma; } return result; }) ); }; const doRestart = async () => { setRestarting(true); await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( (error: Error) => { toast.error(error.message); } ); }; const entities_theme = useMemo( () => 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; background-color: #177ac9; } `, 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) { const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de)); setNumChanges(changedEntities.length); } }, [deviceEntities]); useEffect(() => { if (devices && selectedDevice !== -1) { void sendDeviceEntities(selectedDevice); const index = devices.devices.findIndex((d) => d.id === selectedDevice); if (index === -1) { setSelectedDevice(-1); setSelectedDeviceTypeNameURL(''); } else { const device = devices.devices[index]; if (device) { setSelectedDeviceTypeNameURL(device.url || ''); setSelectedDeviceName(device.n); } setNumChanges(0); setRestartNeeded(false); } } }, [devices, selectedDevice]); function formatValue(value: unknown) { 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 as string; } const isCommand = useCallback((de: DeviceEntity) => { return de.n && de.n[0] === '!'; }, []); const formatName = useCallback( (de: DeviceEntity, withShortname: boolean) => { let name: string; if (isCommand(de)) { name = de.t ? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}` : `${LL.COMMAND(1)}: ${de.n?.slice(1)}`; } else if (de.cn && de.cn !== '') { name = de.t ? `${de.t} ${de.cn}` : de.cn; } else { name = de.t ? `${de.t} ${de.n}` : de.n || ''; } return withShortname ? `${name} ${de.id}` : name; }, [LL] ); 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 = useCallback( (de: DeviceEntity) => (de.m & selectedFilters || !selectedFilters) && formatName(de, true).toLowerCase().includes(search.toLowerCase()), [selectedFilters, search, formatName] ); const maskDisabled = useCallback( (set: boolean) => { setDeviceEntities((prev) => prev.map((de) => { if (filter_entity(de)) { const excludeMask = DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; return { ...de, m: set ? de.m | excludeMask : de.m & ~excludeMask }; } return de; }) ); }, [filter_entity] ); const resetCustomization = useCallback(async () => { try { await sendResetCustomizations(); toast.info(LL.CUSTOMIZATIONS_RESTART()); } catch (error) { toast.error((error as Error).message); } finally { setConfirmReset(false); setRestarting(true); } }, [sendResetCustomizations, LL]); const onDialogClose = () => { setDialogOpen(false); }; const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { setDeviceEntities( (prev) => prev?.map((de) => de.id === updatedItem.id ? { ...de, ...updatedItem } : de ) ?? [] ); }, []); const onDialogSave = useCallback( (updatedItem: DeviceEntity) => { setDialogOpen(false); updateDeviceEntity(updatedItem); }, [updateDeviceEntity] ); 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 = useCallback(async () => { if (!devices || !deviceEntities || selectedDevice === -1) { return; } const masked_entities = deviceEntities .filter((de: DeviceEntity) => hasEntityChanged(de)) .map((new_de) => createMaskedEntityId(new_de)); // check size in bytes to match buffer in CPP, which is 2048 const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length; if (bytes > MAX_BUFFER_SIZE) { toast.warning(LL.CUSTOMIZATIONS_FULL()); return; } await sendCustomizationEntities({ id: selectedDevice, entity_ids: masked_entities }) .then(() => { toast.success(LL.CUSTOMIZATIONS_SAVED()); }) .catch((error: Error) => { if (error.message === 'Reboot required') { setRestartNeeded(true); } else { toast.error(error.message); } }) .finally(() => { setOriginalSettings(deviceEntities); }); }, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]); const renameDevice = useCallback(async () => { await sendDeviceName({ id: selectedDevice, name: selectedDeviceName }) .then(() => { toast.success(LL.UPDATED_OF(LL.NAME(1))); }) .catch(() => { toast.error(`${LL.UPDATE_OF(LL.NAME(1))} ${LL.FAILED(1)}`); }) .finally(async () => { setRename(false); await fetchCoreData(); }); }, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]); const renderDeviceList = () => ( <> {LL.CUSTOMIZATIONS_HELP_1()}. {rename ? ( setSelectedDeviceName(e.target.value)} margin="normal" /> ) : ( setSelectedDevice(parseInt(e.target.value))} margin="normal" style={{ minWidth: '50%' }} select > {LL.SELECT_DEVICE()}... {devices.devices.map( (device: Device) => device.id < 90 && ( {device.n} ({device.tn}) ) )} )} {selectedDevice !== -1 && (rename ? ( <> ) : ( <> ))} ); const filteredEntities = useMemo( () => deviceEntities.filter((de) => filter_entity(de)), [deviceEntities, filter_entity] ); const renderDeviceData = () => { return ( <> ={LL.CUSTOMIZATIONS_HELP_2()}    ={LL.CUSTOMIZATIONS_HELP_3()}    = {LL.CUSTOMIZATIONS_HELP_4()}   = {LL.CUSTOMIZATIONS_HELP_5()}   ={LL.CUSTOMIZATIONS_HELP_6()} { setSearch(event.target.value); }} slotProps={{ input: { startAdornment: ( ) } }} /> { setSelectedFilters(getMaskNumber(mask)); }} > {LL.SHOWING()} {filteredEntities.length}/{deviceEntities.length}  {LL.ENTITIES(deviceEntities.length)} {(tableList: DeviceEntity[]) => ( <>
{LL.OPTIONS()} {LL.NAME(1)} {LL.MIN()} {LL.MAX()} {LL.VALUE(0)}
{tableList.map((de: DeviceEntity) => ( editDeviceEntity(de)}> {formatName(de, false)} ( {de.id} ) {!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.mi)} {!(de.m & DeviceEntityMask.DV_READONLY) && formatValue(de.ma)} {formatValue(de.v)} ))} )}
); }; const renderResetDialog = () => ( setConfirmReset(false)} > {LL.REMOVE_ALL()} {LL.CUSTOMIZATIONS_RESET()} ); const renderContent = () => ( <> {devices && renderDeviceList()} {selectedDevice !== -1 && !rename && renderDeviceData()} {restartNeeded ? ( ) : ( {numChanges !== 0 && ( )} )} {renderResetDialog()} ); return ( {blocker ? : null} {restarting ? : renderContent()} {selectedDeviceEntity && ( )} ); }; export default Customizations;