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 { 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,9 +86,11 @@ 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(
|
||||||
|
() =>
|
||||||
|
useTheme({
|
||||||
Table: `
|
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;
|
||||||
`,
|
`,
|
||||||
@@ -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,17 +181,18 @@ 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(
|
||||||
|
(updatedItem: EntityItem) => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||||
const new_data = creating
|
const new_data = creating
|
||||||
@@ -195,12 +206,14 @@ const CustomEntities = () => {
|
|||||||
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
||||||
return new_data;
|
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}
|
{ei.name}
|
||||||
{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>
|
||||||
|
|||||||
@@ -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') {
|
if (reason !== 'backdropClick') {
|
||||||
onClose();
|
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) {
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
prev.map((de) => {
|
||||||
if (filter_entity(de)) {
|
if (filter_entity(de)) {
|
||||||
|
const excludeMask =
|
||||||
|
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||||
return {
|
return {
|
||||||
...de,
|
...de,
|
||||||
m: set
|
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||||
? de.m |
|
|
||||||
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
|
||||||
DeviceEntityMask.DV_WEB_EXCLUDE)
|
|
||||||
: de.m &
|
|
||||||
~(
|
|
||||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
|
|
||||||
DeviceEntityMask.DV_WEB_EXCLUDE
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
return de;
|
|
||||||
}
|
}
|
||||||
|
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) =>
|
||||||
|
prev?.map((de) =>
|
||||||
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
||||||
)
|
) ?? []
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onDialogSave = (updatedItem: DeviceEntity) => {
|
const onDialogSave = useCallback(
|
||||||
|
(updatedItem: DeviceEntity) => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
updateDeviceEntity(updatedItem);
|
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,15 +401,18 @@ const Customizations = () => {
|
|||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveCustomization = async () => {
|
const saveCustomization = useCallback(async () => {
|
||||||
if (devices && deviceEntities && selectedDevice !== -1) {
|
if (!devices || !deviceEntities || selectedDevice === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const masked_entities = deviceEntities
|
const masked_entities = deviceEntities
|
||||||
.filter((de: DeviceEntity) => hasEntityChanged(de))
|
.filter((de: DeviceEntity) => hasEntityChanged(de))
|
||||||
.map((new_de) => createMaskedEntityId(new_de));
|
.map((new_de) => createMaskedEntityId(new_de));
|
||||||
|
|
||||||
// check size in bytes to match buffer in CPP, which is 2048
|
// check size in bytes to match buffer in CPP, which is 2048
|
||||||
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
|
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
|
||||||
if (bytes > 2000) {
|
if (bytes > MAX_BUFFER_SIZE) {
|
||||||
toast.warning(LL.CUSTOMIZATIONS_FULL());
|
toast.warning(LL.CUSTOMIZATIONS_FULL());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -419,22 +434,21 @@ const Customizations = () => {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setOriginalSettings(deviceEntities);
|
setOriginalSettings(deviceEntities);
|
||||||
});
|
});
|
||||||
}
|
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
|
||||||
};
|
|
||||||
|
|
||||||
const renameDevice = async () => {
|
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 = () => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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}:
|
{label}:
|
||||||
</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} />
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -118,30 +118,28 @@ const Devices = memo(() => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
function updateSize() {
|
let raf = 0;
|
||||||
|
const updateSize = () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
setSize([window.innerWidth, window.innerHeight]);
|
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 }}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
(_event: unknown, mask: string[]) => {
|
||||||
// Convert selected masks to a number
|
// Convert selected masks to a number
|
||||||
const newMask = getMaskNumber(mask);
|
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]);
|
||||||
|
|
||||||
|
// Memoize disabled states
|
||||||
|
const isFavoriteDisabled = useMemo(
|
||||||
|
() =>
|
||||||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
|
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 = useMemo(
|
||||||
const isReadonlyDisabled =
|
() =>
|
||||||
!de.w ||
|
!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 = useMemo(
|
||||||
const isApiMqttExcludeDisabled =
|
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||||
de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED);
|
[de.n, de.m]
|
||||||
|
);
|
||||||
|
|
||||||
// Check if web exclude button should be disabled
|
const isWebExcludeDisabled = useMemo(
|
||||||
const isWebExcludeDisabled =
|
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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,6 +150,7 @@ const Modules = () => {
|
|||||||
}, [fetchModules]);
|
}, [fetchModules]);
|
||||||
|
|
||||||
const saveModules = useCallback(async () => {
|
const saveModules = useCallback(async () => {
|
||||||
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
modules.map((condensed_mi: ModuleItem) =>
|
modules.map((condensed_mi: ModuleItem) =>
|
||||||
updateModules({
|
updateModules({
|
||||||
@@ -159,17 +159,14 @@ const Modules = () => {
|
|||||||
license: condensed_mi.license
|
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 {
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await fetchModules();
|
await fetchModules();
|
||||||
setNumChanges(0);
|
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>
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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,70 +144,32 @@ 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 () => {
|
||||||
|
try {
|
||||||
await updateSchedule({
|
await updateSchedule({
|
||||||
schedule: schedule
|
schedule: schedule
|
||||||
.filter((si: ScheduleItem) => !si.deleted)
|
.filter((si: ScheduleItem) => !si.deleted)
|
||||||
@@ -150,17 +182,15 @@ const Scheduler = () => {
|
|||||||
value: condensed_si.value,
|
value: condensed_si.value,
|
||||||
name: condensed_si.name
|
name: condensed_si.name
|
||||||
}))
|
}))
|
||||||
})
|
});
|
||||||
.then(() => {
|
|
||||||
toast.success(LL.SCHEDULE_UPDATED());
|
toast.success(LL.SCHEDULE_UPDATED());
|
||||||
})
|
} catch (error: unknown) {
|
||||||
.catch((error: Error) => {
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
toast.error(error.message);
|
toast.error(message);
|
||||||
})
|
} finally {
|
||||||
.finally(async () => {
|
|
||||||
await fetchSchedule();
|
await fetchSchedule();
|
||||||
setNumChanges(0);
|
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;
|
||||||
|
const isActive = (si.flags & flag) === flag;
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
|
||||||
sx={{ fontSize: 11 }}
|
{dow[dayIndex]}
|
||||||
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
|
|
||||||
>
|
|
||||||
{dow[Math.log(flag) / Math.log(2)]}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider orientation="vertical" flexItem />
|
<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="success"
|
color={si.active ? 'success' : 'error'}
|
||||||
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
sx={{ fontSize: ICON_SIZE, 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>
|
||||||
|
|||||||
@@ -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()}
|
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}
|
||||||
{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>
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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(
|
||||||
|
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_0 ||
|
||||||
editItem.t === AnalogType.PWM_1 ||
|
editItem.t === AnalogType.PWM_1 ||
|
||||||
editItem.t === AnalogType.PWM_2;
|
editItem.t === AnalogType.PWM_2,
|
||||||
const isDigitalOutGPIO =
|
[editItem.t]
|
||||||
|
);
|
||||||
|
const isDigitalOutGPIO = useMemo(
|
||||||
|
() =>
|
||||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||||
(editItem.g === 25 || editItem.g === 26);
|
(editItem.g === 25 || editItem.g === 26),
|
||||||
const isDigitalOutNonGPIO =
|
[editItem.t, editItem.g]
|
||||||
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
|
);
|
||||||
|
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()} ${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()}
|
|
||||||
{LL.ANALOG_SENSOR(0)}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid>
|
<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 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()} {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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user