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

View File

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

View File

@@ -62,16 +62,24 @@ import OptionIcon from './OptionIcon';
import { DeviceEntityMask } from './types'; import { DeviceEntityMask } from './types';
import type { APIcall, Device, DeviceEntity } 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 // Helper function to create masked entity ID - extracted to avoid duplication
const createMaskedEntityId = (de: DeviceEntity): string => const createMaskedEntityId = (de: DeviceEntity): string => {
de.m.toString(16).padStart(2, '0') + const maskHex = de.m.toString(16).padStart(2, '0');
de.id + const hasCustomizations = !!(de.cn || de.mi || de.ma);
(de.cn || de.mi || de.ma ? '|' : '') + const customizations = [
(de.cn ? de.cn : '') + de.cn || '',
(de.mi ? '>' + de.mi : '') + de.mi ? `>${de.mi}` : '',
(de.ma ? '<' + de.ma : ''); de.ma ? `<${de.ma}` : ''
]
.filter(Boolean)
.join('');
return `${maskHex}${de.id}${hasCustomizations ? `|${customizations}` : ''}`;
};
const Customizations = () => { const Customizations = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -277,18 +285,22 @@ const Customizations = () => {
return value as string; return value as string;
} }
const formatName = (de: DeviceEntity, withShortname: boolean) => const formatName = useCallback(
(de.n && de.n[0] === '!' (de: DeviceEntity, withShortname: boolean) => {
? de.t let name: string;
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1) if (de.n && de.n[0] === '!') {
: LL.COMMAND(1) + ': ' + de.n.slice(1) name = de.t
: de.cn && de.cn !== '' ? `${LL.COMMAND(1)}: ${de.t} ${de.n.slice(1)}`
? de.t : `${LL.COMMAND(1)}: ${de.n.slice(1)}`;
? de.t + ' ' + de.cn } else if (de.cn && de.cn !== '') {
: de.cn name = de.t ? `${de.t} ${de.cn}` : de.cn;
: de.t } else {
? de.t + ' ' + de.n name = de.t ? `${de.t} ${de.n}` : de.n || '';
: de.n) + (withShortname ? ' ' + de.id : ''); }
return withShortname ? `${name} ${de.id}` : name;
},
[LL]
);
const getMaskNumber = (newMask: string[]) => { const getMaskNumber = (newMask: string[]) => {
let new_mask = 0; let new_mask = 0;
@@ -322,33 +334,29 @@ const Customizations = () => {
(de: DeviceEntity) => (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) && (de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()), formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search] [selectedFilters, search, formatName]
); );
const maskDisabled = (set: boolean) => { const maskDisabled = useCallback(
setDeviceEntities( (set: boolean) => {
deviceEntities.map(function (de) { setDeviceEntities((prev) =>
if (filter_entity(de)) { prev.map((de) => {
return { if (filter_entity(de)) {
...de, const excludeMask =
m: set DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
? de.m | return {
(DeviceEntityMask.DV_API_MQTT_EXCLUDE | ...de,
DeviceEntityMask.DV_WEB_EXCLUDE) m: set ? de.m | excludeMask : de.m & ~excludeMask
: de.m & };
~( }
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE
)
};
} else {
return de; return de;
} })
}) );
); },
}; [filter_entity]
);
const resetCustomization = async () => { const resetCustomization = useCallback(async () => {
try { try {
await sendResetCustomizations(); await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART()); toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -357,24 +365,28 @@ const Customizations = () => {
} finally { } finally {
setConfirmReset(false); setConfirmReset(false);
} }
}; }, [sendResetCustomizations, LL]);
const onDialogClose = () => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const updateDeviceEntity = (updatedItem: DeviceEntity) => { const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setDeviceEntities( setDeviceEntities(
deviceEntities?.map((de) => (prev) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de prev?.map((de) =>
) de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ?? []
); );
}; }, []);
const onDialogSave = (updatedItem: DeviceEntity) => { const onDialogSave = useCallback(
setDialogOpen(false); (updatedItem: DeviceEntity) => {
updateDeviceEntity(updatedItem); setDialogOpen(false);
}; updateDeviceEntity(updatedItem);
},
[updateDeviceEntity]
);
const editDeviceEntity = useCallback((de: DeviceEntity) => { const editDeviceEntity = useCallback((de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) { if (de.n === undefined || (de.n && de.n[0] === '!')) {
@@ -389,52 +401,54 @@ const Customizations = () => {
setDialogOpen(true); setDialogOpen(true);
}, []); }, []);
const saveCustomization = async () => { const saveCustomization = useCallback(async () => {
if (devices && deviceEntities && selectedDevice !== -1) { if (!devices || !deviceEntities || selectedDevice === -1) {
const masked_entities = deviceEntities return;
.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 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 }) await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
.then(() => { .then(() => {
toast.success(LL.UPDATED_OF(LL.NAME(1))); toast.success(LL.UPDATED_OF(LL.NAME(1)));
}) })
.catch(() => { .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 () => { .finally(async () => {
setRename(false); setRename(false);
await fetchCoreData(); await fetchCoreData();
}); });
}; }, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]);
const renderDeviceList = () => ( 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 CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@@ -35,14 +35,17 @@ interface LabelValueProps {
value: React.ReactNode; value: React.ReactNode;
} }
const LabelValue = ({ label, value }: LabelValueProps) => ( const LabelValue = memo(({ label, value }: LabelValueProps) => (
<Grid container direction="row"> <Grid container direction="row">
<Typography variant="body2" color="warning.main"> <Typography variant="body2" color="warning.main">
{label}:&nbsp; {label}:&nbsp;
</Typography> </Typography>
<Typography variant="body2">{value}</Typography> <Typography variant="body2">{value}</Typography>
</Grid> </Grid>
); ));
LabelValue.displayName = 'LabelValue';
const ICON_SIZE = 16;
const CustomizationsDialog = ({ const CustomizationsDialog = ({
open, open,
@@ -54,7 +57,15 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem); const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false); 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( const isWriteableNumber = useMemo(
() => () =>
@@ -93,32 +104,32 @@ const CustomizationsDialog = ({
} }
}, [isWriteableNumber, editItem, onSave]); }, [isWriteableNumber, editItem, onSave]);
const updateDeviceEntity = useCallback( const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
(updatedItem: DeviceEntity) => { setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
setEditItem({ ...editItem, m: updatedItem.m }); }, []);
},
[editItem] 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 ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} /> <LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<LabelValue <LabelValue
label={LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)} label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
value={editItem.n} value={editItem.n}
/> />
<LabelValue <LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
label={LL.WRITEABLE()}
value={
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: 16 }} />
) : (
<CloseIcon color="error" sx={{ fontSize: 16 }} />
)
}
/>
<Box mt={1} mb={2}> <Box mt={1} mb={2}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />

View File

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

View File

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

View File

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

View File

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

View File

@@ -101,9 +101,12 @@ const HelpComponent = () => {
toast.error(String(error.error?.message || 'An error occurred')); 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(() => { const handleDownloadSystemInfo = useCallback(() => {
void sendAPI({ device: 'system', cmd: 'info', id: 0 }); void sendAPI(apiCall);
}, [sendAPI]); }, [sendAPI, apiCall]);
const handleImageError = useCallback(() => { const handleImageError = useCallback(() => {
setImgError(true); setImgError(true);
@@ -131,6 +134,8 @@ const HelpComponent = () => {
[LL] [LL]
); );
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
// Memoize image source computation // Memoize image source computation
const imageSrc = useMemo( const imageSrc = useMemo(
() => () =>
@@ -161,7 +166,7 @@ const HelpComponent = () => {
</Stack> </Stack>
)} )}
{me.admin && ( {isAdmin && (
<List> <List>
{helpLinks.map(({ href, icon, label }) => ( {helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}> <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 { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -34,16 +34,15 @@ import type { ModuleItem } from './types';
const PENDING_COLOR = 'red'; const PENDING_COLOR = 'red';
const ACTIVATED_COLOR = '#00FF7F'; const ACTIVATED_COLOR = '#00FF7F';
function hasModulesChanged(mi: ModuleItem): boolean { const hasModulesChanged = (mi: ModuleItem): boolean =>
return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license; mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
}
const colorStatus = (status: number) => { const ColorStatus = memo(({ status }: { status: number }) => {
if (status === 1) { if (status === 1) {
return <div style={{ color: PENDING_COLOR }}>Pending Activation</div>; return <div style={{ color: PENDING_COLOR }}>Pending Activation</div>;
} }
return <div style={{ color: ACTIVATED_COLOR }}>Activated</div>; return <div style={{ color: ACTIVATED_COLOR }}>Activated</div>;
}; });
const Modules = () => { const Modules = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -151,25 +150,23 @@ const Modules = () => {
}, [fetchModules]); }, [fetchModules]);
const saveModules = useCallback(async () => { const saveModules = useCallback(async () => {
await Promise.all( try {
modules.map((condensed_mi: ModuleItem) => await Promise.all(
updateModules({ modules.map((condensed_mi: ModuleItem) =>
key: condensed_mi.key, updateModules({
enabled: condensed_mi.enabled, key: condensed_mi.key,
license: condensed_mi.license enabled: condensed_mi.enabled,
}) license: condensed_mi.license
) })
) )
.then(() => { );
toast.success(LL.MODULES_UPDATED()); toast.success(LL.MODULES_UPDATED());
}) } catch (error) {
.catch((error: Error) => { toast.error(error instanceof Error ? error.message : String(error));
toast.error(error.message); } finally {
}) await fetchModules();
.finally(async () => { setNumChanges(0);
await fetchModules(); }
setNumChanges(0);
});
}, [modules, updateModules, LL, fetchModules]); }, [modules, updateModules, LL, fetchModules]);
const content = useMemo(() => { const content = useMemo(() => {
@@ -229,7 +226,9 @@ const Modules = () => {
<Cell>{mi.author}</Cell> <Cell>{mi.author}</Cell>
<Cell>{mi.version}</Cell> <Cell>{mi.version}</Cell>
<Cell>{mi.message}</Cell> <Cell>{mi.message}</Cell>
<Cell>{colorStatus(mi.status)}</Cell> <Cell>
<ColorStatus status={mi.status} />
</Cell>
</Row> </Row>
))} ))}
</Body> </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 CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -37,7 +37,15 @@ const ModulesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem); 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 // Sync form state when dialog opens or selected item changes
useEffect(() => { useEffect(() => {
@@ -46,9 +54,18 @@ const ModulesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleSave = useCallback(() => {
onSave(editItem);
}, [editItem, onSave]);
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return ( return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}> <Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{LL.EDIT() + ' ' + editItem.key}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container> <Grid container>
<BlockFormControlLabel <BlockFormControlLabel
@@ -86,7 +103,7 @@ const ModulesDialog = ({
<Button <Button
startIcon={<DoneIcon />} startIcon={<DoneIcon />}
variant="outlined" variant="outlined"
onClick={() => onSave(editItem)} onClick={handleSave}
color="primary" color="primary"
> >
{LL.UPDATE()} {LL.UPDATE()}

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,27 @@ import {
temperatureSensorItemValidation temperatureSensorItemValidation
} from './validators'; } 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 = { const common_theme = {
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
@@ -130,11 +151,13 @@ const Sensors = () => {
} }
); );
useInterval(() => { const intervalCallback = useCallback(() => {
if (!temperatureDialogOpen && !analogDialogOpen) { if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData(); void fetchSensorData();
} }
}); }, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
useInterval(intervalCallback);
const temperature_theme = useTheme([common_theme, temperature_theme_config]); const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]); const analog_theme = useTheme([common_theme, analog_theme_config]);
@@ -160,11 +183,16 @@ const Sensors = () => {
}, },
sortToggleType: SortToggleType.AlternateWithReset, sortToggleType: SortToggleType.AlternateWithReset,
sortFns: { sortFns: {
GPIO: (array) => array.sort((a, b) => a.g - b.g), GPIO: (array) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return [...array].sort((a, b) => (a as AnalogSensor).g - (b as AnalogSensor).g),
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)), NAME: (array) =>
TYPE: (array) => array.sort((a, b) => a.t - b.t), [...array].sort((a, b) =>
VALUE: (array) => array.sort((a, b) => a.v - b.v) (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, sortToggleType: SortToggleType.AlternateWithReset,
sortFns: { sortFns: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return NAME: (array) =>
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)), [...array].sort((a, b) =>
VALUE: (array) => array.sort((a, b) => a.t - b.t) (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( const formatDurationMin = useCallback(
(duration_min: number) => { (duration_min: number) => {
const days = Math.trunc((duration_min * 60000) / 86400000); const totalMs = duration_min * MS_PER_MINUTE;
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24; const days = Math.trunc(totalMs / MS_PER_DAY);
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60; const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
let formatted = ''; const parts: string[] = [];
if (days) { if (days > 0) {
formatted += LL.NUM_DAYS({ num: days }) + ' '; parts.push(LL.NUM_DAYS({ num: days }));
} }
if (hours) { if (hours > 0) {
formatted += LL.NUM_HOURS({ num: hours }) + ' '; parts.push(LL.NUM_HOURS({ num: hours }));
} }
if (minutes) { if (minutes > 0) {
formatted += LL.NUM_MINUTES({ num: minutes }); parts.push(LL.NUM_MINUTES({ num: minutes }));
} }
return formatted; return parts.join(' ');
}, },
[LL] [LL]
); );
@@ -298,9 +333,9 @@ const Sensors = () => {
const addAnalogSensor = useCallback(() => { const addAnalogSensor = useCallback(() => {
setCreating(true); setCreating(true);
setSelectedAnalogSensor({ 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: '', n: '',
g: 21, // default GPIO 21 which is safe for all platforms g: DEFAULT_GPIO,
u: 0, u: 0,
v: 0, v: 0,
o: 0, o: 0,
@@ -354,7 +389,7 @@ const Sensors = () => {
<HeaderCell stiff> <HeaderCell stiff>
<Button <Button
fullWidth fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }} style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')} endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })} onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
> >
@@ -364,7 +399,7 @@ const Sensors = () => {
<HeaderCell resize> <HeaderCell resize>
<Button <Button
fullWidth fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }} style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')} endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })} onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
> >
@@ -374,7 +409,7 @@ const Sensors = () => {
<HeaderCell stiff> <HeaderCell stiff>
<Button <Button
fullWidth fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }} style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')} endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })} onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
> >
@@ -384,7 +419,7 @@ const Sensors = () => {
<HeaderCell stiff> <HeaderCell stiff>
<Button <Button
fullWidth fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }} style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')} endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => onClick={() =>
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' }) analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
@@ -401,12 +436,16 @@ const Sensors = () => {
<Cell stiff>{a.g}</Cell> <Cell stiff>{a.g}</Cell>
<Cell>{a.n}</Cell> <Cell>{a.n}</Cell>
<Cell stiff>{AnalogTypeNames[a.t]} </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.DIGITAL_IN ||
a.t === AnalogType.PULSE ? ( a.t === AnalogType.PULSE ? (
<Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell> <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> </Row>
))} ))}
@@ -441,7 +480,7 @@ const Sensors = () => {
<HeaderCell resize> <HeaderCell resize>
<Button <Button
fullWidth fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }} style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')} endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() => onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' }) temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
@@ -453,7 +492,7 @@ const Sensors = () => {
<HeaderCell stiff> <HeaderCell stiff>
<Button <Button
fullWidth fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }} style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')} endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() => onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' }) temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })

View File

@@ -48,22 +48,49 @@ const SensorsAnalogDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem); const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
// Helper functions to check sensor type conditions const updateFormValue = useMemo(
const isCounterOrRate = () =>
editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE; updateValue((updater) =>
const isFreqType = setEditItem(
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2; (prev) =>
const isPWM = updater(
editItem.t === AnalogType.PWM_0 || prev as unknown as Record<string, unknown>
editItem.t === AnalogType.PWM_1 || ) as unknown as AnalogSensor
editItem.t === AnalogType.PWM_2; )
const isDigitalOutGPIO = ),
editItem.t === AnalogType.DIGITAL_OUT && [setEditItem]
(editItem.g === 25 || editItem.g === 26); );
const isDigitalOutNonGPIO =
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26; // 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 // Memoize menu items to avoid recreation on each render
const analogTypeMenuItems = useMemo( const analogTypeMenuItems = useMemo(
@@ -86,6 +113,7 @@ const SensorsAnalogDialog = ({
[] []
); );
// Reset form when dialog opens or selectedItem changes
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -113,16 +141,18 @@ const SensorsAnalogDialog = ({
}, [validator, editItem, onSave]); }, [validator, editItem, onSave]);
const remove = useCallback(() => { const remove = useCallback(() => {
editItem.d = true; onSave({ ...editItem, d: true });
onSave(editItem);
}, [editItem, onSave]); }, [editItem, onSave]);
const dialogTitle = useMemo(
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{LL.ANALOG_SENSOR(0)}
</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid> <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 CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -33,6 +33,12 @@ interface SensorsTemperatureDialogProps {
validator: Schema; validator: Schema;
} }
// Constants
const OFFSET_MIN = -5;
const OFFSET_MAX = 5;
const OFFSET_STEP = 0.1;
const TEMP_UNIT = '°C';
const SensorsTemperatureDialog = ({ const SensorsTemperatureDialog = ({
open, open,
onClose, onClose,
@@ -43,7 +49,18 @@ const SensorsTemperatureDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem); 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(() => { useEffect(() => {
if (open) { if (open) {
@@ -53,7 +70,7 @@ const SensorsTemperatureDialog = ({
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = useCallback( const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { (_event: React.SyntheticEvent, reason?: string) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
@@ -71,13 +88,29 @@ const SensorsTemperatureDialog = ({
} }
}, [validator, editItem, onSave]); }, [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 ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
{LL.EDIT()}&nbsp;{LL.TEMP_SENSOR()}
</DialogTitle>
<DialogContent dividers> <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"> <Typography variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id} {LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
</Typography> </Typography>
@@ -85,7 +118,7 @@ const SensorsTemperatureDialog = ({
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors ?? {}}
name="n" name="n"
label={LL.NAME(0)} label={LL.NAME(0)}
value={editItem.n} value={editItem.n}
@@ -97,19 +130,12 @@ const SensorsTemperatureDialog = ({
<TextField <TextField
name="o" name="o"
label={LL.OFFSET()} label={LL.OFFSET()}
value={numberValue(editItem.o)} value={offsetValue}
sx={{ width: '11ch' }} sx={{ width: '11ch' }}
type="number" type="number"
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
slotProps={{ slotProps={slotProps}
input: {
startAdornment: (
<InputAdornment position="start">°C</InputAdornment>
)
},
htmlInput: { min: '-5', max: '5', step: '0.1' }
}}
/> />
</Grid> </Grid>
</Grid> </Grid>

View File

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

View File

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