optimizations

This commit is contained in:
proddy
2025-10-31 18:38:38 +01:00
parent ca1506de8b
commit 6b7534b7fb
19 changed files with 967 additions and 624 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -35,6 +35,10 @@ import { DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { Entities, EntityItem } from './types';
import { entityItemValidation } from './validators';
const MIN_ID = -100;
const MAX_ID = 100;
const ICON_SIZE = 12;
const CustomEntities = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
@@ -53,18 +57,20 @@ const CustomEntities = () => {
initialData: []
});
useInterval(() => {
const intervalCallback = useCallback(() => {
if (!dialogOpen && !numChanges) {
void fetchEntities();
}
});
}, [dialogOpen, numChanges, fetchEntities]);
useInterval(intervalCallback);
const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data),
{ immediate: false }
);
function hasEntityChanged(ei: EntityItem) {
const hasEntityChanged = useCallback((ei: EntityItem) => {
return (
ei.id !== ei.o_id ||
ei.ram !== ei.o_ram ||
@@ -80,19 +86,21 @@ const CustomEntities = () => {
ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '')
);
}
}, []);
const entity_theme = useTheme({
Table: `
const entity_theme = useMemo(
() =>
useTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
@@ -112,7 +120,7 @@ const CustomEntities = () => {
text-align: center;
}
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -121,7 +129,7 @@ const CustomEntities = () => {
height: 36px;
}
`,
Row: `
Row: `
background-color: #1e1e1e;
position: relative;
cursor: pointer;
@@ -132,9 +140,11 @@ const CustomEntities = () => {
background-color: #177ac9;
}
`
});
}),
[]
);
const saveEntities = async () => {
const saveEntities = useCallback(async () => {
await writeEntities({
entities: entities
.filter((ei: EntityItem) => !ei.deleted)
@@ -163,7 +173,7 @@ const CustomEntities = () => {
await fetchEntities();
setNumChanges(0);
});
};
}, [entities, writeEntities, LL, fetchEntities]);
const editEntityItem = useCallback((ei: EntityItem) => {
setCreating(false);
@@ -171,36 +181,39 @@ const CustomEntities = () => {
setDialogOpen(true);
}, []);
const onDialogClose = () => {
const onDialogClose = useCallback(() => {
setDialogOpen(false);
};
}, []);
const onDialogCancel = async () => {
const onDialogCancel = useCallback(async () => {
await fetchEntities().then(() => {
setNumChanges(0);
});
};
}, [fetchEntities]);
const onDialogSave = (updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
};
const onDialogSave = useCallback(
(updatedItem: EntityItem) => {
setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating
? [
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((ei) =>
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
);
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data;
});
},
[creating, hasEntityChanged]
);
const onDialogDup = (item: EntityItem) => {
const onDialogDup = useCallback((item: EntityItem) => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
name: item.name + '_',
ram: item.ram,
device_id: item.device_id,
@@ -215,12 +228,12 @@ const CustomEntities = () => {
value: item.value
});
setDialogOpen(true);
};
}, []);
const addEntityItem = () => {
const addEntityItem = useCallback(() => {
setCreating(true);
setSelectedEntityItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
name: '',
ram: 0,
device_id: '0',
@@ -235,22 +248,30 @@ const CustomEntities = () => {
value: ''
});
setDialogOpen(true);
};
}, []);
function formatValue(value: unknown, uom: number) {
const formatValue = useCallback((value: unknown, uom: number) => {
return value === undefined
? ''
: typeof value === 'number'
? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom])
: (value as string) + (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]);
}
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
}, []);
function showHex(value: number, digit: number) {
return '0x' + value.toString(16).toUpperCase().padStart(digit, '0');
}
const showHex = useCallback((value: number, digit: number) => {
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
}, []);
const renderEntity = () => {
const filteredAndSortedEntities = useMemo(
() =>
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const renderEntity = useCallback(() => {
if (!entities) {
return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -260,9 +281,7 @@ const CustomEntities = () => {
return (
<Table
data={{
nodes: entities
.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name))
nodes: filteredAndSortedEntities
}}
theme={entity_theme}
layout={{ custom: true }}
@@ -285,7 +304,10 @@ const CustomEntities = () => {
<Cell>
{ei.name}&nbsp;
{ei.writeable && (
<EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
<EditOutlinedIcon
color="primary"
sx={{ fontSize: ICON_SIZE }}
/>
)}
</Cell>
<Cell>
@@ -304,7 +326,17 @@ const CustomEntities = () => {
)}
</Table>
);
};
}, [
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
return (
<SectionContent>

View File

@@ -68,32 +68,51 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
// Convert to hex strings - combined into single setEditItem call
const deviceIdHex =
typeof selectedItem.device_id === 'number'
? selectedItem.device_id.toString(16).toUpperCase()
: selectedItem.device_id;
const typeIdHex =
typeof selectedItem.type_id === 'number'
? selectedItem.type_id.toString(16).toUpperCase()
: selectedItem.type_id;
const factorValue =
selectedItem.value_type === DeviceValueType.BOOL &&
typeof selectedItem.factor === 'number'
? selectedItem.factor.toString(16).toUpperCase()
: selectedItem.factor;
setEditItem({
...selectedItem,
device_id: selectedItem.device_id.toString(16).toUpperCase(),
type_id: selectedItem.type_id.toString(16).toUpperCase(),
factor:
selectedItem.value_type === DeviceValueType.BOOL
? selectedItem.factor.toString(16).toUpperCase()
: selectedItem.factor
device_id: deviceIdHex,
type_id: typeIdHex,
factor: factorValue
});
}
}, [open, selectedItem]);
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') {
onClose();
}
},
[onClose]
);
const save = useCallback(async () => {
try {
@@ -104,16 +123,16 @@ const CustomEntitiesDialog = ({
const processedItem: EntityItem = { ...editItem };
if (typeof processedItem.device_id === 'string') {
processedItem.device_id = parseInt(processedItem.device_id, 16);
processedItem.device_id = Number.parseInt(processedItem.device_id, 16);
}
if (typeof processedItem.type_id === 'string') {
processedItem.type_id = parseInt(processedItem.type_id, 16);
processedItem.type_id = Number.parseInt(processedItem.type_id, 16);
}
if (
processedItem.value_type === DeviceValueType.BOOL &&
typeof processedItem.factor === 'string'
) {
processedItem.factor = parseInt(processedItem.factor, 16);
processedItem.factor = Number.parseInt(processedItem.factor, 16);
}
onSave(processedItem);
} catch (error) {

View File

@@ -62,16 +62,24 @@ import OptionIcon from './OptionIcon';
import { DeviceEntityMask } from './types';
import type { APIcall, Device, DeviceEntity } from './types';
export const APIURL = window.location.origin + '/api/';
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 =>
de.m.toString(16).padStart(2, '0') +
de.id +
(de.cn || de.mi || de.ma ? '|' : '') +
(de.cn ? de.cn : '') +
(de.mi ? '>' + de.mi : '') +
(de.ma ? '<' + de.ma : '');
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();
@@ -277,18 +285,22 @@ const Customizations = () => {
return value as string;
}
const formatName = (de: DeviceEntity, withShortname: boolean) =>
(de.n && de.n[0] === '!'
? de.t
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1)
: LL.COMMAND(1) + ': ' + de.n.slice(1)
: de.cn && de.cn !== ''
? de.t
? de.t + ' ' + de.cn
: de.cn
: de.t
? de.t + ' ' + de.n
: de.n) + (withShortname ? ' ' + de.id : '');
const formatName = useCallback(
(de: DeviceEntity, withShortname: boolean) => {
let name: string;
if (de.n && de.n[0] === '!') {
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;
@@ -322,33 +334,29 @@ const Customizations = () => {
(de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search]
[selectedFilters, search, formatName]
);
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 {
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 = async () => {
const resetCustomization = useCallback(async () => {
try {
await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -357,24 +365,28 @@ const Customizations = () => {
} finally {
setConfirmReset(false);
}
};
}, [sendResetCustomizations, LL]);
const onDialogClose = () => {
setDialogOpen(false);
};
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setDeviceEntities(
deviceEntities?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
)
(prev) =>
prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? []
);
};
}, []);
const onDialogSave = (updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
};
const onDialogSave = useCallback(
(updatedItem: DeviceEntity) => {
setDialogOpen(false);
updateDeviceEntity(updatedItem);
},
[updateDeviceEntity]
);
const editDeviceEntity = useCallback((de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) {
@@ -389,52 +401,54 @@ const Customizations = () => {
setDialogOpen(true);
}, []);
const saveCustomization = async () => {
if (devices && deviceEntities && selectedDevice !== -1) {
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 > 2000) {
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);
});
const saveCustomization = useCallback(async () => {
if (!devices || !deviceEntities || selectedDevice === -1) {
return;
}
};
const renameDevice = async () => {
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));
toast.error(`${LL.UPDATE_OF(LL.NAME(1))} ${LL.FAILED(1)}`);
})
.finally(async () => {
setRename(false);
await fetchCoreData();
});
};
}, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]);
const renderDeviceList = () => (
<>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
@@ -35,14 +35,17 @@ interface LabelValueProps {
value: React.ReactNode;
}
const LabelValue = ({ label, value }: LabelValueProps) => (
const LabelValue = memo(({ label, value }: LabelValueProps) => (
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{label}:&nbsp;
</Typography>
<Typography variant="body2">{value}</Typography>
</Grid>
);
));
LabelValue.displayName = 'LabelValue';
const ICON_SIZE = 16;
const CustomizationsDialog = ({
open,
@@ -54,7 +57,15 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
const isWriteableNumber = useMemo(
() =>
@@ -93,32 +104,32 @@ const CustomizationsDialog = ({
}
}, [isWriteableNumber, editItem, onSave]);
const updateDeviceEntity = useCallback(
(updatedItem: DeviceEntity) => {
setEditItem({ ...editItem, m: updatedItem.m });
},
[editItem]
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
}, []);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
const writeableIcon = useMemo(
() =>
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
),
[editItem.w]
);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue
label={LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n}
/>
<LabelValue
label={LL.WRITEABLE()}
value={
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: 16 }} />
) : (
<CloseIcon color="error" sx={{ fontSize: 16 }} />
)
}
/>
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
<Box mt={1} mb={2}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />

View File

@@ -133,7 +133,7 @@ const Dashboard = memo(() => {
);
const tree = useTree(
{ nodes: data.nodes },
{ nodes: [...data.nodes] },
{
onChange: () => {} // not used but needed
},

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai';
import { CgSmartHomeBoiler } from 'react-icons/cg';
import { FaSolarPanel } from 'react-icons/fa';
@@ -50,9 +51,9 @@ interface DeviceIconProps {
type_id: DeviceType;
}
const DeviceIcon = ({ type_id }: DeviceIconProps) => {
const DeviceIcon = memo(({ type_id }: DeviceIconProps) => {
const Icon = deviceIconLookup[type_id];
return Icon ? <Icon /> : null;
};
});
export default DeviceIcon;

View File

@@ -118,30 +118,28 @@ const Devices = memo(() => {
);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
let raf = 0;
const updateSize = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
setSize([window.innerWidth, window.innerHeight]);
});
};
window.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
return () => {
window.removeEventListener('resize', updateSize);
cancelAnimationFrame(raf);
};
}, []);
const leftOffset = () => {
const leftOffset = useCallback(() => {
const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) {
return 0;
}
const clientRect = devicesWindow.getBoundingClientRect();
const left = clientRect.left;
const right = clientRect.right;
if (!left || !right) {
return 0;
}
if (!devicesWindow) return 0;
const { left, right } = devicesWindow.getBoundingClientRect();
if (!left || !right) return 0;
return left + (right - left < 400 ? 0 : 200);
};
}, []);
const common_theme = useMemo(
() =>
@@ -261,7 +259,7 @@ const Devices = memo(() => {
};
const dv_sort = useSort(
{ nodes: deviceData.nodes },
{ nodes: [...deviceData.nodes] },
{},
{
sortIcon: {
@@ -291,7 +289,7 @@ const Devices = memo(() => {
}
const device_select = useRowSelect(
{ nodes: coreData.devices },
{ nodes: [...coreData.devices] },
{
onChange: onSelectChange
}
@@ -549,7 +547,7 @@ const Devices = memo(() => {
{coreData.connected && (
<Table
data={{ nodes: coreData.devices }}
data={{ nodes: [...coreData.devices] }}
select={device_select}
theme={device_theme}
layout={{ custom: true }}
@@ -654,7 +652,7 @@ const Devices = memo(() => {
sx={{
backgroundColor: 'black',
position: 'absolute',
left: () => leftOffset(),
left: leftOffset,
right: 0,
bottom: 0,
top: 64,
@@ -744,7 +742,7 @@ const Devices = memo(() => {
</Box>
<Table
data={{ nodes: shown_data }}
data={{ nodes: Array.from(shown_data) }}
theme={data_theme}
sort={dv_sort}
layout={{ custom: true, fixedHeader: true }}

View File

@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon';
@@ -40,80 +42,101 @@ const getMaskString = (mask: number): string[] => {
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = (_event: unknown, mask: string[]) => {
// Convert selected masks to a number
const newMask = getMaskNumber(mask);
const handleChange = useCallback(
(_event: unknown, mask: string[]) => {
// Convert selected masks to a number
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
// Apply business logic for mask interactions
// If entity has no name and is set to readonly, also exclude from web
if (de.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
de.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
} else {
de.m = newMask;
}
// Apply business logic for mask interactions
// If entity has no name and is set to readonly, also exclude from web
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
} else {
updatedDe.m = newMask;
}
// If excluded from web, cannot be favorite
if (hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
de.m = de.m & ~DeviceEntityMask.DV_FAVORITE;
}
// If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
}
onUpdate(de);
};
onUpdate(updatedDe);
},
[de, onUpdate]
);
// Check if favorite button should be disabled
const isFavoriteDisabled =
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
de.n === undefined;
// Memoize mask string value
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
// Check if readonly button should be disabled
const isReadonlyDisabled =
!de.w ||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE);
// Memoize disabled states
const isFavoriteDisabled = useMemo(
() =>
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
de.n === undefined,
[de.m, de.n]
);
// Check if api/mqtt exclude button should be disabled
const isApiMqttExcludeDisabled =
de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED);
const isReadonlyDisabled = useMemo(
() =>
!de.w ||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
[de.w, de.m]
);
// Check if web exclude button should be disabled
const isWebExcludeDisabled =
de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED);
const isApiMqttExcludeDisabled = useMemo(
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
const isWebExcludeDisabled = useMemo(
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
// Memoize mask flag checks
const isFavoriteSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
[de.m]
);
const isReadonlySet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
[de.m]
);
const isApiMqttExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
[de.m]
);
const isWebExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
[de.m]
);
const isDeletedSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.m]
);
return (
<ToggleButtonGroup
size="small"
color="secondary"
value={getMaskString(de.m)}
value={maskStringValue}
onChange={handleChange}
>
<ToggleButton value="8" disabled={isFavoriteDisabled}>
<OptionIcon
type="favorite"
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
/>
<OptionIcon type="favorite" isSet={isFavoriteSet} />
</ToggleButton>
<ToggleButton value="4" disabled={isReadonlyDisabled}>
<OptionIcon
type="readonly"
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
/>
<OptionIcon type="readonly" isSet={isReadonlySet} />
</ToggleButton>
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
<OptionIcon
type="api_mqtt_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
/>
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
</ToggleButton>
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
<OptionIcon
type="web_exclude"
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
/>
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
</ToggleButton>
<ToggleButton value="128">
<OptionIcon
type="deleted"
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
/>
<OptionIcon type="deleted" isSet={isDeletedSet} />
</ToggleButton>
</ToggleButtonGroup>
);

View File

@@ -101,9 +101,12 @@ const HelpComponent = () => {
toast.error(String(error.error?.message || 'An error occurred'));
});
// Optimize API call memoization
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
const handleDownloadSystemInfo = useCallback(() => {
void sendAPI({ device: 'system', cmd: 'info', id: 0 });
}, [sendAPI]);
void sendAPI(apiCall);
}, [sendAPI, apiCall]);
const handleImageError = useCallback(() => {
setImgError(true);
@@ -131,6 +134,8 @@ const HelpComponent = () => {
[LL]
);
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
// Memoize image source computation
const imageSrc = useMemo(
() =>
@@ -161,7 +166,7 @@ const HelpComponent = () => {
</Stack>
)}
{me.admin && (
{isAdmin && (
<List>
{helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}>

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
@@ -34,16 +34,15 @@ import type { ModuleItem } from './types';
const PENDING_COLOR = 'red';
const ACTIVATED_COLOR = '#00FF7F';
function hasModulesChanged(mi: ModuleItem): boolean {
return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
}
const hasModulesChanged = (mi: ModuleItem): boolean =>
mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
const colorStatus = (status: number) => {
const ColorStatus = memo(({ status }: { status: number }) => {
if (status === 1) {
return <div style={{ color: PENDING_COLOR }}>Pending Activation</div>;
}
return <div style={{ color: ACTIVATED_COLOR }}>Activated</div>;
};
});
const Modules = () => {
const { LL } = useI18nContext();
@@ -151,25 +150,23 @@ const Modules = () => {
}, [fetchModules]);
const saveModules = useCallback(async () => {
await Promise.all(
modules.map((condensed_mi: ModuleItem) =>
updateModules({
key: condensed_mi.key,
enabled: condensed_mi.enabled,
license: condensed_mi.license
})
)
)
.then(() => {
toast.success(LL.MODULES_UPDATED());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchModules();
setNumChanges(0);
});
try {
await Promise.all(
modules.map((condensed_mi: ModuleItem) =>
updateModules({
key: condensed_mi.key,
enabled: condensed_mi.enabled,
license: condensed_mi.license
})
)
);
toast.success(LL.MODULES_UPDATED());
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
await fetchModules();
setNumChanges(0);
}
}, [modules, updateModules, LL, fetchModules]);
const content = useMemo(() => {
@@ -229,7 +226,9 @@ const Modules = () => {
<Cell>{mi.author}</Cell>
<Cell>{mi.version}</Cell>
<Cell>{mi.message}</Cell>
<Cell>{colorStatus(mi.status)}</Cell>
<Cell>
<ColorStatus status={mi.status} />
</Cell>
</Row>
))}
</Body>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
@@ -37,7 +37,15 @@ const ModulesDialog = ({
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
// Sync form state when dialog opens or selected item changes
useEffect(() => {
@@ -46,9 +54,18 @@ const ModulesDialog = ({
}
}, [open, selectedItem]);
const handleSave = useCallback(() => {
onSave(editItem);
}, [editItem, onSave]);
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{LL.EDIT() + ' ' + editItem.key}</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Grid container>
<BlockFormControlLabel
@@ -86,7 +103,7 @@ const ModulesDialog = ({
<Button
startIcon={<DoneIcon />}
variant="outlined"
onClick={() => onSave(editItem)}
onClick={handleSave}
color="primary"
>
{LL.UPDATE()}

View File

@@ -1,3 +1,5 @@
import { memo } from 'react';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
@@ -10,34 +12,39 @@ import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
import type { SvgIconProps } from '@mui/material';
type OptionType =
export type OptionType =
| 'deleted'
| 'readonly'
| 'web_exclude'
| 'api_mqtt_exclude'
| 'favorite';
const OPTION_ICONS: {
[type in OptionType]: [
React.ComponentType<SvgIconProps>,
React.ComponentType<SvgIconProps>
];
} = {
type IconPair = [
React.ComponentType<SvgIconProps>,
React.ComponentType<SvgIconProps>
];
const OPTION_ICONS: Record<OptionType, IconPair> = {
deleted: [DeleteForeverIcon, DeleteOutlineIcon],
readonly: [EditOffOutlinedIcon, EditOutlinedIcon],
web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon],
api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon],
favorite: [StarIcon, StarOutlineIcon]
} as const;
const ICON_SIZE = 16;
const ICON_SX = { fontSize: ICON_SIZE, verticalAlign: 'middle' } as const;
export interface OptionIconProps {
readonly type: OptionType;
readonly isSet: boolean;
}
const OptionIcon = ({ type, isSet }: OptionIconProps) => {
const [SetIcon, UnsetIcon] = OPTION_ICONS[type];
const Icon = isSet ? SetIcon : UnsetIcon;
return <Icon {...(isSet && { color: 'primary' })} sx={ICON_SX} />;
};
const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => {
const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
return (
<Icon
{...(isSet && { color: 'primary' })}
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
);
};
export default OptionIcon;
export default memo(OptionIcon);

View File

@@ -35,6 +35,76 @@ import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types';
import { schedulerItemValidation } from './validators';
// Constants
const INTERVAL_DELAY = 30000; // 30 seconds
const MIN_ID = -100;
const MAX_ID = 100;
const ICON_SIZE = 16;
const SCHEDULE_FLAG_THRESHOLD = 127;
const REFERENCE_YEAR = 2017;
const REFERENCE_MONTH = '01';
const LOG_2 = Math.log(2);
// Days of week starting from Monday (1-7)
const WEEK_DAYS = [1, 2, 3, 4, 5, 6, 7] as const;
const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
active: false,
deleted: false,
flags: ScheduleFlag.SCHEDULE_DAY,
time: '',
cmd: '',
value: '',
name: ''
};
const scheduleTheme = {
Table: `
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) {
text-align: center;
}
`,
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 {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
};
const scheduleTypeLabels: Record<number, string> = {
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
};
const Scheduler = () => {
const { LL, locale } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
@@ -74,93 +144,53 @@ const Scheduler = () => {
);
}, []);
useInterval(() => {
const intervalCallback = useCallback(() => {
if (numChanges === 0) {
void fetchSchedule();
}
}, 30000);
}, [numChanges, fetchSchedule]);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, {
weekday: 'short',
timeZone: 'UTC'
});
const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
const dd = day < 10 ? `0${day}` : day;
return new Date(`2017-01-${dd}T00:00:00+00:00`);
const days = WEEK_DAYS.map((day) => {
const dayStr = String(day).padStart(2, '0');
return new Date(
`${REFERENCE_YEAR}-${REFERENCE_MONTH}-${dayStr}T00:00:00+00:00`
);
});
setDow(days.map((date) => formatter.format(date)));
}, [locale]);
const schedule_theme = useTheme(
useMemo(
() => ({
Table: `
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) {
text-align: center;
}
`,
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 {
border-bottom: 1px solid #565656;
}
&:hover .td {
background-color: #177ac9;
}
`
}),
[]
)
);
const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = useCallback(async () => {
await updateSchedule({
schedule: schedule
.filter((si: ScheduleItem) => !si.deleted)
.map((condensed_si: ScheduleItem) => ({
id: condensed_si.id,
active: condensed_si.active,
flags: condensed_si.flags,
time: condensed_si.time,
cmd: condensed_si.cmd,
value: condensed_si.value,
name: condensed_si.name
}))
})
.then(() => {
toast.success(LL.SCHEDULE_UPDATED());
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(async () => {
await fetchSchedule();
setNumChanges(0);
try {
await updateSchedule({
schedule: schedule
.filter((si: ScheduleItem) => !si.deleted)
.map((condensed_si: ScheduleItem) => ({
id: condensed_si.id,
active: condensed_si.active,
flags: condensed_si.flags,
time: condensed_si.time,
cmd: condensed_si.cmd,
value: condensed_si.value,
name: condensed_si.name
}))
});
toast.success(LL.SCHEDULE_UPDATED());
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
} finally {
await fetchSchedule();
setNumChanges(0);
}
}, [LL, schedule, updateSchedule, fetchSchedule]);
const editScheduleItem = useCallback((si: ScheduleItem) => {
@@ -187,10 +217,7 @@ const Scheduler = () => {
setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating
? [
...data.filter((si) => creating || si.o_id !== updatedItem.o_id),
updatedItem
]
? [...data, updatedItem]
: data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
);
@@ -205,16 +232,11 @@ const Scheduler = () => {
const addScheduleItem = useCallback(() => {
setCreating(true);
setSelectedScheduleItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
active: false,
deleted: false,
flags: ScheduleFlag.SCHEDULE_DAY,
time: '',
cmd: '',
value: '',
name: ''
});
const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
...DEFAULT_SCHEDULE_ITEM
};
setSelectedScheduleItem(newItem);
setDialogOpen(true);
}, []);
@@ -227,44 +249,37 @@ const Scheduler = () => {
);
const dayBox = useCallback(
(si: ScheduleItem, flag: number) => (
<>
<Box>
<Typography
sx={{ fontSize: 11 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[Math.log(flag) / Math.log(2)]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
),
(si: ScheduleItem, flag: number) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
return (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
},
[dow]
);
const scheduleType = useCallback(
(si: ScheduleItem) => (
const scheduleType = useCallback((si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags];
return (
<Box>
<Typography sx={{ fontSize: 11 }} color="primary">
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
<>Immediate</>
) : si.flags === ScheduleFlag.SCHEDULE_TIMER ? (
<>Timer</>
) : si.flags === ScheduleFlag.SCHEDULE_CONDITION ? (
<>Condition</>
) : si.flags === ScheduleFlag.SCHEDULE_ONCHANGE ? (
<>On Change</>
) : (
<></>
)}
{label || ''}
</Typography>
</Box>
),
[]
);
);
}, []);
const renderSchedule = () => {
const renderSchedule = useCallback(() => {
if (!schedule) {
return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
@@ -293,22 +308,15 @@ const Scheduler = () => {
{tableList.map((si: ScheduleItem) => (
<Row key={si.id} item={si} onClick={() => editScheduleItem(si)}>
<Cell stiff>
{si.active ? (
<CircleIcon
color="success"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
) : (
<CircleIcon
color="error"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
)}
<CircleIcon
color={si.active ? 'success' : 'error'}
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
/>
</Cell>
<Cell stiff>
<Stack spacing={0.5} direction="row">
<Divider orientation="vertical" flexItem />
{si.flags > 127 ? (
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
scheduleType(si)
) : (
<>
@@ -334,7 +342,17 @@ const Scheduler = () => {
)}
</Table>
);
};
}, [
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
return (
<SectionContent>

View File

@@ -31,6 +31,34 @@ import { validate } from 'validators';
import { ScheduleFlag } from './types';
import type { ScheduleItem } from './types';
// Constants
const FLAG_MASK_127 = 127;
const SCHEDULE_TYPE_THRESHOLD = 128;
const DEFAULT_TIME = '00:00';
const TYPOGRAPHY_FONT_SIZE = 10;
// Day of week flag configuration (static, defined outside component)
const DAY_FLAGS = [
{ value: '2', flag: ScheduleFlag.SCHEDULE_MON },
{ value: '4', flag: ScheduleFlag.SCHEDULE_TUE },
{ value: '8', flag: ScheduleFlag.SCHEDULE_WED },
{ value: '16', flag: ScheduleFlag.SCHEDULE_THU },
{ value: '32', flag: ScheduleFlag.SCHEDULE_FRI },
{ value: '64', flag: ScheduleFlag.SCHEDULE_SAT },
{ value: '1', flag: ScheduleFlag.SCHEDULE_SUN }
] as const;
// Day of week flag values array (static)
const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SUN,
ScheduleFlag.SCHEDULE_MON,
ScheduleFlag.SCHEDULE_TUE,
ScheduleFlag.SCHEDULE_WED,
ScheduleFlag.SCHEDULE_THU,
ScheduleFlag.SCHEDULE_FRI,
ScheduleFlag.SCHEDULE_SAT
] as const;
interface SchedulerDialogProps {
open: boolean;
creating: boolean;
@@ -55,20 +83,30 @@ const SchedulerDialog = ({
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
// set the flags based on type when page is loaded...
// Set the flags based on type when page is loaded:
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setScheduleType(
selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags
selectedItem.flags < SCHEDULE_TYPE_THRESHOLD
? ScheduleFlag.SCHEDULE_DAY
: selectedItem.flags
);
}
}, [open, selectedItem]);
@@ -101,32 +139,24 @@ const SchedulerDialog = ({
// Optimize DOW flag conversion
const getFlagDOWnumber = useCallback((flags: string[]) => {
return flags.reduce((acc, flag) => acc | Number(flag), 0) & 127;
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
}, []);
const getFlagDOWstring = useCallback((f: number) => {
const flagValues = [
ScheduleFlag.SCHEDULE_SUN,
ScheduleFlag.SCHEDULE_MON,
ScheduleFlag.SCHEDULE_TUE,
ScheduleFlag.SCHEDULE_WED,
ScheduleFlag.SCHEDULE_THU,
ScheduleFlag.SCHEDULE_FRI,
ScheduleFlag.SCHEDULE_SAT
];
return flagValues
.filter((flag) => (f & flag) === flag)
.map((flag) => String(flag));
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
String(flag)
);
}, []);
// Day of week display component
const DayOfWeekButton = useMemo(
() => (flag: number) => {
const DayOfWeekButton = useCallback(
(flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: 10 }}
color={(editItem.flags & flag) === flag ? 'primary' : 'grey'}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
@@ -152,25 +182,37 @@ const SchedulerDialog = ({
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? 0 : flag;
setEditItem({ ...editItem, time: '', flags: newFlags });
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
},
[editItem]
[]
);
const handleDOWChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
const newFlags = getFlagDOWnumber(flags);
setEditItem({ ...editItem, flags: newFlags });
setEditItem((prev) => ({ ...prev, flags: newFlags }));
},
[editItem, getFlagDOWnumber]
[getFlagDOWnumber]
);
// Memoize derived values
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE;
const needsTimeField = isDaySchedule || isTimerSchedule;
const isDaySchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
[scheduleType]
);
const isTimerSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
[scheduleType]
);
const isImmediateSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
[scheduleType]
);
const needsTimeField = useMemo(
() => isDaySchedule || isTimerSchedule,
[isDaySchedule, isTimerSchedule]
);
const dowFlags = useMemo(
() => getFlagDOWstring(editItem.flags),
@@ -179,9 +221,9 @@ const SchedulerDialog = ({
const timeFieldValue = useMemo(() => {
if (needsTimeField) {
return editItem.time === '' ? '00:00' : editItem.time;
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
}
return editItem.time === '00:00' ? '' : editItem.time;
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
}, [editItem.time, needsTimeField]);
const timeFieldLabel = useMemo(() => {
@@ -192,21 +234,10 @@ const SchedulerDialog = ({
return LL.TIME(1);
}, [scheduleType, LL]);
// Day of week configuration
const dayFlags = [
{ value: '2', flag: ScheduleFlag.SCHEDULE_MON },
{ value: '4', flag: ScheduleFlag.SCHEDULE_TUE },
{ value: '8', flag: ScheduleFlag.SCHEDULE_WED },
{ value: '16', flag: ScheduleFlag.SCHEDULE_THU },
{ value: '32', flag: ScheduleFlag.SCHEDULE_FRI },
{ value: '64', flag: ScheduleFlag.SCHEDULE_SAT },
{ value: '1', flag: ScheduleFlag.SCHEDULE_SUN }
];
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}&nbsp;
{LL.SCHEDULE(1)}
</DialogTitle>
<DialogContent dividers>
@@ -220,7 +251,7 @@ const SchedulerDialog = ({
>
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
<Typography
sx={{ fontSize: 10 }}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isDaySchedule ? 'primary' : 'grey'}
>
{LL.SCHEDULE(0)}
@@ -228,7 +259,7 @@ const SchedulerDialog = ({
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
<Typography
sx={{ fontSize: 10 }}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isTimerSchedule ? 'primary' : 'grey'}
>
{LL.TIMER(0)}
@@ -236,7 +267,7 @@ const SchedulerDialog = ({
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}>
<Typography
sx={{ fontSize: 10 }}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
}
@@ -246,7 +277,7 @@ const SchedulerDialog = ({
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
<Typography
sx={{ fontSize: 10 }}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
}
@@ -256,7 +287,7 @@ const SchedulerDialog = ({
</ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography
sx={{ fontSize: 10 }}
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isImmediateSchedule ? 'primary' : 'grey'}
>
{LL.IMMEDIATE()}
@@ -271,7 +302,7 @@ const SchedulerDialog = ({
value={dowFlags}
onChange={handleDOWChange}
>
{dayFlags.map(({ value, flag }) => (
{DAY_FLAGS.map(({ value, flag }) => (
<ToggleButton key={value} value={value}>
{DayOfWeekButton(flag)}
</ToggleButton>

View File

@@ -49,6 +49,27 @@ import {
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 DEFAULT_GPIO = 21; // Safe GPIO for all platforms
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;
@@ -130,11 +151,13 @@ const Sensors = () => {
}
);
useInterval(() => {
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]);
@@ -160,11 +183,16 @@ const Sensors = () => {
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
GPIO: (array) => array.sort((a, b) => a.g - b.g),
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
TYPE: (array) => array.sort((a, b) => a.t - b.t),
VALUE: (array) => array.sort((a, b) => a.v - b.v)
GPIO: (array) =>
[...array].sort((a, b) => (a as AnalogSensor).g - (b as AnalogSensor).g),
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)
}
}
);
@@ -180,9 +208,15 @@ const Sensors = () => {
},
sortToggleType: SortToggleType.AlternateWithReset,
sortFns: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)),
VALUE: (array) => array.sort((a, b) => a.t - b.t)
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)
)
}
}
);
@@ -191,21 +225,22 @@ const Sensors = () => {
const formatDurationMin = useCallback(
(duration_min: number) => {
const days = Math.trunc((duration_min * 60000) / 86400000);
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
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;
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days }) + ' ';
const parts: string[] = [];
if (days > 0) {
parts.push(LL.NUM_DAYS({ num: days }));
}
if (hours) {
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
if (hours > 0) {
parts.push(LL.NUM_HOURS({ num: hours }));
}
if (minutes) {
formatted += LL.NUM_MINUTES({ num: minutes });
if (minutes > 0) {
parts.push(LL.NUM_MINUTES({ num: minutes }));
}
return formatted;
return parts.join(' ');
},
[LL]
);
@@ -298,9 +333,9 @@ const Sensors = () => {
const addAnalogSensor = useCallback(() => {
setCreating(true);
setSelectedAnalogSensor({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID),
n: '',
g: 21, // default GPIO 21 which is safe for all platforms
g: DEFAULT_GPIO,
u: 0,
v: 0,
o: 0,
@@ -354,7 +389,7 @@ const Sensors = () => {
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
>
@@ -364,7 +399,7 @@ const Sensors = () => {
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
>
@@ -374,7 +409,7 @@ const Sensors = () => {
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
>
@@ -384,7 +419,7 @@ const Sensors = () => {
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() =>
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
@@ -401,12 +436,16 @@ const Sensors = () => {
<Cell stiff>{a.g}</Cell>
<Cell>{a.n}</Cell>
<Cell stiff>{AnalogTypeNames[a.t]} </Cell>
{(a.t === AnalogType.DIGITAL_OUT && a.g !== 25 && a.g !== 26) ||
{(a.t === AnalogType.DIGITAL_OUT &&
a.g !== GPIO_25 &&
a.g !== GPIO_26) ||
a.t === AnalogType.DIGITAL_IN ||
a.t === AnalogType.PULSE ? (
<Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell>
) : (
<Cell stiff>{a.t ? formatValue(a.v, a.u) : ''}</Cell>
<Cell stiff>
{a.t !== AnalogType.NOTUSED ? formatValue(a.v, a.u) : ''}
</Cell>
)}
</Row>
))}
@@ -441,7 +480,7 @@ const Sensors = () => {
<HeaderCell resize>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }}
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
@@ -453,7 +492,7 @@ const Sensors = () => {
<HeaderCell stiff>
<Button
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })

View File

@@ -48,22 +48,49 @@ const SensorsAnalogDialog = ({
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
// Helper functions to check sensor type conditions
const isCounterOrRate =
editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE;
const isFreqType =
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
const isPWM =
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2;
const isDigitalOutGPIO =
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26);
const isDigitalOutNonGPIO =
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
const updateFormValue = useMemo(
() =>
updateValue((updater) =>
setEditItem(
(prev) =>
updater(
prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor
)
),
[setEditItem]
);
// Memoize helper functions to check sensor type conditions
const isCounterOrRate = useMemo(
() => editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE,
[editItem.t]
);
const isFreqType = useMemo(
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
[editItem.t]
);
const isPWM = useMemo(
() =>
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2,
[editItem.t]
);
const isDigitalOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26),
[editItem.t, editItem.g]
);
const isDigitalOutNonGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26,
[editItem.t, editItem.g]
);
// Memoize menu items to avoid recreation on each render
const analogTypeMenuItems = useMemo(
@@ -86,6 +113,7 @@ const SensorsAnalogDialog = ({
[]
);
// Reset form when dialog opens or selectedItem changes
useEffect(() => {
if (open) {
setFieldErrors(undefined);
@@ -113,16 +141,18 @@ const SensorsAnalogDialog = ({
}, [validator, editItem, onSave]);
const remove = useCallback(() => {
editItem.d = true;
onSave(editItem);
onSave({ ...editItem, d: true });
}, [editItem, onSave]);
const dialogTitle = useMemo(
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{LL.ANALOG_SENSOR(0)}
</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
<Grid>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
@@ -33,6 +33,12 @@ interface SensorsTemperatureDialogProps {
validator: Schema;
}
// Constants
const OFFSET_MIN = -5;
const OFFSET_MAX = 5;
const OFFSET_STEP = 0.1;
const TEMP_UNIT = '°C';
const SensorsTemperatureDialog = ({
open,
onClose,
@@ -43,7 +49,18 @@ const SensorsTemperatureDialog = ({
const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as (
updater: (
prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown>
) => void
),
[setEditItem]
);
useEffect(() => {
if (open) {
@@ -53,7 +70,7 @@ const SensorsTemperatureDialog = ({
}, [open, selectedItem]);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
(_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') {
onClose();
}
@@ -71,13 +88,29 @@ const SensorsTemperatureDialog = ({
}
}, [validator, editItem, onSave]);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
const slotProps = useMemo(
() => ({
input: {
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}),
[]
);
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{LL.EDIT()}&nbsp;{LL.TEMP_SENSOR()}
</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Box color="warning.main" mb={2}>
<Typography variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
</Typography>
@@ -85,7 +118,7 @@ const SensorsTemperatureDialog = ({
<Grid container spacing={2}>
<Grid>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
fieldErrors={fieldErrors ?? {}}
name="n"
label={LL.NAME(0)}
value={editItem.n}
@@ -97,19 +130,12 @@ const SensorsTemperatureDialog = ({
<TextField
name="o"
label={LL.OFFSET()}
value={numberValue(editItem.o)}
value={offsetValue}
sx={{ width: '11ch' }}
type="number"
variant="outlined"
onChange={updateFormValue}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">°C</InputAdornment>
)
},
htmlInput: { min: '-5', max: '5', step: '0.1' }
}}
slotProps={slotProps}
/>
</Grid>
</Grid>

View File

@@ -60,7 +60,7 @@ export interface Stat {
}
export interface Activity {
stats: Stat[];
readonly stats: readonly Stat[];
}
export interface Device {
@@ -112,8 +112,8 @@ export interface SensorData {
}
export interface CoreData {
connected: boolean;
devices: Device[];
readonly connected: boolean;
readonly devices: readonly Device[];
}
export interface DashboardItem {
@@ -126,8 +126,8 @@ export interface DashboardItem {
}
export interface DashboardData {
connected: boolean; // true if connected to EMS bus
nodes: DashboardItem[];
readonly connected: boolean; // true if connected to EMS bus
readonly nodes: readonly DashboardItem[];
}
export interface DeviceValue {
@@ -140,10 +140,11 @@ export interface DeviceValue {
s?: string; // steps for up/down, optional
m?: number; // min, optional
x?: number; // max, optional
[key: string]: unknown;
}
export interface DeviceData {
nodes: DeviceValue[];
readonly nodes: readonly DeviceValue[];
}
export interface DeviceEntity {
@@ -222,7 +223,7 @@ export const DeviceValueUOM_s = [
'l/h',
'ct/kWh',
'Hz'
];
] as const;
export enum AnalogType {
REMOVED = -1,
@@ -261,11 +262,9 @@ export const AnalogTypeNames = [
'Freq 0',
'Freq 1',
'Freq 2'
];
] as const;
type BoardProfiles = Record<string, string>;
export const BOARD_PROFILES: BoardProfiles = {
export const BOARD_PROFILES = {
S32: 'BBQKees Gateway S32',
S32S3: 'BBQKees Gateway S3',
E32: 'BBQKees Gateway E32',
@@ -279,7 +278,9 @@ export const BOARD_PROFILES: BoardProfiles = {
C3MINI: 'Wemos C3 Mini',
S2MINI: 'Wemos S2 Mini',
S3MINI: 'Liligo S3'
};
} as const;
export type BoardProfileKey = keyof typeof BOARD_PROFILES;
export interface BoardProfile {
board_profile: string;
@@ -347,7 +348,7 @@ export interface ScheduleItem {
}
export interface Schedule {
schedule: ScheduleItem[];
readonly schedule: readonly ScheduleItem[];
}
export interface ModuleItem {
@@ -365,7 +366,7 @@ export interface ModuleItem {
}
export interface Modules {
modules: ModuleItem[];
readonly modules: readonly ModuleItem[];
}
export enum ScheduleFlag {
@@ -414,7 +415,7 @@ export interface EntityItem {
}
export interface Entities {
entities: EntityItem[];
readonly entities: readonly EntityItem[];
}
// matches emsdevice.h DeviceType
@@ -470,4 +471,4 @@ export const DeviceValueTypeNames = [
'ENUM',
'RAW',
'CMD'
];
] as const;

View File

@@ -11,6 +11,40 @@ import type {
TemperatureSensor
} from './types';
// Constants
const ERROR_MESSAGES = {
GPIO_INVALID: 'Must be an valid GPIO port',
NAME_DUPLICATE: 'Name already in use',
GPIO_DUPLICATE: 'GPIO already in use',
VALUE_OUT_OF_RANGE: 'Value out of range',
HEX_REQUIRED: 'Is required and must be in hex format'
} as const;
const VALIDATION_LIMITS = {
PORT_MIN: 0,
PORT_MAX: 65535,
MODBUS_MAX_CLIENTS_MIN: 0,
MODBUS_MAX_CLIENTS_MAX: 50,
MODBUS_TIMEOUT_MIN: 100,
MODBUS_TIMEOUT_MAX: 20000,
SYSLOG_MARK_INTERVAL_MIN: 0,
SYSLOG_MARK_INTERVAL_MAX: 10,
SHOWER_MIN_DURATION_MIN: 10,
SHOWER_MIN_DURATION_MAX: 360,
SHOWER_ALERT_TRIGGER_MIN: 1,
SHOWER_ALERT_TRIGGER_MAX: 20,
SHOWER_ALERT_COLDSHOT_MIN: 1,
SHOWER_ALERT_COLDSHOT_MAX: 10,
REMOTE_TIMEOUT_MIN: 1,
REMOTE_TIMEOUT_MAX: 240,
OFFSET_MIN: 0,
OFFSET_MAX: 255,
COMMAND_MIN: 1,
COMMAND_MAX: 300,
NAME_MAX_LENGTH: 19,
HEX_BASE: 16
} as const;
// Helper to create GPIO validator from invalid ranges
const createGPIOValidator = (
invalidRanges: Array<number | [number, number]>,
@@ -27,20 +61,20 @@ const createGPIOValidator = (
}
if (value < 0 || value > maxValue) {
callback('Must be an valid GPIO port');
callback(ERROR_MESSAGES.GPIO_INVALID);
return;
}
for (const range of invalidRanges) {
if (typeof range === 'number') {
if (value === range) {
callback('Must be an valid GPIO port');
callback(ERROR_MESSAGES.GPIO_INVALID);
return;
}
} else {
const [start, end] = range;
if (value >= start && value <= end) {
callback('Must be an valid GPIO port');
callback(ERROR_MESSAGES.GPIO_INVALID);
return;
}
}
@@ -93,9 +127,9 @@ const createGPIOValidations = (
): Record<string, ValidationRules> =>
GPIO_FIELD_NAMES.reduce(
(acc, field) => {
const fieldName = field.replace('_gpio', '');
const fieldName = field.replace('_gpio', '').toUpperCase();
acc[field] = [
{ required: true, message: `${fieldName.toUpperCase()} GPIO is required` },
{ required: true, message: `${fieldName} GPIO is required` },
validator
];
return acc;
@@ -134,11 +168,21 @@ export const createSettingsValidator = (settings: Settings) => {
];
schema.syslog_port = [
{ required: true, message: 'Port is required' },
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
{
type: 'number',
min: VALIDATION_LIMITS.PORT_MIN,
max: VALIDATION_LIMITS.PORT_MAX,
message: 'Invalid Port'
}
];
schema.syslog_mark_interval = [
{ required: true, message: 'Mark interval is required' },
{ type: 'number', min: 0, max: 10, message: 'Must be between 0 and 10' }
{
type: 'number',
min: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN,
max: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX,
message: `Must be between ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN} and ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX}`
}
];
}
@@ -146,19 +190,29 @@ export const createSettingsValidator = (settings: Settings) => {
if (settings.modbus_enabled) {
schema.modbus_max_clients = [
{ required: true, message: 'Max clients is required' },
{ type: 'number', min: 0, max: 50, message: 'Invalid number' }
{
type: 'number',
min: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MIN,
max: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MAX,
message: 'Invalid number'
}
];
schema.modbus_port = [
{ required: true, message: 'Port is required' },
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
{
type: 'number',
min: VALIDATION_LIMITS.PORT_MIN,
max: VALIDATION_LIMITS.PORT_MAX,
message: 'Invalid Port'
}
];
schema.modbus_timeout = [
{ required: true, message: 'Timeout is required' },
{
type: 'number',
min: 100,
max: 20000,
message: 'Must be between 100 and 20000'
min: VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN,
max: VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX,
message: `Must be between ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN} and ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX}`
}
];
}
@@ -168,9 +222,9 @@ export const createSettingsValidator = (settings: Settings) => {
schema.shower_min_duration = [
{
type: 'number',
min: 10,
max: 360,
message: 'Time must be between 10 and 360 seconds'
min: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN,
max: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN} and ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX} seconds`
}
];
}
@@ -180,17 +234,17 @@ export const createSettingsValidator = (settings: Settings) => {
schema.shower_alert_trigger = [
{
type: 'number',
min: 1,
max: 20,
message: 'Time must be between 1 and 20 minutes'
min: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN,
max: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX} minutes`
}
];
schema.shower_alert_coldshot = [
{
type: 'number',
min: 1,
max: 10,
message: 'Time must be between 1 and 10 seconds'
min: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN,
max: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX} seconds`
}
];
}
@@ -200,9 +254,9 @@ export const createSettingsValidator = (settings: Settings) => {
schema.remote_timeout = [
{
type: 'number',
min: 1,
max: 240,
message: 'Timeout must be between 1 and 240 hours'
min: VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN,
max: VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX,
message: `Timeout must be between ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN} and ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX} hours`
}
];
}
@@ -226,10 +280,10 @@ const createUniqueNameValidator = <T extends { name: string }>(
originalName.toLowerCase() !== name.toLowerCase()) &&
items.find((item) => item.name.toLowerCase() === name.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
callback(ERROR_MESSAGES.NAME_DUPLICATE);
return;
}
callback();
}
});
@@ -250,23 +304,30 @@ const createUniqueFieldNameValidator = <T>(
originalName.toLowerCase() !== name.toLowerCase()) &&
items.find((item) => getName(item).toLowerCase() === name.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
callback(ERROR_MESSAGES.NAME_DUPLICATE);
return;
}
callback();
}
});
const NAME_PATTERN_BASE = '[a-zA-Z0-9_]';
const NAME_PATTERN_MESSAGE = `Must be <${VALIDATION_LIMITS.NAME_MAX_LENGTH + 1} characters: alphanumeric or '_'`;
const NAME_PATTERN = {
type: 'string' as const,
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
pattern: new RegExp(
`^${NAME_PATTERN_BASE}{0,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
),
message: NAME_PATTERN_MESSAGE
};
const NAME_PATTERN_REQUIRED = {
type: 'string' as const,
pattern: /^[a-zA-Z0-9_]{1,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
pattern: new RegExp(
`^${NAME_PATTERN_BASE}{1,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
),
message: NAME_PATTERN_MESSAGE
};
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =>
@@ -282,9 +343,9 @@ export const schedulerItemValidation = (
{ required: true, message: 'Command is required' },
{
type: 'string',
min: 1,
max: 300,
message: 'Command must be 1-300 characters'
min: VALIDATION_LIMITS.COMMAND_MIN,
max: VALIDATION_LIMITS.COMMAND_MAX,
message: `Command must be ${VALIDATION_LIMITS.COMMAND_MIN}-${VALIDATION_LIMITS.COMMAND_MAX} characters`
}
]
});
@@ -298,11 +359,11 @@ const hexValidator = {
value: string,
callback: (error?: string) => void
) {
if (!value || isNaN(parseInt(value, 16))) {
callback('Is required and must be in hex format');
} else {
callback();
if (!value || Number.isNaN(Number.parseInt(value, VALIDATION_LIMITS.HEX_BASE))) {
callback(ERROR_MESSAGES.HEX_REQUIRED);
return;
}
callback();
}
};
@@ -317,7 +378,12 @@ export const entityItemValidation = (entity: EntityItem[], entityItem: EntityIte
type_id: [hexValidator],
offset: [
{ required: true, message: 'Offset is required' },
{ type: 'number', min: 0, max: 255, message: 'Must be between 0 and 255' }
{
type: 'number',
min: VALIDATION_LIMITS.OFFSET_MIN,
max: VALIDATION_LIMITS.OFFSET_MAX,
message: `Must be between ${VALIDATION_LIMITS.OFFSET_MIN} and ${VALIDATION_LIMITS.OFFSET_MAX}`
}
],
factor: [{ required: true, message: 'is required' }]
});
@@ -341,11 +407,11 @@ export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
gpio: number,
callback: (error?: string) => void
) {
if (sensors.find((as) => as.g === gpio)) {
callback('GPIO already in use');
} else {
callback();
if (sensors.some((as) => as.g === gpio)) {
callback(ERROR_MESSAGES.GPIO_DUPLICATE);
return;
}
callback();
}
});
@@ -354,20 +420,26 @@ export const uniqueAnalogNameValidator = (
o_name?: string
) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
const getPlatformGPIOValidator = (platform: string) => {
switch (platform) {
case 'ESP32S3':
return GPIO_VALIDATORS3;
case 'ESP32S2':
return GPIO_VALIDATORS2;
case 'ESP32C3':
return GPIO_VALIDATORC3;
default:
return GPIO_VALIDATOR;
}
};
export const analogSensorItemValidation = (
sensors: AnalogSensor[],
sensor: AnalogSensor,
creating: boolean,
platform: string
) => {
const gpioValidator =
platform === 'ESP32S3'
? GPIO_VALIDATORS3
: platform === 'ESP32S2'
? GPIO_VALIDATORS2
: platform === 'ESP32C3'
? GPIO_VALIDATORC3
: GPIO_VALIDATOR;
const gpioValidator = getPlatformGPIOValidator(platform);
return new Schema({
n: [NAME_PATTERN, uniqueAnalogNameValidator(sensors, sensor.o_n)],
@@ -395,10 +467,10 @@ export const deviceValueItemValidation = (dv: DeviceValue) =>
dv.x !== undefined &&
(value < dv.m || value > dv.x)
) {
callback('Value out of range');
} else {
callback();
callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE);
return;
}
callback();
}
}
]