This commit is contained in:
proddy
2026-10-12 23:19:16 +02:00
parent 229ba81301
commit 540a87f465
15 changed files with 204 additions and 14 deletions
+156 -14
View File
@@ -11,6 +11,7 @@ import WarningIcon from '@mui/icons-material/Warning';
import { import {
Box, Box,
Button, Button,
Checkbox,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
@@ -91,6 +92,8 @@ const Customizations = () => {
const [deviceEntities, setDeviceEntities] = useState<DeviceEntity[]>([]); const [deviceEntities, setDeviceEntities] = useState<DeviceEntity[]>([]);
const [confirmReset, setConfirmReset] = useState<boolean>(false); const [confirmReset, setConfirmReset] = useState<boolean>(false);
const [selectedFilters, setSelectedFilters] = useState<number>(0); const [selectedFilters, setSelectedFilters] = useState<number>(0);
const [modifiedOnly, setModifiedOnly] = useState<boolean>(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [selectedDeviceEntity, setSelectedDeviceEntity] = useState<DeviceEntity>(); const [selectedDeviceEntity, setSelectedDeviceEntity] = useState<DeviceEntity>();
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const [dialogOpen, setDialogOpen] = useState<boolean>(false);
@@ -173,7 +176,7 @@ const Customizations = () => {
const entities_theme = useTheme({ const entities_theme = useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); --data-table-library_grid-template-columns: 32px 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
@@ -182,10 +185,10 @@ const Customizations = () => {
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(3) { &:nth-of-type(4) {
text-align: right; text-align: right;
} }
&:nth-of-type(4) { &:nth-of-type(5) {
text-align: right; text-align: right;
} }
&:last-of-type { &:last-of-type {
@@ -221,13 +224,13 @@ const Customizations = () => {
} }
`, `,
Cell: ` Cell: `
&:nth-of-type(2) { &:nth-of-type(3) {
padding: 8px; padding: 8px;
} }
&:nth-of-type(3) { &:nth-of-type(4) {
padding-right: 4px; padding-right: 4px;
} }
&:nth-of-type(4) { &:nth-of-type(5) {
padding-right: 4px; padding-right: 4px;
} }
&:last-of-type { &:last-of-type {
@@ -245,6 +248,11 @@ const Customizations = () => {
); );
} }
// Saved customization (c), or an edit that is not applied yet.
function isModified(de: DeviceEntity) {
return !!de.c || hasEntityChanged(de);
}
useEffect(() => { useEffect(() => {
if (deviceEntities.length) { if (deviceEntities.length) {
const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de)); const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
@@ -267,6 +275,7 @@ const Customizations = () => {
setSelectedDeviceBrand(device.b); setSelectedDeviceBrand(device.b);
} }
setNumChanges(0); setNumChanges(0);
setSelectedIds(new Set());
setRestartNeeded(false); setRestartNeeded(false);
} }
} }
@@ -331,24 +340,61 @@ const Customizations = () => {
const filter_entity = (de: DeviceEntity) => const filter_entity = (de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) && (de.m & selectedFilters || !selectedFilters) &&
(!modifiedOnly || isModified(de)) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()); formatName(de, true).toLowerCase().includes(search.toLowerCase());
// Checked rows when any are checked, otherwise every row the filter is showing.
const inBulkTarget = (de: DeviceEntity) =>
selectedIds.size > 0 ? selectedIds.has(de.id) : filter_entity(de);
const maskDisabled = (set: boolean) => { const maskDisabled = (set: boolean) => {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
setDeviceEntities((prev) => setDeviceEntities((prev) =>
prev.map((de) => { prev.map((de) => {
if (filter_entity(de)) { if (!inBulkTarget(de)) {
const excludeMask = return de;
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
return {
...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask
};
} }
return de; return {
...de,
m: set ? de.m | excludeMask : de.m & ~excludeMask
};
}) })
); );
}; };
const maskReadonly = (set: boolean) => {
setDeviceEntities((prev) =>
prev.map((de) => {
if (!inBulkTarget(de) || !de.w) {
return de;
}
return {
...de,
m: set
? de.m | DeviceEntityMask.DV_READONLY
: de.m & ~DeviceEntityMask.DV_READONLY
};
})
);
};
const resetSelected = () => {
setDeviceEntities((prev) =>
prev.map((de) => {
if (!selectedIds.has(de.id)) {
return de;
}
const next: DeviceEntity = { ...de, m: 0, c: false };
delete next.cn;
delete next.mi;
delete next.ma;
return next;
})
);
setSelectedIds(new Set());
};
const resetCustomization = async () => { const resetCustomization = async () => {
try { try {
await sendResetCustomizations(); await sendResetCustomizations();
@@ -414,6 +460,8 @@ const Customizations = () => {
}) })
.then(() => { .then(() => {
toast.success(LL.CUSTOMIZATIONS_SAVED()); toast.success(LL.CUSTOMIZATIONS_SAVED());
// Reload so cleared min/max fall back to the built-in limits and the modified flag matches the file.
void sendDeviceEntities(selectedDevice);
}) })
.catch((error: Error) => { .catch((error: Error) => {
if (error.message === 'Reboot required') { if (error.message === 'Reboot required') {
@@ -541,6 +589,34 @@ const Customizations = () => {
); );
const filteredEntities = deviceEntities.filter((de) => filter_entity(de)); const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
const allFilteredSelected =
filteredEntities.length > 0 &&
filteredEntities.every((de) => selectedIds.has(de.id));
const someFilteredSelected = filteredEntities.some((de) => selectedIds.has(de.id));
const toggleSelected = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const toggleSelectFiltered = () => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (allFilteredSelected) {
filteredEntities.forEach((de) => next.delete(de.id));
} else {
filteredEntities.forEach((de) => next.add(de.id));
}
return next;
});
};
const renderDeviceData = () => { const renderDeviceData = () => {
return ( return (
@@ -608,6 +684,17 @@ const Customizations = () => {
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
</Grid> </Grid>
<Grid>
<Button
size="small"
sx={{ fontSize: 10 }}
variant="outlined"
color={modifiedOnly ? 'secondary' : 'inherit'}
onClick={() => setModifiedOnly((current) => !current)}
>
{LL.MODIFIED()}
</Button>
</Grid>
<Grid> <Grid>
<Button <Button
size="small" size="small"
@@ -634,6 +721,43 @@ const Customizations = () => {
<OptionIcon type="web_exclude" isSet={true} /> <OptionIcon type="web_exclude" isSet={true} />
</Button> </Button>
</Grid> </Grid>
<Grid>
<Button
size="small"
sx={{ fontSize: 10 }}
variant="outlined"
color="inherit"
onClick={() => maskReadonly(false)}
>
{LL.SET_ALL()}&nbsp;
<OptionIcon type="readonly" isSet={false} />
</Button>
</Grid>
<Grid>
<Button
size="small"
sx={{ fontSize: 10 }}
variant="outlined"
color="inherit"
onClick={() => maskReadonly(true)}
>
{LL.SET_ALL()}&nbsp;
<OptionIcon type="readonly" isSet={true} />
</Button>
</Grid>
{selectedIds.size > 0 && (
<Grid>
<Button
size="small"
sx={{ fontSize: 10 }}
variant="outlined"
color="error"
onClick={resetSelected}
>
{LL.RESET_SELECTED()} ({selectedIds.size})
</Button>
</Grid>
)}
<Grid> <Grid>
<Typography variant="subtitle2" color="grey"> <Typography variant="subtitle2" color="grey">
{LL.SHOWING()}&nbsp;{filteredEntities.length}/{deviceEntities.length} {LL.SHOWING()}&nbsp;{filteredEntities.length}/{deviceEntities.length}
@@ -650,6 +774,16 @@ const Customizations = () => {
<> <>
<Header> <Header>
<HeaderRow> <HeaderRow>
<HeaderCell stiff>
<Checkbox
size="small"
sx={{ p: 0, color: 'inherit' }}
checked={allFilteredSelected}
indeterminate={!allFilteredSelected && someFilteredSelected}
disabled={filteredEntities.length === 0}
onChange={toggleSelectFiltered}
/>
</HeaderCell>
<HeaderCell stiff>{LL.OPTIONS()}</HeaderCell> <HeaderCell stiff>{LL.OPTIONS()}</HeaderCell>
<HeaderCell resize>{LL.NAME(1)}</HeaderCell> <HeaderCell resize>{LL.NAME(1)}</HeaderCell>
<HeaderCell stiff>{LL.MIN()}</HeaderCell> <HeaderCell stiff>{LL.MIN()}</HeaderCell>
@@ -660,6 +794,14 @@ const Customizations = () => {
<Body> <Body>
{tableList.map((de: DeviceEntity) => ( {tableList.map((de: DeviceEntity) => (
<Row key={de.id} item={de} onClick={() => editDeviceEntity(de)}> <Row key={de.id} item={de} onClick={() => editDeviceEntity(de)}>
<Cell stiff onClick={(event) => event.stopPropagation()}>
<Checkbox
size="small"
sx={{ p: 0 }}
checked={selectedIds.has(de.id)}
onChange={() => toggleSelected(de.id)}
/>
</Cell>
<Cell stiff> <Cell stiff>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={de} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={de} />
</Cell> </Cell>
@@ -3,6 +3,7 @@ import { memo, useEffect, 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';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import { import {
Box, Box,
Button, Button,
@@ -101,6 +102,14 @@ const CustomizationsDialog = ({
setEditItem((prev) => ({ ...prev, m: updatedItem.m })); setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
}; };
const remove = () => {
const next: DeviceEntity = { ...editItem, m: 0, c: false };
delete next.cn;
delete next.mi;
delete next.ma;
onSave(next);
};
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle> <DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
@@ -169,6 +178,15 @@ const CustomizationsDialog = ({
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={remove}
color="error"
>
{LL.REMOVE()}
</Button>
<Box sx={{ flexGrow: 1 }} />
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
+1
View File
@@ -174,6 +174,7 @@ export interface DeviceEntity {
w: boolean; // writeable w: boolean; // writeable
mi?: number; // min value mi?: number; // min value
ma?: number; // max value ma?: number; // max value
c?: boolean; // true when a customization is saved (mask, name, or min/max)
o_m?: number; // original mask before edits o_m?: number; // original mask before edits
o_cn?: string; // original cn before edits o_cn?: string; // original cn before edits
o_mi?: number; // original min value o_mi?: number; // original min value
+2
View File
@@ -141,6 +141,8 @@ const cs: Translation = {
CUSTOMIZATIONS_HELP_6: 'odstranit z paměti', CUSTOMIZATIONS_HELP_6: 'odstranit z paměti',
SELECT_DEVICE: 'Vyberte zařízení', SELECT_DEVICE: 'Vyberte zařízení',
SET_ALL: 'nastavit vše', SET_ALL: 'nastavit vše',
MODIFIED: 'upravené',
RESET_SELECTED: 'Obnovit vybrané',
OPTIONS: 'Možnosti', OPTIONS: 'Možnosti',
NAME: 'Název', NAME: 'Název',
CUSTOMIZATIONS_RESET: 'Opravdu chcete odstranit všechna přizpůsobení?', CUSTOMIZATIONS_RESET: 'Opravdu chcete odstranit všechna přizpůsobení?',
+2
View File
@@ -141,6 +141,8 @@ const de: Translation = {
CUSTOMIZATIONS_HELP_6: 'Aus dem Speicher entfernen', CUSTOMIZATIONS_HELP_6: 'Aus dem Speicher entfernen',
SELECT_DEVICE: 'Wählen Sie ein Gerät aus', SELECT_DEVICE: 'Wählen Sie ein Gerät aus',
SET_ALL: 'Setzen Sie alle', SET_ALL: 'Setzen Sie alle',
MODIFIED: 'geändert',
RESET_SELECTED: 'Auswahl zurücksetzen',
OPTIONS: 'Optionen', OPTIONS: 'Optionen',
NAME: 'Name', NAME: 'Name',
CUSTOMIZATIONS_RESET: 'Möchten Sie wirklich alle Anpassungen entfernen?', CUSTOMIZATIONS_RESET: 'Möchten Sie wirklich alle Anpassungen entfernen?',
+2
View File
@@ -141,6 +141,8 @@ const en: Translation = {
CUSTOMIZATIONS_HELP_6: 'remove from memory', CUSTOMIZATIONS_HELP_6: 'remove from memory',
SELECT_DEVICE: 'Select a device', SELECT_DEVICE: 'Select a device',
SET_ALL: 'set all', SET_ALL: 'set all',
MODIFIED: 'modified',
RESET_SELECTED: 'Reset selected',
OPTIONS: 'Options', OPTIONS: 'Options',
NAME: 'Name', NAME: 'Name',
CUSTOMIZATIONS_RESET: 'Are you sure you want remove all the customizations?', CUSTOMIZATIONS_RESET: 'Are you sure you want remove all the customizations?',
+2
View File
@@ -141,6 +141,8 @@ const fr: Translation = {
CUSTOMIZATIONS_HELP_6: 'supprimer de la mémoire', CUSTOMIZATIONS_HELP_6: 'supprimer de la mémoire',
SELECT_DEVICE: 'Sélectionnez un appareil', SELECT_DEVICE: 'Sélectionnez un appareil',
SET_ALL: 'tout régler', SET_ALL: 'tout régler',
MODIFIED: 'modifié',
RESET_SELECTED: 'Réinitialiser la sélection',
OPTIONS: 'Options', OPTIONS: 'Options',
NAME: 'Nom', NAME: 'Nom',
CUSTOMIZATIONS_RESET: 'Êtes-vous sûr de vouloir supprimer toutes les personnalisations?', CUSTOMIZATIONS_RESET: 'Êtes-vous sûr de vouloir supprimer toutes les personnalisations?',
+2
View File
@@ -141,6 +141,8 @@ const it: Translation = {
CUSTOMIZATIONS_HELP_6: 'rimuovi dalla memoria', CUSTOMIZATIONS_HELP_6: 'rimuovi dalla memoria',
SELECT_DEVICE: 'Seleziona un dispositivo', SELECT_DEVICE: 'Seleziona un dispositivo',
SET_ALL: 'imposta tutto', SET_ALL: 'imposta tutto',
MODIFIED: 'modificato',
RESET_SELECTED: 'Reimposta selezionati',
OPTIONS: 'Opzioni', OPTIONS: 'Opzioni',
NAME: 'Nome', NAME: 'Nome',
CUSTOMIZATIONS_RESET: 'Sei sicuro di voler rimuovere tutte le personalizzazioni?', CUSTOMIZATIONS_RESET: 'Sei sicuro di voler rimuovere tutte le personalizzazioni?',
+2
View File
@@ -141,6 +141,8 @@ const nl: Translation = {
CUSTOMIZATIONS_HELP_6: 'verwijderen van memory', CUSTOMIZATIONS_HELP_6: 'verwijderen van memory',
SELECT_DEVICE: 'Selecteer een apparaat', SELECT_DEVICE: 'Selecteer een apparaat',
SET_ALL: 'Alles aanzetten', SET_ALL: 'Alles aanzetten',
MODIFIED: 'gewijzigd',
RESET_SELECTED: 'Selectie resetten',
OPTIONS: 'Opties', OPTIONS: 'Opties',
NAME: 'Naam', NAME: 'Naam',
CUSTOMIZATIONS_RESET: 'Weet je zeker dat je alle custom aanpassingen wilt verwijderen?', CUSTOMIZATIONS_RESET: 'Weet je zeker dat je alle custom aanpassingen wilt verwijderen?',
+2
View File
@@ -141,6 +141,8 @@ const no: Translation = {
CUSTOMIZATIONS_HELP_6: 'fjern fra minnet', CUSTOMIZATIONS_HELP_6: 'fjern fra minnet',
SELECT_DEVICE: 'Velg en enhet', SELECT_DEVICE: 'Velg en enhet',
SET_ALL: 'sett alle', SET_ALL: 'sett alle',
MODIFIED: 'endret',
RESET_SELECTED: 'Tilbakestill valgte',
OPTIONS: 'Alternativ', OPTIONS: 'Alternativ',
NAME: 'Navn', NAME: 'Navn',
CUSTOMIZATIONS_RESET: 'Er du sikker på att du vil fjerne tilpassninger?', CUSTOMIZATIONS_RESET: 'Er du sikker på att du vil fjerne tilpassninger?',
+2
View File
@@ -141,6 +141,8 @@ const pl: BaseTranslation = {
CUSTOMIZATIONS_HELP_6: 'usuń z pamięci', CUSTOMIZATIONS_HELP_6: 'usuń z pamięci',
SELECT_DEVICE: 'wybierz urządzenie', SELECT_DEVICE: 'wybierz urządzenie',
SET_ALL: 'Ustaw wszystko jako', SET_ALL: 'Ustaw wszystko jako',
MODIFIED: 'zmienione',
RESET_SELECTED: 'Resetuj zaznaczone',
OPTIONS: 'Opcje', OPTIONS: 'Opcje',
NAME: '{{Nazwa|nazwa|}}', NAME: '{{Nazwa|nazwa|}}',
CUSTOMIZATIONS_RESET: 'Na pewno chcesz usunąć wszystkie personalizacje?', CUSTOMIZATIONS_RESET: 'Na pewno chcesz usunąć wszystkie personalizacje?',
+2
View File
@@ -141,6 +141,8 @@ const sk: Translation = {
CUSTOMIZATIONS_HELP_6: 'odstrániť z pamäte', CUSTOMIZATIONS_HELP_6: 'odstrániť z pamäte',
SELECT_DEVICE: 'Zvoliť zariadenie', SELECT_DEVICE: 'Zvoliť zariadenie',
SET_ALL: 'nastaviť všetko', SET_ALL: 'nastaviť všetko',
MODIFIED: 'upravené',
RESET_SELECTED: 'Obnoviť vybrané',
OPTIONS: 'Možnosti', OPTIONS: 'Možnosti',
NAME: 'Názov', NAME: 'Názov',
CUSTOMIZATIONS_RESET: 'Naozaj chcete odstrániť všetky prispôsobenia?', CUSTOMIZATIONS_RESET: 'Naozaj chcete odstrániť všetky prispôsobenia?',
+2
View File
@@ -141,6 +141,8 @@ const sv: Translation = {
CUSTOMIZATIONS_HELP_6: 'Ta bort', CUSTOMIZATIONS_HELP_6: 'Ta bort',
SELECT_DEVICE: 'Välj en enhet', SELECT_DEVICE: 'Välj en enhet',
SET_ALL: 'ställ in alla', SET_ALL: 'ställ in alla',
MODIFIED: 'ändrade',
RESET_SELECTED: 'Återställ markerade',
OPTIONS: 'Alternativ', OPTIONS: 'Alternativ',
NAME: 'Namn', NAME: 'Namn',
CUSTOMIZATIONS_RESET: 'Är du säker på att du vill ta bort alla anpassningar?', CUSTOMIZATIONS_RESET: 'Är du säker på att du vill ta bort alla anpassningar?',
+2
View File
@@ -141,6 +141,8 @@ const tr: Translation = {
CUSTOMIZATIONS_HELP_6: 'remove from memory', CUSTOMIZATIONS_HELP_6: 'remove from memory',
SELECT_DEVICE: 'Bir cihaz seç', SELECT_DEVICE: 'Bir cihaz seç',
SET_ALL: 'hepsini ayarla', SET_ALL: 'hepsini ayarla',
MODIFIED: 'değiştirilmiş',
RESET_SELECTED: 'Seçilenleri sıfırla',
OPTIONS: 'Seçenekler', OPTIONS: 'Seçenekler',
NAME: 'İsim', NAME: 'İsim',
CUSTOMIZATIONS_RESET: 'Bütün özelleştirmeleri kaldırmak istediğinizden emin misiniz?', CUSTOMIZATIONS_RESET: 'Bütün özelleştirmeleri kaldırmak istediğinizden emin misiniz?',
+7
View File
@@ -1193,6 +1193,12 @@ void EMSdevice::generate_values_web_customization(JsonArray output) {
// 0x10 = 16 = DV_WEB_EXCLUDE // 0x10 = 16 = DV_WEB_EXCLUDE
obj["m"] = dv.state >> 4; obj["m"] = dv.state >> 4;
// Saved customization: mask bits, or a custom name/min/max stored in custom_fullname.
// Min/max in "mi"/"ma" are the effective limits, so they alone do not show a customization.
if (dv.has_custom_fullname() || (dv.state >> 4) != 0) {
obj["c"] = true;
}
obj["w"] = dv.has_cmd; // if writable obj["w"] = dv.has_cmd; // if writable
if (dv.has_cmd && (obj["v"].is<float>() || obj["v"].is<int>())) { if (dv.has_cmd && (obj["v"].is<float>() || obj["v"].is<int>())) {
@@ -1219,6 +1225,7 @@ void EMSdevice::generate_values_web_customization(JsonArray output) {
JsonObject obj = output.add<JsonObject>(); JsonObject obj = output.add<JsonObject>();
obj["id"] = DeviceValue::get_name(entity_id); // set the name, it could be custom following a '|' obj["id"] = DeviceValue::get_name(entity_id); // set the name, it could be custom following a '|'
obj["m"] = mask; // update the mask obj["m"] = mask; // update the mask
obj["c"] = true;
obj["w"] = false; // not writeable as it won't be shown obj["w"] = false; // not writeable as it won't be shown
} }
} }