mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-06 15:59:52 +03:00
optimizations
This commit is contained in:
@@ -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,9 +86,11 @@ const CustomEntities = () => {
|
||||
ei.deleted !== ei.o_deleted ||
|
||||
(ei.value || '') !== (ei.o_value || '')
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const entity_theme = useTheme({
|
||||
const entity_theme = useMemo(
|
||||
() =>
|
||||
useTheme({
|
||||
Table: `
|
||||
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
|
||||
`,
|
||||
@@ -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,17 +181,18 @@ 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) => {
|
||||
const onDialogSave = useCallback(
|
||||
(updatedItem: EntityItem) => {
|
||||
setDialogOpen(false);
|
||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||
const new_data = creating
|
||||
@@ -195,12 +206,14 @@ const CustomEntities = () => {
|
||||
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}
|
||||
{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>
|
||||
|
||||
@@ -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'
|
||||
) => {
|
||||
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) {
|
||||
|
||||
@@ -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) {
|
||||
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 |
|
||||
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE)
|
||||
: de.m &
|
||||
~(
|
||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
||||
DeviceEntityMask.DV_WEB_EXCLUDE
|
||||
)
|
||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||
};
|
||||
} else {
|
||||
return de;
|
||||
}
|
||||
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) =>
|
||||
(prev) =>
|
||||
prev?.map((de) =>
|
||||
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
||||
)
|
||||
) ?? []
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onDialogSave = (updatedItem: DeviceEntity) => {
|
||||
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,15 +401,18 @@ const Customizations = () => {
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const saveCustomization = async () => {
|
||||
if (devices && deviceEntities && selectedDevice !== -1) {
|
||||
const saveCustomization = useCallback(async () => {
|
||||
if (!devices || !deviceEntities || selectedDevice === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const masked_entities = deviceEntities
|
||||
.filter((de: DeviceEntity) => hasEntityChanged(de))
|
||||
.map((new_de) => createMaskedEntityId(new_de));
|
||||
|
||||
// check size in bytes to match buffer in CPP, which is 2048
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
|
||||
if (bytes > 2000) {
|
||||
if (bytes > MAX_BUFFER_SIZE) {
|
||||
toast.warning(LL.CUSTOMIZATIONS_FULL());
|
||||
return;
|
||||
}
|
||||
@@ -419,22 +434,21 @@ const Customizations = () => {
|
||||
.finally(() => {
|
||||
setOriginalSettings(deviceEntities);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
|
||||
|
||||
const renameDevice = async () => {
|
||||
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 = () => (
|
||||
<>
|
||||
|
||||
@@ -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}:
|
||||
</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} />
|
||||
|
||||
@@ -133,7 +133,7 @@ const Dashboard = memo(() => {
|
||||
);
|
||||
|
||||
const tree = useTree(
|
||||
{ nodes: data.nodes },
|
||||
{ nodes: [...data.nodes] },
|
||||
{
|
||||
onChange: () => {} // not used but needed
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -118,30 +118,28 @@ const Devices = memo(() => {
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
function updateSize() {
|
||||
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 }}
|
||||
|
||||
@@ -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[]) => {
|
||||
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;
|
||||
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
||||
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||
} else {
|
||||
de.m = newMask;
|
||||
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 (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 =
|
||||
// Memoize mask string value
|
||||
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
|
||||
|
||||
// Memoize disabled states
|
||||
const isFavoriteDisabled = useMemo(
|
||||
() =>
|
||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
|
||||
de.n === undefined;
|
||||
de.n === undefined,
|
||||
[de.m, de.n]
|
||||
);
|
||||
|
||||
// Check if readonly button should be disabled
|
||||
const isReadonlyDisabled =
|
||||
const isReadonlyDisabled = useMemo(
|
||||
() =>
|
||||
!de.w ||
|
||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE);
|
||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
|
||||
[de.w, de.m]
|
||||
);
|
||||
|
||||
// Check if api/mqtt exclude button should be disabled
|
||||
const isApiMqttExcludeDisabled =
|
||||
de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED);
|
||||
const isApiMqttExcludeDisabled = useMemo(
|
||||
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||
[de.n, de.m]
|
||||
);
|
||||
|
||||
// Check if web exclude button should be disabled
|
||||
const isWebExcludeDisabled =
|
||||
de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED);
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,6 +150,7 @@ const Modules = () => {
|
||||
}, [fetchModules]);
|
||||
|
||||
const saveModules = useCallback(async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
modules.map((condensed_mi: ModuleItem) =>
|
||||
updateModules({
|
||||
@@ -159,17 +159,14 @@ const Modules = () => {
|
||||
license: condensed_mi.license
|
||||
})
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
);
|
||||
toast.success(LL.MODULES_UPDATED());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(async () => {
|
||||
} 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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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]: [
|
||||
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);
|
||||
|
||||
@@ -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,70 +144,32 @@ 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 () => {
|
||||
try {
|
||||
await updateSchedule({
|
||||
schedule: schedule
|
||||
.filter((si: ScheduleItem) => !si.deleted)
|
||||
@@ -150,17 +182,15 @@ const Scheduler = () => {
|
||||
value: condensed_si.value,
|
||||
name: condensed_si.name
|
||||
}))
|
||||
})
|
||||
.then(() => {
|
||||
});
|
||||
toast.success(LL.SCHEDULE_UPDATED());
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(async () => {
|
||||
} 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) => (
|
||||
(si: ScheduleItem, flag: number) => {
|
||||
const dayIndex = Math.log(flag) / LOG_2;
|
||||
const isActive = (si.flags & flag) === flag;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<Typography
|
||||
sx={{ fontSize: 11 }}
|
||||
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
|
||||
>
|
||||
{dow[Math.log(flag) / Math.log(2)]}
|
||||
<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' }}
|
||||
color={si.active ? 'success' : 'error'}
|
||||
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
|
||||
/>
|
||||
) : (
|
||||
<CircleIcon
|
||||
color="error"
|
||||
sx={{ fontSize: 16, 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>
|
||||
|
||||
@@ -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()}
|
||||
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}
|
||||
{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>
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 =
|
||||
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;
|
||||
const isDigitalOutGPIO =
|
||||
editItem.t === AnalogType.PWM_2,
|
||||
[editItem.t]
|
||||
);
|
||||
const isDigitalOutGPIO = useMemo(
|
||||
() =>
|
||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||
(editItem.g === 25 || editItem.g === 26);
|
||||
const isDigitalOutNonGPIO =
|
||||
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
|
||||
(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()} ${LL.ANALOG_SENSOR(0)}`,
|
||||
[creating, LL]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||
<DialogTitle>
|
||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}
|
||||
{LL.ANALOG_SENSOR(0)}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container spacing={2}>
|
||||
<Grid>
|
||||
|
||||
@@ -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()} {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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user