first try

This commit is contained in:
proddy
2026-06-07 18:26:35 +02:00
parent f4cee54042
commit 5c4dfcb9ae
38 changed files with 1282 additions and 439 deletions

View File

@@ -1,6 +1,7 @@
import { memo, useContext } from 'react'; import { memo, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router'; import { Navigate, Route, Routes } from 'react-router';
import Commands from 'app/main/Commands';
import CustomEntities from 'app/main/CustomEntities'; import CustomEntities from 'app/main/CustomEntities';
import Customizations from 'app/main/Customizations'; import Customizations from 'app/main/Customizations';
import Dashboard from 'app/main/Dashboard'; import Dashboard from 'app/main/Dashboard';
@@ -65,6 +66,7 @@ const AuthenticatedRouting = memo(() => {
<Route path="/settings/security/*" element={<Security />} /> <Route path="/settings/security/*" element={<Security />} />
<Route path="/customizations" element={<Customizations />} /> <Route path="/customizations" element={<Customizations />} />
<Route path="/commands" element={<Commands />} />
<Route path="/scheduler" element={<Scheduler />} /> <Route path="/scheduler" element={<Scheduler />} />
<Route path="/customentities" element={<CustomEntities />} /> <Route path="/customentities" element={<CustomEntities />} />
</> </>

View File

@@ -4,6 +4,8 @@ import type {
APIcall, APIcall,
Action, Action,
Activity, Activity,
CommandItem,
Commands,
CoreData, CoreData,
DashboardData, DashboardData,
DeviceData, DeviceData,
@@ -102,8 +104,7 @@ export const readSchedule = () =>
o_deleted: si.deleted, o_deleted: si.deleted,
o_flags: si.flags, o_flags: si.flags,
o_time: si.time, o_time: si.time,
o_cmd: si.cmd, o_cmd_name: si.cmd_name,
o_value: si.value,
o_name: si.name o_name: si.name
})); }));
} }
@@ -111,6 +112,24 @@ export const readSchedule = () =>
export const writeSchedule = (data: Schedule) => export const writeSchedule = (data: Schedule) =>
alovaInstance.Post('/rest/schedule', data); alovaInstance.Post('/rest/schedule', data);
// Commands
export const readCommands = () =>
alovaInstance.Get<CommandItem[]>('/rest/commands', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) {
const commands = (data as Commands).commands;
return commands.map((ci) => ({
...ci,
o_id: ci.id,
o_cmd: ci.cmd,
o_value: ci.value,
o_name: ci.name
}));
}
});
export const writeCommands = (data: Commands) =>
alovaInstance.Post('/rest/commands', data);
// Modules // Modules
export const readModules = () => export const readModules = () =>
alovaInstance.Get<ModuleItem[]>('/rest/modules', { alovaInstance.Get<ModuleItem[]>('/rest/modules', {

View File

@@ -0,0 +1,284 @@
import { useEffect, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning';
import { Box, Button, Typography } from '@mui/material';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme } from '@table-library/react-table-library/theme';
import { updateState, useRequest } from 'alova/client';
import {
BlockNavigation,
ButtonRow,
FormLoader,
SectionContent,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';
import { readCommands, writeCommands } from '../../api/app';
import CommandsDialog from './CommandsDialog';
import type { CommandItem, Commands as CommandsType } from './types';
import { commandItemValidation } from './validators';
const INTERVAL_DELAY = 30000;
const MIN_ID = -100;
const MAX_ID = 100;
const DEFAULT_COMMAND_ITEM: Omit<CommandItem, 'id'> = {
cmd: '',
value: '',
name: '',
deleted: false
};
const commandsTheme = {
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(100px, 1fr)) repeat(1, minmax(100px, 1fr)) 160px;
`,
BaseRow: `
font-size: 14px;
.td {
height: 32px;
}
`,
BaseCell: `
&:nth-of-type(1) {
padding: 8px;
}
`,
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 CommandsPage = () => {
const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [selectedItem, setSelectedItem] = useState<CommandItem>();
const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.COMMANDS());
const {
data: commands,
send: fetchCommands,
error
} = useRequest(readCommands, {
initialData: []
});
const { send: updateCommands } = useRequest(
(data: CommandsType) => writeCommands(data),
{ immediate: false }
);
const hasChanged = (ci: CommandItem) =>
ci.id !== ci.o_id ||
(ci.name || '') !== (ci.o_name || '') ||
ci.cmd !== ci.o_cmd ||
ci.value !== ci.o_value ||
ci.deleted !== ci.o_deleted;
useInterval(() => {
if (numChanges === 0) {
void fetchCommands();
}
}, INTERVAL_DELAY);
const theme = useTheme(commandsTheme);
const saveCommands = async () => {
try {
await updateCommands({
commands: commands
.filter((ci: CommandItem) => !ci.deleted)
.map((ci: CommandItem) => ({
id: ci.id,
cmd: ci.cmd,
value: ci.value,
name: ci.name
}))
});
toast.success(LL.UPDATED_OF(LL.COMMANDS(1)));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
} finally {
await fetchCommands();
setNumChanges(0);
}
};
const editItem = (ci: CommandItem) => {
setCreating(false);
setSelectedItem(ci);
setDialogOpen(true);
if (ci.o_name === undefined) {
ci.o_name = ci.name;
}
};
const onDialogClose = () => {
setDialogOpen(false);
};
const onDialogCancel = async () => {
await fetchCommands().then(() => {
setNumChanges(0);
});
};
const onDialogSave = (updatedItem: CommandItem) => {
setDialogOpen(false);
void updateState(readCommands(), (data: CommandItem[]) => {
const new_data = creating
? [...data, updatedItem]
: data.map((ci) =>
ci.id === updatedItem.id ? { ...ci, ...updatedItem } : ci
);
setNumChanges(new_data.filter((ci) => hasChanged(ci)).length);
return new_data;
});
};
const addItem = () => {
setCreating(true);
const newItem: CommandItem = {
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
...DEFAULT_COMMAND_ITEM
};
setSelectedItem(newItem);
setDialogOpen(true);
};
const filteredCommands = commands.filter((ci: CommandItem) => !ci.deleted);
const renderCommands = () => {
if (!commands) {
return (
<FormLoader onRetry={fetchCommands} errorMessage={error?.message || ''} />
);
}
return (
<Table
data={{ nodes: filteredCommands }}
theme={theme}
layout={{ custom: true }}
>
{(tableList: CommandItem[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ci: CommandItem) => (
<Row key={ci.id} item={ci} onClick={() => editItem(ci)}>
<Cell>{ci.cmd}</Cell>
<Cell>{ci.value}</Cell>
<Cell>{ci.name}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
{LL.COMMANDS_HELP_1()}.
</Typography>
{renderCommands()}
{selectedItem && (
<CommandsDialog
open={dialogOpen}
creating={creating}
onClose={onDialogClose}
onSave={onDialogSave}
selectedItem={selectedItem}
validator={commandItemValidation(commands, selectedItem)}
/>
)}
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
<Box sx={{ flexGrow: 1 }}>
{numChanges !== 0 && (
<ButtonRow>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onDialogCancel}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
onClick={saveCommands}
>
{LL.APPLY_CHANGES(numChanges)}
</Button>
</ButtonRow>
)}
</Box>
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
<ButtonRow>
<Button
startIcon={<AddIcon />}
variant="outlined"
color="primary"
onClick={addItem}
>
{LL.ADD(0)}
</Button>
</ButtonRow>
</Box>
</Box>
</SectionContent>
);
};
export default CommandsPage;

View File

@@ -0,0 +1,188 @@
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField
} from '@mui/material';
import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators';
import type { CommandItem } from './types';
interface CommandsDialogProps {
open: boolean;
creating: boolean;
onClose: () => void;
onSave: (ci: CommandItem) => void;
selectedItem: CommandItem;
validator: Schema;
}
const CommandsDialog = ({
open,
creating,
onClose,
onSave,
selectedItem,
validator
}: CommandsDialogProps) => {
const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<CommandItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
);
useEffect(() => {
if (open) {
setFieldErrors(undefined);
setEditItem(selectedItem);
}
}, [open, selectedItem]);
const handleSave = async (itemToSave: CommandItem) => {
try {
setFieldErrors(undefined);
await validate(validator, itemToSave);
onSave(itemToSave);
} catch (error) {
setFieldErrors((error as ValidationError).fieldErrors);
}
};
const save = async () => {
await handleSave(editItem);
};
const { send: executeCommand } = useRequest(
(id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false }
)
.onSuccess(() => {
toast.success(LL.EXECUTE_COMMAND_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const execute = async () => {
await executeCommand(editItem.name);
};
const remove = () => {
onSave({ ...editItem, deleted: true });
};
const handleClose = (
_event: React.SyntheticEvent,
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') {
onClose();
}
};
return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}&nbsp;
{LL.COMMAND(1)}
</DialogTitle>
<DialogContent dividers>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="cmd"
label={LL.COMMAND(0)}
multiline
fullWidth
value={editItem.cmd}
margin="normal"
onChange={updateFormValue}
/>
<TextField
name="value"
label={LL.VALUE(0)}
multiline
margin="normal"
fullWidth
value={editItem.value}
onChange={updateFormValue}
/>
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="name"
label={LL.NAME(0)}
value={editItem.name}
fullWidth
margin="normal"
onChange={updateFormValue}
/>
</DialogContent>
<DialogActions>
{!creating && (
<Box sx={{ flexGrow: 1 }}>
<Button
startIcon={<RemoveIcon />}
variant="outlined"
color="warning"
onClick={remove}
>
{LL.REMOVE()}
</Button>
</Box>
)}
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={onClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={creating ? <AddIcon /> : <DoneIcon />}
variant="outlined"
onClick={save}
color="primary"
>
{creating ? LL.ADD(0) : LL.UPDATE()}
</Button>
{!creating && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={execute}
color="success"
>
{LL.EXECUTE()}
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default CommandsDialog;

View File

@@ -180,6 +180,8 @@ const Dashboard = memo(() => {
return LL.ANALOG_SENSORS(); return LL.ANALOG_SENSORS();
case DeviceType.TEMPERATURESENSOR: case DeviceType.TEMPERATURESENSOR:
return LL.TEMP_SENSORS(); return LL.TEMP_SENSORS();
case DeviceType.COMMAND:
return LL.COMMANDS();
case DeviceType.SCHEDULER: case DeviceType.SCHEDULER:
return LL.SCHEDULER(); return LL.SCHEDULER();
default: default:

View File

@@ -38,6 +38,7 @@ const deviceIconLookup: Record<DeviceType, IconType | null> = {
[DeviceType.CUSTOM]: MdPlaylistAdd, [DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors, [DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: null, [DeviceType.SYSTEM]: null,
[DeviceType.COMMAND]: MdPlaylistAdd,
[DeviceType.SCHEDULER]: MdMoreTime, [DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors, [DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan [DeviceType.VENTILATION]: PiFan

View File

@@ -64,12 +64,12 @@ const DevicesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const { send: executeSchedule } = useRequest( const { send: executeCommand } = useRequest(
(id: string) => callAction({ action: 'executeSchedule', param: id }), (id: string) => callAction({ action: 'executeCommand', param: id }),
{ immediate: false } { immediate: false }
) )
.onSuccess(() => { .onSuccess(() => {
toast.success(LL.EXECUTE_SCHEDULE_SENT()); toast.success(LL.EXECUTE_COMMAND_SENT());
}) })
.onError((error) => { .onError((error) => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
@@ -79,7 +79,7 @@ const DevicesDialog = ({
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
if (editItem.v === undefined && editItem.c !== undefined) { if (editItem.v === undefined && editItem.c !== undefined) {
await executeSchedule(editItem.c); await executeCommand(editItem.c);
} else { } else {
await validate(validator, editItem); await validate(validator, editItem);
} }

View File

@@ -29,7 +29,7 @@ import {
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
import { readSchedule, writeSchedule } from '../../api/app'; import { readCommands, readSchedule, writeSchedule } from '../../api/app';
import SettingsSchedulerDialog from './SchedulerDialog'; import SettingsSchedulerDialog from './SchedulerDialog';
import { ScheduleFlag } from './types'; import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types'; import type { Schedule, ScheduleItem } from './types';
@@ -54,14 +54,13 @@ const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
deleted: false, deleted: false,
flags: FLAG_ALL_DAYS, flags: FLAG_ALL_DAYS,
time: '', time: '',
cmd: '', cmd_name: '',
value: '',
name: '' name: ''
}; };
const scheduleTheme = { const scheduleTheme = {
Table: ` Table: `
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px; --data-table-library_grid-template-columns: 36px 220px repeat(1, minmax(20px, 1fr)) 192px 160px;
`, `,
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
@@ -70,11 +69,8 @@ const scheduleTheme = {
} }
`, `,
BaseCell: ` BaseCell: `
&:nth-of-type(2) {
text-align: center;
}
&:nth-of-type(1) { &:nth-of-type(1) {
text-align: center; text-align: 8px;
} }
`, `,
HeaderRow: ` HeaderRow: `
@@ -100,7 +96,6 @@ const scheduleTheme = {
}; };
const scheduleTypeLabels: Record<number, string> = { const scheduleTypeLabels: Record<number, string> = {
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer', [ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition', [ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change' [ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
@@ -125,6 +120,11 @@ const Scheduler = () => {
initialData: [] initialData: []
}); });
const { data: commandNames } = useRequest(readCommands, {
initialData: [],
initializing: true
});
const { send: updateSchedule } = useRequest( const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data), (data: Schedule) => writeSchedule(data),
{ {
@@ -140,8 +140,7 @@ const Scheduler = () => {
si.deleted !== si.o_deleted || si.deleted !== si.o_deleted ||
si.flags !== si.o_flags || si.flags !== si.o_flags ||
si.time !== si.o_time || si.time !== si.o_time ||
si.cmd !== si.o_cmd || si.cmd_name !== si.o_cmd_name
si.value !== si.o_value
); );
}; };
@@ -177,8 +176,7 @@ const Scheduler = () => {
active: condensed_si.active, active: condensed_si.active,
flags: condensed_si.flags, flags: condensed_si.flags,
time: condensed_si.time, time: condensed_si.time,
cmd: condensed_si.cmd, cmd_name: condensed_si.cmd_name,
value: condensed_si.value,
name: condensed_si.name name: condensed_si.name
})) }))
}); });
@@ -289,7 +287,6 @@ const Scheduler = () => {
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell> <HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
<HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell> <HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell> <HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell> <HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
</HeaderRow> </HeaderRow>
</Header> </Header>
@@ -297,12 +294,10 @@ 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.flags !== ScheduleFlag.SCHEDULE_IMMEDIATE && ( <CircleIcon
<CircleIcon color={si.active ? 'success' : 'error'}
color={si.active ? 'success' : 'error'} sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }} />
/>
)}
</Cell> </Cell>
<Cell stiff> <Cell stiff>
<Stack spacing={0.5} direction="row"> <Stack spacing={0.5} direction="row">
@@ -322,8 +317,7 @@ const Scheduler = () => {
</Stack> </Stack>
</Cell> </Cell>
<Cell>{si.time}</Cell> <Cell>{si.time}</Cell>
<Cell>{si.cmd}</Cell> <Cell>{si.cmd_name}</Cell>
<Cell>{si.value}</Cell>
<Cell>{si.name}</Cell> <Cell>{si.name}</Cell>
</Row> </Row>
))} ))}
@@ -351,6 +345,7 @@ const Scheduler = () => {
selectedItem={selectedScheduleItem} selectedItem={selectedScheduleItem}
validator={schedulerItemValidation(schedule, selectedScheduleItem)} validator={schedulerItemValidation(schedule, selectedScheduleItem)}
dow={dow} dow={dow}
commandNames={commandNames.map((ci) => ci.name)}
/> />
)} )}

View File

@@ -1,10 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
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';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined'; import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
import { import {
Box, Box,
@@ -15,15 +13,14 @@ import {
DialogContent, DialogContent,
DialogTitle, DialogTitle,
Grid, Grid,
MenuItem,
TextField, TextField,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import { callAction } from '@/api/app';
import { dialogStyle } from 'CustomTheme'; import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import type Schema from 'async-validator'; import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator'; import type { ValidateFieldsError } from 'async-validator';
import { BlockFormControlLabel, ValidatedTextField } from 'components'; import { BlockFormControlLabel, ValidatedTextField } from 'components';
@@ -77,6 +74,7 @@ interface SchedulerDialogProps {
selectedItem: ScheduleItem; selectedItem: ScheduleItem;
validator: Schema; validator: Schema;
dow: string[]; dow: string[];
commandNames: string[];
} }
const SchedulerDialog = ({ const SchedulerDialog = ({
@@ -86,7 +84,8 @@ const SchedulerDialog = ({
onSave, onSave,
selectedItem, selectedItem,
validator, validator,
dow dow,
commandNames
}: SchedulerDialogProps) => { }: SchedulerDialogProps) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem); const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
@@ -103,12 +102,6 @@ const SchedulerDialog = ({
if (open) { if (open) {
setFieldErrors(undefined); setFieldErrors(undefined);
setEditItem(selectedItem); setEditItem(selectedItem);
// Set the flags based on type when page is loaded:
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setScheduleType( setScheduleType(
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
? ScheduleFlag.SCHEDULE_DAY ? ScheduleFlag.SCHEDULE_DAY
@@ -131,21 +124,6 @@ const SchedulerDialog = ({
await handleSave(editItem); await handleSave(editItem);
}; };
const { send: executeSchedule } = useRequest(
(id: string) => callAction({ action: 'executeSchedule', param: id }),
{ immediate: false }
)
.onSuccess(() => {
toast.success(LL.EXECUTE_SCHEDULE_SENT());
})
.onError((error) => {
toast.error(String(error.error?.message || 'An error occurred'));
});
const execute = async () => {
await executeSchedule(editItem.name);
};
const remove = () => { const remove = () => {
onSave({ ...editItem, deleted: true }); onSave({ ...editItem, deleted: true });
}; };
@@ -197,7 +175,6 @@ const SchedulerDialog = ({
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY; const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER; const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE;
const needsTimeField = isDaySchedule || isTimerSchedule; const needsTimeField = isDaySchedule || isTimerSchedule;
const dowFlags = getFlagDOWstring(editItem.flags); const dowFlags = getFlagDOWstring(editItem.flags);
@@ -214,7 +191,6 @@ const SchedulerDialog = ({
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1); if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION(); if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE(); if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1); return LL.TIME(1);
})(); })();
@@ -269,14 +245,6 @@ const SchedulerDialog = ({
{LL.CONDITION()} {LL.CONDITION()}
</Typography> </Typography>
</ToggleButton> </ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isImmediateSchedule ? 'primary' : 'grey'}
>
{LL.IMMEDIATE()}
</Typography>
</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
{isDaySchedule && ( {isDaySchedule && (
@@ -294,74 +262,66 @@ const SchedulerDialog = ({
</ToggleButtonGroup> </ToggleButtonGroup>
)} )}
{!isImmediateSchedule && ( <Grid container>
<> <BlockFormControlLabel
<Grid container> control={
<BlockFormControlLabel <Checkbox
control={ checked={editItem.active}
<Checkbox onChange={updateFormValue}
checked={editItem.active} name="active"
onChange={updateFormValue}
name="active"
/>
}
label={LL.ACTIVE()}
/> />
</Grid> }
<Grid container> label={LL.ACTIVE()}
{needsTimeField ? ( />
<> </Grid>
<TextField <Grid container>
name="time" {needsTimeField ? (
type="time" <>
label={timeFieldLabel} <TextField
value={timeFieldValue} name="time"
margin="normal" type="time"
onChange={updateFormValue} label={timeFieldLabel}
/> value={timeFieldValue}
{isTimerSchedule && ( margin="normal"
<Typography onChange={updateFormValue}
sx={{ ml: 2, mt: 4 }} />
color="warning" {isTimerSchedule && (
variant="body2" <Typography
> sx={{ ml: 2, mt: 4 }}
{LL.SCHEDULER_HELP_2()} color="warning"
</Typography> variant="body2"
)} >
</> {LL.SCHEDULER_HELP_2()}
) : ( </Typography>
<TextField
name="time"
label={timeFieldLabel}
multiline
fullWidth
value={timeFieldValue}
margin="normal"
onChange={updateFormValue}
/>
)} )}
</Grid> </>
</> ) : (
)} <TextField
<ValidatedTextField name="time"
fieldErrors={fieldErrors || {}} label={timeFieldLabel}
name="cmd" multiline
label={LL.COMMAND(0)} fullWidth
multiline value={timeFieldValue}
fullWidth margin="normal"
value={editItem.cmd} onChange={updateFormValue}
margin="normal" />
onChange={updateFormValue} )}
/> </Grid>
<TextField <TextField
name="value" name="cmd_name"
label={LL.VALUE(0)} label={LL.COMMAND(0)}
multiline value={editItem.cmd_name}
margin="normal"
fullWidth fullWidth
value={editItem.value} select
margin="normal"
onChange={updateFormValue} onChange={updateFormValue}
/> >
{commandNames.map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</TextField>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors || {}}
name="name" name="name"
@@ -402,16 +362,6 @@ const SchedulerDialog = ({
> >
{creating ? LL.ADD(0) : LL.UPDATE()} {creating ? LL.ADD(0) : LL.UPDATE()}
</Button> </Button>
{isImmediateSchedule && !creating && editItem.cmd !== '' && (
<Button
startIcon={<PlayArrowIcon />}
variant="outlined"
onClick={execute}
color="success"
>
{LL.EXECUTE()}
</Button>
)}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); );

View File

@@ -354,16 +354,14 @@ export interface ScheduleItem {
deleted?: boolean; deleted?: boolean;
flags: number; flags: number;
time: string; // also used for Condition and On Change time: string; // also used for Condition and On Change
cmd: string; cmd_name: string; // references a named Command
value: string;
name: string; name: string;
o_id?: number; o_id?: number;
o_active?: boolean; o_active?: boolean;
o_deleted?: boolean; o_deleted?: boolean;
o_flags?: number; o_flags?: number;
o_time?: string; o_time?: string;
o_cmd?: string; o_cmd_name?: string;
o_value?: string;
o_name?: string; o_name?: string;
} }
@@ -371,6 +369,23 @@ export interface Schedule {
readonly schedule: readonly ScheduleItem[]; readonly schedule: readonly ScheduleItem[];
} }
export interface CommandItem {
id: number;
cmd: string;
value: string;
name: string;
deleted?: boolean;
o_id?: number;
o_cmd?: string;
o_value?: string;
o_name?: string;
o_deleted?: boolean;
}
export interface Commands {
readonly commands: readonly CommandItem[];
}
export interface ModuleItem { export interface ModuleItem {
id: number; // unique index id: number; // unique index
key: string; key: string;
@@ -401,8 +416,7 @@ export enum ScheduleFlag {
SCHEDULE_DAY = 0, // no bits set SCHEDULE_DAY = 0, // no bits set
SCHEDULE_TIMER = 128, // bit 8 SCHEDULE_TIMER = 128, // bit 8
SCHEDULE_ONCHANGE = 129, // bit 1 SCHEDULE_ONCHANGE = 129, // bit 1
SCHEDULE_CONDITION = 130, // bit 2 SCHEDULE_CONDITION = 130 // bit 2
SCHEDULE_IMMEDIATE = 132 // bit 3
} }
export interface EntityItem { export interface EntityItem {
@@ -445,6 +459,7 @@ export const enum DeviceType {
ANALOGSENSOR = 2, ANALOGSENSOR = 2,
SCHEDULER = 3, SCHEDULER = 3,
CUSTOM = 4, CUSTOM = 4,
COMMAND = 5,
BOILER, BOILER,
THERMOSTAT, THERMOSTAT,
MIXER, MIXER,

View File

@@ -4,6 +4,7 @@ import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
import type { import type {
AnalogSensor, AnalogSensor,
CommandItem,
DeviceValue, DeviceValue,
EntityItem, EntityItem,
ScheduleItem, ScheduleItem,
@@ -237,6 +238,24 @@ export const schedulerItemValidation = (
NAME_PATTERN_REQUIRED, NAME_PATTERN_REQUIRED,
uniqueNameValidator(schedule, scheduleItem.o_name) uniqueNameValidator(schedule, scheduleItem.o_name)
], ],
cmd_name: [
{ required: true, message: 'Command is required' }
]
});
export const uniqueCommandNameValidator = (commands: CommandItem[], o_name?: string) =>
createUniqueNameValidator(commands, o_name);
export const commandItemValidation = (
commands: CommandItem[],
commandItem: CommandItem
) =>
new Schema({
name: [
{ required: true, message: 'Name is required' },
NAME_PATTERN_REQUIRED,
uniqueCommandNameValidator(commands, commandItem.o_name)
],
cmd: [ cmd: [
{ required: true, message: 'Command is required' }, { required: true, message: 'Command is required' },
{ {

View File

@@ -7,6 +7,7 @@ import ConstructionIcon from '@mui/icons-material/Construction';
import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
import LiveHelpIcon from '@mui/icons-material/LiveHelp'; import LiveHelpIcon from '@mui/icons-material/LiveHelp';
import MoreTimeIcon from '@mui/icons-material/MoreTime'; import MoreTimeIcon from '@mui/icons-material/MoreTime';
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd'; import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import SensorsIcon from '@mui/icons-material/Sensors'; import SensorsIcon from '@mui/icons-material/Sensors';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
@@ -80,6 +81,12 @@ const LayoutMenuComponent = () => {
disabled={!me.admin} disabled={!me.admin}
to={`/customizations`} to={`/customizations`}
/> />
<LayoutMenuItem
icon={PlaylistPlayIcon}
label={LL.COMMANDS()}
disabled={!me.admin}
to={`/commands`}
/>
<LayoutMenuItem <LayoutMenuItem
icon={MoreTimeIcon} icon={MoreTimeIcon}
label={LL.SCHEDULER()} label={LL.SCHEDULER()}

View File

@@ -286,14 +286,13 @@ const cz: Translation = {
STAY: 'Zůstat', STAY: 'Zůstat',
LEAVE: 'Odejít', LEAVE: 'Odejít',
SCHEDULER: 'Plánovač', SCHEDULER: 'Plánovač',
SCHEDULER_HELP_1: 'Automatizujte příkazy přidáním naplánovaných událostí níže. Nastavte jedinečný název pro povolení/zakázání aktivace přes API/MQTT', SCHEDULER_HELP_1: 'Automatizujte příkazy přidáním naplánovaných událostí níže',
SCHEDULER_HELP_2: 'Použijte 00:00 pro spuštění při startu', SCHEDULER_HELP_2: 'Použijte 00:00 pro spuštění při startu',
SCHEDULE: 'Harmonogram', SCHEDULE: 'Harmonogram',
TIME: 'Čas', TIME: 'Čas',
TIMER: 'Časovač', TIMER: 'Časovač',
ONCHANGE: 'Při změně', ONCHANGE: 'Při změně',
CONDITION: 'Podmínka', CONDITION: 'Podmínka',
IMMEDIATE: 'Ihned',
SCHEDULE_UPDATED: 'Harmonogram aktualizován', SCHEDULE_UPDATED: 'Harmonogram aktualizován',
SCHEDULE_TIMER_1: 'při startu', SCHEDULE_TIMER_1: 'při startu',
SCHEDULE_TIMER_2: 'každou minutu', SCHEDULE_TIMER_2: 'každou minutu',
@@ -365,7 +364,10 @@ const cz: Translation = {
WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?', WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?',
TEST_EMAIL_SUCCESSFUL: 'Test email byl úspěšně odeslán', TEST_EMAIL_SUCCESSFUL: 'Test email byl úspěšně odeslán',
SYSTEM_NAME: 'Název systému', SYSTEM_NAME: 'Název systému',
EXECUTE_SCHEDULE_SENT: 'Plán byl úspěšně proveden' COMMANDS: 'Příkazy',
COMMANDS_UPDATED: 'Příkazy byly aktualizovány',
COMMANDS_HELP_1: 'Definujte vlastní příkazy pro magistrali EMS',
EXECUTE_COMMAND_SENT: 'Příkaz byl odeslán.',
}; };
export default cz; export default cz;

View File

@@ -286,14 +286,13 @@ const de: Translation = {
STAY: 'Bleiben', STAY: 'Bleiben',
LEAVE: 'Verlassen', LEAVE: 'Verlassen',
SCHEDULER: 'Planer', SCHEDULER: 'Planer',
SCHEDULER_HELP_1: 'Fügen Sie eigene geplante Befehle zur Automatisierung hinzu. Vergeben Sie einen Entitätsnamen, um die Aktivierung über API/Mqtt zu steuern', SCHEDULER_HELP_1: 'Fügen Sie eigene geplante Befehle zur Automatisierung hinzu',
SCHEDULER_HELP_2: '00:00 aktiviert einmalige Ausführung beim Start.', SCHEDULER_HELP_2: '00:00 aktiviert einmalige Ausführung beim Start.',
SCHEDULE: 'Zeitplan', SCHEDULE: 'Zeitplan',
TIME: 'Zeit', TIME: 'Zeit',
TIMER: 'Timer', TIMER: 'Timer',
ONCHANGE: 'Bei Änderung', ONCHANGE: 'Bei Änderung',
CONDITION: 'Zustand', CONDITION: 'Zustand',
IMMEDIATE: 'Sofort',
SCHEDULE_UPDATED: 'Zeitplan aktualisiert', SCHEDULE_UPDATED: 'Zeitplan aktualisiert',
SCHEDULE_TIMER_1: 'beim Start', SCHEDULE_TIMER_1: 'beim Start',
SCHEDULE_TIMER_2: 'jede Minute', SCHEDULE_TIMER_2: 'jede Minute',
@@ -365,7 +364,10 @@ const de: Translation = {
WARNING_SYSTEM_BACKUP: 'Dies wird eine Sicherung Ihrer vollständigen Systemkonfiguration und Einstellungen erstellen. Alle Passwörter werden in dieser Sicherungsdatei lesbar sein. Seien Sie vorsichtig beim Teilen! Möchten Sie fortfahren?', WARNING_SYSTEM_BACKUP: 'Dies wird eine Sicherung Ihrer vollständigen Systemkonfiguration und Einstellungen erstellen. Alle Passwörter werden in dieser Sicherungsdatei lesbar sein. Seien Sie vorsichtig beim Teilen! Möchten Sie fortfahren?',
TEST_EMAIL_SUCCESSFUL: 'Test email erfolgreich gesendet', TEST_EMAIL_SUCCESSFUL: 'Test email erfolgreich gesendet',
SYSTEM_NAME: 'Systemname', SYSTEM_NAME: 'Systemname',
EXECUTE_SCHEDULE_SENT: 'Zeitplan erfolgreich ausgeführt' COMMANDS: 'Befehle',
COMMANDS_UPDATED: 'Befehle wurden aktualisiert',
COMMANDS_HELP_1: 'Definieren Sie eigene Befehle für die EMS-Magistral',
EXECUTE_COMMAND_SENT: 'Befehl wurde ausgeführt.',
}; };
export default de; export default de;

View File

@@ -285,22 +285,22 @@ const en: Translation = {
BLOCK_NAVIGATE_2: 'If you navigate to a different page, your unsaved changes will be lost. Are you sure you want to leave this page?', BLOCK_NAVIGATE_2: 'If you navigate to a different page, your unsaved changes will be lost. Are you sure you want to leave this page?',
STAY: 'Stay', STAY: 'Stay',
LEAVE: 'Leave', LEAVE: 'Leave',
COMMANDS: 'Commands',
COMMANDS_HELP_1: 'Define reusable named commands below. These can be executed from the console, API/MQTT, or referenced by the Scheduler',
EXECUTE_COMMAND_SENT: 'Command executed successfully',
SCHEDULER: 'Scheduler', SCHEDULER: 'Scheduler',
SCHEDULER_HELP_1: 'Automate commands by adding scheduled events below. Set a unique Name to enable/disable activation via API/MQTT', SCHEDULER_HELP_1: 'Automate commands by adding scheduled events below',
SCHEDULER_HELP_2: 'Use 00:00 to trigger once on start-up', SCHEDULER_HELP_2: 'Use 00:00 to trigger once on start-up',
SCHEDULE: 'Schedule', SCHEDULE: 'Schedule',
TIME: 'Time', TIME: 'Time',
TIMER: 'Timer', TIMER: 'Timer',
ONCHANGE: 'On Change', ONCHANGE: 'On Change',
CONDITION: 'Condition', CONDITION: 'Condition',
IMMEDIATE: 'Immediate',
SCHEDULE_UPDATED: 'Schedule updated',
SCHEDULE_TIMER_1: 'on startup', SCHEDULE_TIMER_1: 'on startup',
SCHEDULE_TIMER_2: 'every minute', SCHEDULE_TIMER_2: 'every minute',
SCHEDULE_TIMER_3: 'every hour', SCHEDULE_TIMER_3: 'every hour',
CUSTOM_ENTITIES: 'Custom Entities', CUSTOM_ENTITIES: 'Custom Entities',
ENTITIES_HELP_1: 'Define custom EMS entities or dynamic user variables', ENTITIES_HELP_1: 'Define custom EMS entities or dynamic user variables',
ENTITIES_UPDATED: 'Entities Updated',
WRITEABLE: 'Writeable', WRITEABLE: 'Writeable',
SHOWING: 'Showing', SHOWING: 'Showing',
SEARCH: 'Search', SEARCH: 'Search',
@@ -321,7 +321,6 @@ const en: Translation = {
DOWNLOAD_UPLOAD_1: 'Download and Upload Settings and Firmware', DOWNLOAD_UPLOAD_1: 'Download and Upload Settings and Firmware',
MODULES: 'Modules', MODULES: 'Modules',
MODULES_1: 'Activate or deactivate external modules', MODULES_1: 'Activate or deactivate external modules',
MODULES_UPDATED: 'Modules updated',
MODULES_DESCRIPTION: 'Click on the Module to activate or de-activate EMS-ESP library modules', MODULES_DESCRIPTION: 'Click on the Module to activate or de-activate EMS-ESP library modules',
MODULES_NONE: 'No external modules detected', MODULES_NONE: 'No external modules detected',
RENAME: 'Rename', RENAME: 'Rename',
@@ -364,8 +363,7 @@ const en: Translation = {
UPGRADE_IMPORTANT_MESSAGES_2: 'You are upgrading to a new major version. Make sure you have read the ChangeLog for any breaking changes.', UPGRADE_IMPORTANT_MESSAGES_2: 'You are upgrading to a new major version. Make sure you have read the ChangeLog for any breaking changes.',
WARNING_SYSTEM_BACKUP: 'This will create a backup of your full system configuration and settings. All passwords will be readable in the backup file. Be careful with sharing! Do you want to continue?', WARNING_SYSTEM_BACKUP: 'This will create a backup of your full system configuration and settings. All passwords will be readable in the backup file. Be careful with sharing! Do you want to continue?',
TEST_EMAIL_SUCCESSFUL: 'Test email sent successfully', TEST_EMAIL_SUCCESSFUL: 'Test email sent successfully',
SYSTEM_NAME: 'System Name', SYSTEM_NAME: 'System Name'
EXECUTE_SCHEDULE_SENT: 'Schedule executed successfully'
}; };
export default en; export default en;

View File

@@ -286,14 +286,13 @@ const fr: Translation = {
STAY: 'Rester', STAY: 'Rester',
LEAVE: 'Quitter', LEAVE: 'Quitter',
SCHEDULER: 'Scheduler', SCHEDULER: 'Scheduler',
SCHEDULER_HELP_1: 'Automatiser les commandes en ajoutant des événements programmés ci-dessous. Définissez un nom unique pour activer/désactiver l\'activation via API/MQTT', SCHEDULER_HELP_1: 'Automatiser les commandes en ajoutant des événements programmés ci-dessous',
SCHEDULER_HELP_2: 'Utiliser 00:00 pour déclencher une fois au démarrage', SCHEDULER_HELP_2: 'Utiliser 00:00 pour déclencher une fois au démarrage',
SCHEDULE: 'Programme', SCHEDULE: 'Programme',
TIME: 'Temps', TIME: 'Temps',
TIMER: 'Minuteur', TIMER: 'Minuteur',
ONCHANGE: 'Sur le changement', ONCHANGE: 'Sur le changement',
CONDITION: 'Condition', CONDITION: 'Condition',
IMMEDIATE: 'Immédiat',
SCHEDULE_UPDATED: 'Programme mis à jour', SCHEDULE_UPDATED: 'Programme mis à jour',
SCHEDULE_TIMER_1: 'au démarrage', SCHEDULE_TIMER_1: 'au démarrage',
SCHEDULE_TIMER_2: 'toutes les minutes', SCHEDULE_TIMER_2: 'toutes les minutes',
@@ -365,7 +364,10 @@ const fr: Translation = {
WARNING_SYSTEM_BACKUP: 'Cela créera une sauvegarde de votre configuration et paramètres complets. Tous les mots de passe seront lisibles dans le fichier de sauvegarde. Soyez prudent avec le partage ! Voulez-vous continuer ?', WARNING_SYSTEM_BACKUP: 'Cela créera une sauvegarde de votre configuration et paramètres complets. Tous les mots de passe seront lisibles dans le fichier de sauvegarde. Soyez prudent avec le partage ! Voulez-vous continuer ?',
TEST_EMAIL_SUCCESSFUL: 'Test email envoyé avec succès', TEST_EMAIL_SUCCESSFUL: 'Test email envoyé avec succès',
SYSTEM_NAME: 'Nom du système', SYSTEM_NAME: 'Nom du système',
EXECUTE_SCHEDULE_SENT: 'Planlegger exécuté avec succès' COMMANDS: 'Commandes',
COMMANDS_UPDATED: 'Commandes mises à jour',
COMMANDS_HELP_1: 'Définir des commandes personnalisées pour la magistral EMS',
EXECUTE_COMMAND_SENT: 'Commande exécutée avec succès.',
}; };
export default fr; export default fr;

View File

@@ -286,14 +286,13 @@ const it: Translation = {
STAY: 'Stai', STAY: 'Stai',
LEAVE: 'Esci', LEAVE: 'Esci',
SCHEDULER: 'Programma eventi', SCHEDULER: 'Programma eventi',
SCHEDULER_HELP_1: "Automatizza i comandi aggiungendo gli eventi programmati di seguito. Imposta un nome univoco per abilitare/disabilitare l'attivazione tramite API/MQTT", SCHEDULER_HELP_1: "Automatizza i comandi aggiungendo gli eventi programmati di seguito",
SCHEDULER_HELP_2: "per attivare una volta all'avvio", SCHEDULER_HELP_2: "per attivare una volta all'avvio",
SCHEDULE: 'Programma', SCHEDULE: 'Programma',
TIME: 'Ora', TIME: 'Ora',
TIMER: 'Orologio', TIMER: 'Orologio',
ONCHANGE: 'Sul cambiamento', ONCHANGE: 'Sul cambiamento',
CONDITION: 'Condizione', CONDITION: 'Condizione',
IMMEDIATE: 'Immediata',
SCHEDULE_UPDATED: 'Calendario aggiornato', SCHEDULE_UPDATED: 'Calendario aggiornato',
SCHEDULE_TIMER_1: 'All avvio', SCHEDULE_TIMER_1: 'All avvio',
SCHEDULE_TIMER_2: 'Ogni minuto', SCHEDULE_TIMER_2: 'Ogni minuto',
@@ -365,7 +364,10 @@ const it: Translation = {
WARNING_SYSTEM_BACKUP: 'Questo creerà un backup delle tue configurazioni e impostazioni complete. Tutte le password saranno leggibili nel file di backup. Sei sicuro di voler continuare?', WARNING_SYSTEM_BACKUP: 'Questo creerà un backup delle tue configurazioni e impostazioni complete. Tutte le password saranno leggibili nel file di backup. Sei sicuro di voler continuare?',
TEST_EMAIL_SUCCESSFUL: 'Test email inviata con successo', TEST_EMAIL_SUCCESSFUL: 'Test email inviata con successo',
SYSTEM_NAME: 'Nome del sistema', SYSTEM_NAME: 'Nome del sistema',
EXECUTE_SCHEDULE_SENT: 'Programma eseguito con successo' COMMANDS: 'Comandi',
COMMANDS_UPDATED: 'Comandi aggiornati',
COMMANDS_HELP_1: 'Definisci comandi personalizzati per la magistrali EMS',
EXECUTE_COMMAND_SENT: 'Comando eseguito con successo.',
}; };
export default it; export default it;

View File

@@ -286,14 +286,13 @@ const nl: Translation = {
STAY: 'Blijven', STAY: 'Blijven',
LEAVE: 'Verlaten', LEAVE: 'Verlaten',
SCHEDULER: 'Scheduler', SCHEDULER: 'Scheduler',
SCHEDULER_HELP_1: 'Automatiseer opdrachten door hieronder geplande gebeurtenissen toe te voegen. Stel een unieke naam in om activering via API/MQTT in/uit te schakelen', SCHEDULER_HELP_1: 'Automatiseer opdrachten door hieronder geplande gebeurtenissen toe te voegen',
SCHEDULER_HELP_2: 'Gebruik 00:00 om eenmaal te activeren bij het opstarten', SCHEDULER_HELP_2: 'Gebruik 00:00 om eenmaal te activeren bij het opstarten',
SCHEDULE: 'Schedule', SCHEDULE: 'Schedule',
TIME: 'Tijd', TIME: 'Tijd',
TIMER: 'Timer', TIMER: 'Timer',
ONCHANGE: 'Op verandering', ONCHANGE: 'Op verandering',
CONDITION: 'Voorwaarde', CONDITION: 'Voorwaarde',
IMMEDIATE: 'Onmiddellijk',
SCHEDULE_UPDATED: 'Schema bijgewerkt', SCHEDULE_UPDATED: 'Schema bijgewerkt',
SCHEDULE_TIMER_1: 'bij het opstarten', SCHEDULE_TIMER_1: 'bij het opstarten',
SCHEDULE_TIMER_2: 'elke minuut', SCHEDULE_TIMER_2: 'elke minuut',
@@ -365,7 +364,10 @@ const nl: Translation = {
WARNING_SYSTEM_BACKUP: 'Dit zal een back-up van uw volledige systeemconfiguratie en instellingen maken. Alle wachtwoorden zijn leesbaar in het back-upbestand. Wees voorzichtig bij delen! Wilt u doorgaan?', WARNING_SYSTEM_BACKUP: 'Dit zal een back-up van uw volledige systeemconfiguratie en instellingen maken. Alle wachtwoorden zijn leesbaar in het back-upbestand. Wees voorzichtig bij delen! Wilt u doorgaan?',
TEST_EMAIL_SUCCESSFUL: 'Test email verzonden succesvol', TEST_EMAIL_SUCCESSFUL: 'Test email verzonden succesvol',
SYSTEM_NAME: 'Systeemnaam', SYSTEM_NAME: 'Systeemnaam',
EXECUTE_SCHEDULE_SENT: 'Planlegger uitgevoerd succesvol' COMMANDS: 'Commando\'s',
COMMANDS_UPDATED: 'Commando\'s bijgewerkt',
COMMANDS_HELP_1: 'Definieer hergebruikbare benoemde commando\'s hieronder. Deze kunnen worden uitgevoerd vanuit de console, API/MQTT, of worden aangeroepen door de Scheduler',
EXECUTE_COMMAND_SENT: 'Commando uitgevoerd.',
}; };
export default nl; export default nl;

View File

@@ -286,14 +286,13 @@ const no: Translation = {
STAY: 'Bli her', STAY: 'Bli her',
LEAVE: 'Forlat', LEAVE: 'Forlat',
SCHEDULER: 'Planlegger', SCHEDULER: 'Planlegger',
SCHEDULER_HELP_1: 'Automatiser kommandoer ved å legge til skedulerte hendelser nedenfor. Sett et unikt navn for å slå på/av aktivering via API/MQTT', SCHEDULER_HELP_1: 'Automatiser kommandoer ved å legge til skedulerte hendelser nedenfor',
SCHEDULER_HELP_2: 'Bruk 00:00 for å kjøre en gang ved oppstart', SCHEDULER_HELP_2: 'Bruk 00:00 for å kjøre en gang ved oppstart',
SCHEDULE: 'Planlegg', SCHEDULE: 'Planlegg',
TIME: 'Tid', TIME: 'Tid',
TIMER: 'Timer', TIMER: 'Timer',
ONCHANGE: 'På endring', ONCHANGE: 'På endring',
CONDITION: 'Betingelse', CONDITION: 'Betingelse',
IMMEDIATE: 'Umiddelbar',
SCHEDULE_UPDATED: 'Planlegger er oppdatert', SCHEDULE_UPDATED: 'Planlegger er oppdatert',
SCHEDULE_TIMER_1: 'ved oppstart', SCHEDULE_TIMER_1: 'ved oppstart',
SCHEDULE_TIMER_2: 'hvert minutt', SCHEDULE_TIMER_2: 'hvert minutt',
@@ -365,7 +364,10 @@ const no: Translation = {
WARNING_SYSTEM_BACKUP: 'Dette vil lage en sikkerhetskopi av din fullstendige systemkonfigurasjon og innstillinger. Alle passord vil være lesbare i sikkerhetskopien. Vær forsiktig med deling! Vil du fortsette?', WARNING_SYSTEM_BACKUP: 'Dette vil lage en sikkerhetskopi av din fullstendige systemkonfigurasjon og innstillinger. Alle passord vil være lesbare i sikkerhetskopien. Vær forsiktig med deling! Vil du fortsette?',
TEST_EMAIL_SUCCESSFUL: 'Test email sendt suksessfullt', TEST_EMAIL_SUCCESSFUL: 'Test email sendt suksessfullt',
SYSTEM_NAME: 'Systemnavn', SYSTEM_NAME: 'Systemnavn',
EXECUTE_SCHEDULE_SENT: 'Planlegger utført suksessfullt' COMMANDS: 'Kommandoer',
COMMANDS_UPDATED: 'Kommandoer oppdatert',
COMMANDS_HELP_1: 'Definer egne kommandoer for EMS-Magistral',
EXECUTE_COMMAND_SENT: 'Kommando utført.',
}; };
export default no; export default no;

View File

@@ -286,14 +286,13 @@ const pl: BaseTranslation = {
STAY: 'Pozostań', STAY: 'Pozostań',
LEAVE: 'Opuść', LEAVE: 'Opuść',
SCHEDULER: 'Harmonogram', SCHEDULER: 'Harmonogram',
SCHEDULER_HELP_1: 'Zautomatyzuj wykonywanie komend, dodając poniżej harmonogram zdarzeń. Nadaj mu unikalną nazwę, aby móc go aktywować/dezaktywować przez API/MQTT', SCHEDULER_HELP_1: 'Zautomatyzuj wykonywanie komend, dodając poniżej harmonogram zdarzeń',
SCHEDULER_HELP_2: 'Wpisz 00:00 aby wykonywać jednorazowo przy starcie.', SCHEDULER_HELP_2: 'Wpisz 00:00 aby wykonywać jednorazowo przy starcie.',
SCHEDULE: '{{H|h|}}armonogram{{|u|}}', SCHEDULE: '{{H|h|}}armonogram{{|u|}}',
TIME: '{{Czas|Godzina|}}', TIME: '{{Czas|Godzina|}}',
TIMER: '{{m|M|}}inutnik', TIMER: '{{m|M|}}inutnik',
ONCHANGE: 'O zmianie', ONCHANGE: 'O zmianie',
CONDITION: 'Stan', CONDITION: 'Stan',
IMMEDIATE: 'Natychmiastowy',
SCHEDULE_UPDATED: 'Harmonogram został uaktualniony.', SCHEDULE_UPDATED: 'Harmonogram został uaktualniony.',
SCHEDULE_TIMER_1: 'przy starcie', SCHEDULE_TIMER_1: 'przy starcie',
SCHEDULE_TIMER_2: 'co minutę', SCHEDULE_TIMER_2: 'co minutę',
@@ -365,7 +364,10 @@ const pl: BaseTranslation = {
WARNING_SYSTEM_BACKUP: 'To spowoduje utworzenie kopii zapasowej całej konfiguracji i ustawień systemu. Wszystkie hasła będą widoczne w pliku kopii zapasowej. Bądź ostrożny przy udostępnianiu! Chcesz kontynuować?', WARNING_SYSTEM_BACKUP: 'To spowoduje utworzenie kopii zapasowej całej konfiguracji i ustawień systemu. Wszystkie hasła będą widoczne w pliku kopii zapasowej. Bądź ostrożny przy udostępnianiu! Chcesz kontynuować?',
TEST_EMAIL_SUCCESSFUL: 'Test email wysłany pomyślnie', TEST_EMAIL_SUCCESSFUL: 'Test email wysłany pomyślnie',
SYSTEM_NAME: 'Nazwa systemu', SYSTEM_NAME: 'Nazwa systemu',
EXECUTE_SCHEDULE_SENT: 'Harmonogram wykonany pomyślnie' COMMANDS: 'Komendy',
COMMANDS_UPDATED: 'Komendy zostały zaktualizowane',
COMMANDS_HELP_1: 'Zdefiniuj niestandardowe komendy dla magistrali EMS',
EXECUTE_COMMAND_SENT: 'Komenda została wysłana.'
}; };
export default pl; export default pl;

View File

@@ -286,14 +286,13 @@ const sk: Translation = {
STAY: 'Zostať', STAY: 'Zostať',
LEAVE: 'Opustiť', LEAVE: 'Opustiť',
SCHEDULER: 'Plánovač', SCHEDULER: 'Plánovač',
SCHEDULER_HELP_1: 'Automatizujte príkazy pridaním naplánovaných udalostí nižšie. Nastavte jedinečné meno na aktiváciu/deaktiváciu cez API/MQTT', SCHEDULER_HELP_1: 'Automatizujte príkazy pridaním naplánovaných udalostí nižšie',
SCHEDULER_HELP_2: 'Použite 00:00 na jednorazové spustenie pri štarte', SCHEDULER_HELP_2: 'Použite 00:00 na jednorazové spustenie pri štarte',
SCHEDULE: 'Plánovač', SCHEDULE: 'Plánovač',
TIME: 'Čas', TIME: 'Čas',
TIMER: 'Časovač', TIMER: 'Časovač',
ONCHANGE: 'Pri zmene', ONCHANGE: 'Pri zmene',
CONDITION: 'Podmienka', CONDITION: 'Podmienka',
IMMEDIATE: 'Okamžite',
SCHEDULE_UPDATED: 'Plánovanie aktualizované', SCHEDULE_UPDATED: 'Plánovanie aktualizované',
SCHEDULE_TIMER_1: 'pri spustení', SCHEDULE_TIMER_1: 'pri spustení',
SCHEDULE_TIMER_2: 'každú minútu', SCHEDULE_TIMER_2: 'každú minútu',
@@ -365,7 +364,10 @@ const sk: Translation = {
WARNING_SYSTEM_BACKUP: 'Toto vytvorí zálohu všetkých vašich celých systémových konfigurácií a nastavení. Všetky hesla budú čitateľné v zálohovom súbore. Buďte opatrní pri zdieľaní! Chcete pokračovať?', WARNING_SYSTEM_BACKUP: 'Toto vytvorí zálohu všetkých vašich celých systémových konfigurácií a nastavení. Všetky hesla budú čitateľné v zálohovom súbore. Buďte opatrní pri zdieľaní! Chcete pokračovať?',
TEST_EMAIL_SUCCESSFUL: 'Test email bol úspešne odoslaný', TEST_EMAIL_SUCCESSFUL: 'Test email bol úspešne odoslaný',
SYSTEM_NAME: 'Názov systému', SYSTEM_NAME: 'Názov systému',
EXECUTE_SCHEDULE_SENT: 'Plán bol úspešne vykonaný' COMMANDS: 'Príkazy',
COMMANDS_UPDATED: 'Príkazy aktualizované',
COMMANDS_HELP_1: 'Definujte vlastné príkazy pre magistrali EMS',
EXECUTE_COMMAND_SENT: 'Príkaz bol vykonaný.',
}; };
export default sk; export default sk;

View File

@@ -286,14 +286,13 @@ const sv: Translation = {
STAY: 'Stanna', STAY: 'Stanna',
LEAVE: 'Lämna', LEAVE: 'Lämna',
SCHEDULER: 'Schemaläggning', SCHEDULER: 'Schemaläggning',
SCHEDULER_HELP_1: 'Automatisera kommandon genom att lägga till schemahändelser nedan. Ange ett unikt namn för att aktivera/avaktivera aktivering via API/MQTT', SCHEDULER_HELP_1: 'Automatisera kommandon genom att lägga till schemahändelser nedan',
SCHEDULER_HELP_2: 'Använd 00:00 för att trigga en gång vid uppstart', SCHEDULER_HELP_2: 'Använd 00:00 för att trigga en gång vid uppstart',
SCHEDULE: 'schema', SCHEDULE: 'schema',
TIME: 'Tid', TIME: 'Tid',
TIMER: 'Timer', TIMER: 'Timer',
ONCHANGE: 'Vid förändring', ONCHANGE: 'Vid förändring',
CONDITION: 'Villkor', CONDITION: 'Villkor',
IMMEDIATE: 'Omedelbar',
SCHEDULE_UPDATED: 'Schema uppdaterat', SCHEDULE_UPDATED: 'Schema uppdaterat',
SCHEDULE_TIMER_1: 'vid uppstart', SCHEDULE_TIMER_1: 'vid uppstart',
SCHEDULE_TIMER_2: 'varje minut', SCHEDULE_TIMER_2: 'varje minut',
@@ -365,7 +364,10 @@ const sv: Translation = {
WARNING_SYSTEM_BACKUP: 'Detta kommer att skapa en säkerhetskopia av din fullständiga systemkonfiguration och inställningar. Alla lösenord kommer att vara läsbara i säkerhetskopien. Var försiktig med att dela! Vill du fortsätta?', WARNING_SYSTEM_BACKUP: 'Detta kommer att skapa en säkerhetskopia av din fullständiga systemkonfiguration och inställningar. Alla lösenord kommer att vara läsbara i säkerhetskopien. Var försiktig med att dela! Vill du fortsätta?',
TEST_EMAIL_SUCCESSFUL: 'Test email skickad lyckades', TEST_EMAIL_SUCCESSFUL: 'Test email skickad lyckades',
SYSTEM_NAME: 'Systemnamn', SYSTEM_NAME: 'Systemnamn',
EXECUTE_SCHEDULE_SENT: 'Schema utfört' COMMANDS: 'Kommandon',
COMMANDS_UPDATED: 'Kommandon uppdaterade',
COMMANDS_HELP_1: 'Definiera egna kommandon för EMS-Magistral',
EXECUTE_COMMAND_SENT: 'Kommando utfört.',
}; };
export default sv; export default sv;

View File

@@ -286,14 +286,13 @@ const tr: Translation = {
STAY: 'Kal', STAY: 'Kal',
LEAVE: ık', LEAVE: ık',
SCHEDULER: 'Zamanlayıcı', SCHEDULER: 'Zamanlayıcı',
SCHEDULER_HELP_1: 'Komutları zamanlayarak otomatikleştirin. Benzersiz bir ad belirtin API/MQTT aracılığıyla etkinleştirmek/devre dışı bırakma', SCHEDULER_HELP_1: 'Komutları zamanlayarak otomatikleştirin',
SCHEDULER_HELP_2: 'Başlangıçta bir kere tetiklemek için 00:00 kullanın', SCHEDULER_HELP_2: 'Başlangıçta bir kere tetiklemek için 00:00 kullanın',
SCHEDULE: 'Zamanlama', SCHEDULE: 'Zamanlama',
TIME: 'Zaman', TIME: 'Zaman',
TIMER: 'Zamanlayıcı', TIMER: 'Zamanlayıcı',
ONCHANGE: 'Değişimde', ONCHANGE: 'Değişimde',
CONDITION: 'Durum', CONDITION: 'Durum',
IMMEDIATE: 'hemen',
SCHEDULE_UPDATED: 'Zamanlama güncellendi', SCHEDULE_UPDATED: 'Zamanlama güncellendi',
SCHEDULE_TIMER_1: 'Başlangıçta', SCHEDULE_TIMER_1: 'Başlangıçta',
SCHEDULE_TIMER_2: 'her dakikada', SCHEDULE_TIMER_2: 'her dakikada',
@@ -365,7 +364,10 @@ const tr: Translation = {
WARNING_SYSTEM_BACKUP: 'Bu, sistem yapılandırmanızı ve ayarlarınızın bir yedeklemesi oluşturacaktır. Tüm şifreler yedekleme dosyasında okunabilir olacaktır. Paylaşırken dikkatli olun! Devam etmek istediğinize emin misiniz?', WARNING_SYSTEM_BACKUP: 'Bu, sistem yapılandırmanızı ve ayarlarınızın bir yedeklemesi oluşturacaktır. Tüm şifreler yedekleme dosyasında okunabilir olacaktır. Paylaşırken dikkatli olun! Devam etmek istediğinize emin misiniz?',
TEST_EMAIL_SUCCESSFUL: 'Test email başarıyla gönderildi', TEST_EMAIL_SUCCESSFUL: 'Test email başarıyla gönderildi',
SYSTEM_NAME: 'Sistem Adı', SYSTEM_NAME: 'Sistem Adı',
EXECUTE_SCHEDULE_SENT: 'Zamanlama başarıyla uygulandı' COMMANDS: 'Komutlar',
COMMANDS_UPDATED: 'Komutlar güncellendi',
COMMANDS_HELP_1: 'Özel komutları EMS hattına tanımlayın',
EXECUTE_COMMAND_SENT: 'Komut başarıyla çalıştırıldı.',
}; };
export default tr; export default tr;

View File

@@ -241,8 +241,7 @@ const enum ScheduleFlag {
SCHEDULE_DAY = 0, SCHEDULE_DAY = 0,
SCHEDULE_TIMER = 128, SCHEDULE_TIMER = 128,
SCHEDULE_ONCHANGE = 129, SCHEDULE_ONCHANGE = 129,
SCHEDULE_CONDITION = 130, SCHEDULE_CONDITION = 130
SCHEDULE_IMMEDIATE = 132
} }
const enum DeviceType { const enum DeviceType {
@@ -251,6 +250,7 @@ const enum DeviceType {
ANALOGSENSOR, ANALOGSENSOR,
SCHEDULER, SCHEDULER,
CUSTOM, CUSTOM,
COMMAND,
BOILER, BOILER,
THERMOSTAT, THERMOSTAT,
MIXER, MIXER,
@@ -271,6 +271,7 @@ const enum DeviceType {
} }
const enum DeviceTypeUniqueID { const enum DeviceTypeUniqueID {
COMMAND_UID = 95,
SCHEDULER_UID = 96, SCHEDULER_UID = 96,
ANALOGSENSOR_UID = 97, ANALOGSENSOR_UID = 97,
TEMPERATURESENSOR_UID = 98, TEMPERATURESENSOR_UID = 98,
@@ -317,10 +318,10 @@ function updateMask(entity: any, de: any, dd: any) {
const old_custom_name = dd.nodes[dd_objIndex].cn; const old_custom_name = dd.nodes[dd_objIndex].cn;
console.log( console.log(
'comparing names, old (' + 'comparing names, old (' +
old_custom_name + old_custom_name +
') with new (' + ') with new (' +
new_custom_name + new_custom_name +
')' ')'
); );
if (old_custom_name !== new_custom_name) { if (old_custom_name !== new_custom_name) {
changed = true; changed = true;
@@ -374,6 +375,8 @@ function export_data(type: string) {
return emsesp_customentities; return emsesp_customentities;
case 'schedule': case 'schedule':
return emsesp_schedule; return emsesp_schedule;
case 'commands':
return emsesp_commands;
case 'modules': case 'modules':
return emsesp_modules; return emsesp_modules;
case 'allvalues': case 'allvalues':
@@ -409,9 +412,9 @@ function custom_support() {
}; };
} }
// run a schedule // run a command
function executeSchedule(name: string) { function executeCommand(name: string) {
console.log('executing schedule', name); console.log('executing command', name);
return status(200); return status(200);
} }
@@ -435,9 +438,9 @@ function upgradeImportantMessages(version: string) {
console.log( console.log(
'upgradeImportantMessageType: version=' + 'upgradeImportantMessageType: version=' +
version + version +
' type=' + ' type=' +
upgradeImportantMessageType_n upgradeImportantMessageType_n
); );
return { upgradeImportantMessageType: upgradeImportantMessageType_n }; return { upgradeImportantMessageType: upgradeImportantMessageType_n };
} }
@@ -484,17 +487,17 @@ function get_versions() {
console.log( console.log(
'getVersions: current=' + 'getVersions: current=' +
THIS_VERSION + THIS_VERSION +
' stable=' + ' stable=' +
LATEST_STABLE_VERSION + LATEST_STABLE_VERSION +
' (upgradeable=' + ' (upgradeable=' +
(STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + (STABLE_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') +
') dev=' + ') dev=' +
LATEST_DEV_VERSION + LATEST_DEV_VERSION +
' (upgradeable=' + ' (upgradeable=' +
(DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') + (DEV_VERSION_IS_UPGRADEABLE ? 'YES' : 'NO') +
')' + ')' +
(MOCK_OFFLINE ? ' [offline]' : '') (MOCK_OFFLINE ? ' [offline]' : '')
); );
return data; return data;
} }
@@ -751,6 +754,7 @@ const EMSESP_RESET_CUSTOMIZATIONS_ENDPOINT =
REST_ENDPOINT_ROOT + 'resetCustomizations'; REST_ENDPOINT_ROOT + 'resetCustomizations';
const EMSESP_SCHEDULE_ENDPOINT = REST_ENDPOINT_ROOT + 'schedule'; const EMSESP_SCHEDULE_ENDPOINT = REST_ENDPOINT_ROOT + 'schedule';
const EMSESP_COMMANDS_ENDPOINT = REST_ENDPOINT_ROOT + 'commands';
const EMSESP_CUSTOMENTITIES_ENDPOINT = REST_ENDPOINT_ROOT + 'customEntities'; const EMSESP_CUSTOMENTITIES_ENDPOINT = REST_ENDPOINT_ROOT + 'customEntities';
const EMSESP_MODULES_ENDPOINT = REST_ENDPOINT_ROOT + 'modules'; const EMSESP_MODULES_ENDPOINT = REST_ENDPOINT_ROOT + 'modules';
const EMSESP_ACTION_ENDPOINT = REST_ENDPOINT_ROOT + 'action'; const EMSESP_ACTION_ENDPOINT = REST_ENDPOINT_ROOT + 'action';
@@ -4147,6 +4151,48 @@ let emsesp_customentities = {
] ]
}; };
// COMMANDS
let emsesp_commands = {
commands: [
{
id: 1,
cmd: 'hc1/mode',
value: 'day',
name: 'set_day_mode'
},
{
id: 2,
cmd: 'hc1/mode',
value: 'night',
name: 'set_night_mode'
},
{
id: 3,
cmd: 'thermostat/hc2/seltemp',
value: '20',
name: 'set_temp_20'
},
{
id: 4,
cmd: 'system/restart',
value: '',
name: 'restart_system'
},
{
id: 5,
cmd: 'boiler/selflowtemp',
value: '(custom/setpoint - boiler/outdoortemp) * 2.8 + 3',
name: 'heatingcurve'
},
{
id: 6,
cmd: 'system/message',
value: '"hello world"',
name: 'send_message'
}
]
};
// SCHEDULE // SCHEDULE
let emsesp_schedule = { let emsesp_schedule = {
schedule: [ schedule: [
@@ -4155,8 +4201,7 @@ let emsesp_schedule = {
active: true, active: true,
flags: 6, flags: 6,
time: '07:30', time: '07:30',
cmd: 'hc1/mode', cmd_name: 'set_day_mode',
value: 'day',
name: 'day_mode' name: 'day_mode'
}, },
{ {
@@ -4164,8 +4209,7 @@ let emsesp_schedule = {
active: true, active: true,
flags: 31, flags: 31,
time: '23:00', time: '23:00',
cmd: 'hc1/mode', cmd_name: 'set_night_mode',
value: 'night',
name: 'night_mode' name: 'night_mode'
}, },
{ {
@@ -4173,8 +4217,7 @@ let emsesp_schedule = {
active: true, active: true,
flags: 10, flags: 10,
time: '00:00', time: '00:00',
cmd: 'thermostat/hc2/seltemp', cmd_name: 'set_temp_20',
value: '20',
name: 'temp_20' name: 'temp_20'
}, },
{ {
@@ -4182,8 +4225,7 @@ let emsesp_schedule = {
active: false, active: false,
flags: ScheduleFlag.SCHEDULE_TIMER, flags: ScheduleFlag.SCHEDULE_TIMER,
time: '04:00', time: '04:00',
cmd: 'system/restart', cmd_name: 'restart_system',
value: '',
name: 'auto_restart' name: 'auto_restart'
}, },
{ {
@@ -4191,8 +4233,7 @@ let emsesp_schedule = {
active: false, active: false,
flags: ScheduleFlag.SCHEDULE_CONDITION, flags: ScheduleFlag.SCHEDULE_CONDITION,
time: 'system/network info/rssi < -70', time: 'system/network info/rssi < -70',
cmd: 'system/restart', cmd_name: 'restart_system',
value: '',
name: 'bad_wifi' name: 'bad_wifi'
}, },
{ {
@@ -4200,18 +4241,8 @@ let emsesp_schedule = {
active: false, active: false,
flags: ScheduleFlag.SCHEDULE_ONCHANGE, flags: ScheduleFlag.SCHEDULE_ONCHANGE,
time: 'boiler/outdoortemp', time: 'boiler/outdoortemp',
cmd: 'boiler/selflowtemp', cmd_name: 'heatingcurve',
value: '(custom/setpoint - boiler/outdoortemp) * 2.8 + 3',
name: 'heatingcurve' name: 'heatingcurve'
},
{
id: 7,
active: false,
flags: ScheduleFlag.SCHEDULE_IMMEDIATE,
time: '',
cmd: 'system/message',
value: '"hello world"',
name: 'send_message'
} }
] ]
}; };
@@ -4765,20 +4796,15 @@ router
} }
// add the scheduler data // add the scheduler data
// filter emsesp_schedule with only if it has a name and create data in one pass const namedSchedules = emsesp_schedule.schedule.filter((item: any) => item.name);
const namedSchedules = emsesp_schedule.schedule.filter((item) => item.name);
if (namedSchedules.length > 0) { if (namedSchedules.length > 0) {
const scheduler_data = namedSchedules.map((item, index) => ({ const scheduler_data = namedSchedules.map((item: any, index: number) => ({
id: DeviceTypeUniqueID.SCHEDULER_UID * 100 + index, id: DeviceTypeUniqueID.SCHEDULER_UID * 100 + index,
dv: { dv: {
id: '00' + item.name, id: '00' + item.name,
c: item.name, c: item.name,
...(item.flags === ScheduleFlag.SCHEDULE_IMMEDIATE v: item.active ? 'on' : 'off',
? {} l: ['off', 'on']
: {
v: item.active ? 'on' : 'off',
l: ['off', 'on']
})
} }
})); }));
dashboard_object = { dashboard_object = {
@@ -4788,6 +4814,24 @@ router
}; };
dashboard_nodes.push(dashboard_object); dashboard_nodes.push(dashboard_object);
} }
// add the command items (executable from dashboard)
const namedCommands = emsesp_commands.commands.filter((item: any) => item.name);
if (namedCommands.length > 0) {
const command_data = namedCommands.map((item: any, index: number) => ({
id: DeviceTypeUniqueID.COMMAND_UID * 100 + index,
dv: {
id: '00' + item.name,
c: item.name
}
}));
dashboard_object = {
id: DeviceTypeUniqueID.COMMAND_UID,
t: DeviceType.COMMAND,
nodes: command_data
};
dashboard_nodes.push(dashboard_object);
}
} else { } else {
// for testing only // for testing only
// add the custom entity data // add the custom entity data
@@ -4877,6 +4921,15 @@ router
return status(200); return status(200);
}) })
// Commands
.post(EMSESP_COMMANDS_ENDPOINT, async (request: any) => {
const content = await request.json();
emsesp_commands = content;
console.log('commands saved', emsesp_commands);
return status(200);
})
.get(EMSESP_COMMANDS_ENDPOINT, () => emsesp_commands)
// Scheduler // Scheduler
.post(EMSESP_SCHEDULE_ENDPOINT, async (request: any) => { .post(EMSESP_SCHEDULE_ENDPOINT, async (request: any) => {
const content = await request.json(); const content = await request.json();
@@ -4977,15 +5030,17 @@ router
} }
if (id === DeviceTypeUniqueID.SCHEDULER_UID) { if (id === DeviceTypeUniqueID.SCHEDULER_UID) {
// toggle scheduler // toggle scheduler
// find the schedule in emsesp_schedule via the name and toggle the active
const objIndex = emsesp_schedule.schedule.findIndex( const objIndex = emsesp_schedule.schedule.findIndex(
(obj) => obj.name === command (obj: any) => obj.name === command
); );
if (objIndex !== -1) { if (objIndex !== -1) {
emsesp_schedule.schedule[objIndex].active = value; emsesp_schedule.schedule[objIndex].active = value;
console.log("Toggle schedule '" + command + "' to " + value); console.log("Toggle schedule '" + command + "' to " + value);
} }
} }
if (id === DeviceTypeUniqueID.COMMAND_UID) {
console.log("Execute command '" + command + "'");
}
// await delay(1000); // wait to show spinner // await delay(1000); // wait to show spinner
// console.log( // console.log(
@@ -5246,9 +5301,9 @@ router
} else if (action === 'upgradeImportantMessages') { } else if (action === 'upgradeImportantMessages') {
// check upgrade important messages // check upgrade important messages
return upgradeImportantMessages(content.param); return upgradeImportantMessages(content.param);
} else if (action === 'executeSchedule') { } else if (action === 'executeCommand') {
// execute schedule // execute command
return executeSchedule(content.param); return executeCommand(content.param);
} }
} }
return status(404); // cmd not found return status(404); // cmd not found

View File

@@ -716,6 +716,10 @@ bool Command::device_has_commands(const uint8_t device_type) {
return true; return true;
} }
if (device_type == EMSdevice::DeviceType::COMMAND) {
return true;
}
if (device_type == EMSdevice::DeviceType::CUSTOM) { if (device_type == EMSdevice::DeviceType::CUSTOM) {
return true; return true;
} }
@@ -741,6 +745,7 @@ bool Command::device_has_commands(const uint8_t device_type) {
void Command::show_devices(uuid::console::Shell & shell) { void Command::show_devices(uuid::console::Shell & shell) {
shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::SYSTEM)); shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::SYSTEM));
shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::CUSTOM)); shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::CUSTOM));
shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::COMMAND));
shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::SCHEDULER)); shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::SCHEDULER));
if (EMSESP::sensor_enabled()) { if (EMSESP::sensor_enabled()) {
shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::TEMPERATURESENSOR)); shell.printf("%s ", EMSdevice::device_type_2_device_name(EMSdevice::DeviceType::TEMPERATURESENSOR));
@@ -779,6 +784,7 @@ void Command::show_all(uuid::console::Shell & shell) {
// show system ones first // show system ones first
show(shell, EMSdevice::DeviceType::SYSTEM, true); show(shell, EMSdevice::DeviceType::SYSTEM, true);
show(shell, EMSdevice::DeviceType::CUSTOM, true); show(shell, EMSdevice::DeviceType::CUSTOM, true);
show(shell, EMSdevice::DeviceType::COMMAND, true);
show(shell, EMSdevice::DeviceType::SCHEDULER, true); show(shell, EMSdevice::DeviceType::SCHEDULER, true);
// then sensors // then sensors

View File

@@ -145,6 +145,8 @@ const char * EMSdevice::device_type_2_device_name(const uint8_t device_type) {
return F_(scheduler); return F_(scheduler);
case DeviceType::CUSTOM: case DeviceType::CUSTOM:
return F_(custom); return F_(custom);
case DeviceType::COMMAND:
return F_(commands);
case DeviceType::BOILER: case DeviceType::BOILER:
return F_(boiler); return F_(boiler);
case DeviceType::THERMOSTAT: case DeviceType::THERMOSTAT:
@@ -297,6 +299,9 @@ uint8_t EMSdevice::device_name_2_device_type(const char * topic) {
if (!strcmp(lowtopic, F_(scheduler))) { if (!strcmp(lowtopic, F_(scheduler))) {
return DeviceType::SCHEDULER; return DeviceType::SCHEDULER;
} }
if (!strcmp(lowtopic, F_(commands))) {
return DeviceType::COMMAND;
}
if (!strcmp(lowtopic, F_(system))) { if (!strcmp(lowtopic, F_(system))) {
return DeviceType::SYSTEM; return DeviceType::SYSTEM;
} }

View File

@@ -405,6 +405,7 @@ class EMSdevice {
// Unique Identifiers for each Device type, used in Dashboard table // Unique Identifiers for each Device type, used in Dashboard table
// 100 and above is reserved for DeviceType // 100 and above is reserved for DeviceType
enum DeviceTypeUniqueID : uint8_t { enum DeviceTypeUniqueID : uint8_t {
COMMAND_UID = 95,
SCHEDULER_UID = 96, SCHEDULER_UID = 96,
ANALOGSENSOR_UID = 97, ANALOGSENSOR_UID = 97,
TEMPERATURESENSOR_UID = 98, TEMPERATURESENSOR_UID = 98,
@@ -417,6 +418,7 @@ class EMSdevice {
ANALOGSENSOR, // for internal analog sensors ANALOGSENSOR, // for internal analog sensors
SCHEDULER, // for internal schedule SCHEDULER, // for internal schedule
CUSTOM, // for user defined entities CUSTOM, // for user defined entities
COMMAND, // for user defined commands
BOILER, // from here on enum the ems-devices BOILER, // from here on enum the ems-devices
THERMOSTAT, THERMOSTAT,
MIXER, MIXER,

View File

@@ -56,6 +56,7 @@ ESP32React EMSESP::esp32React(&webServer, &dummyFS);
WebSettingsService EMSESP::webSettingsService = WebSettingsService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager()); WebSettingsService EMSESP::webSettingsService = WebSettingsService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager());
WebCustomizationService EMSESP::webCustomizationService = WebCustomizationService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager()); WebCustomizationService EMSESP::webCustomizationService = WebCustomizationService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager());
WebSchedulerService EMSESP::webSchedulerService = WebSchedulerService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager()); WebSchedulerService EMSESP::webSchedulerService = WebSchedulerService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager());
WebCommandService EMSESP::webCommandService = WebCommandService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager());
WebCustomEntityService EMSESP::webCustomEntityService = WebCustomEntityService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager()); WebCustomEntityService EMSESP::webCustomEntityService = WebCustomEntityService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager());
WebModulesService EMSESP::webModulesService = WebModulesService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager()); WebModulesService EMSESP::webModulesService = WebModulesService(&webServer, &dummyFS, EMSESP::esp32React.getSecurityManager());
#else #else
@@ -63,6 +64,7 @@ ESP32React EMSESP::esp32React(&webServer, &LittleFS);
WebSettingsService EMSESP::webSettingsService = WebSettingsService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager()); WebSettingsService EMSESP::webSettingsService = WebSettingsService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager());
WebCustomizationService EMSESP::webCustomizationService = WebCustomizationService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager()); WebCustomizationService EMSESP::webCustomizationService = WebCustomizationService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager());
WebSchedulerService EMSESP::webSchedulerService = WebSchedulerService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager()); WebSchedulerService EMSESP::webSchedulerService = WebSchedulerService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager());
WebCommandService EMSESP::webCommandService = WebCommandService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager());
WebCustomEntityService EMSESP::webCustomEntityService = WebCustomEntityService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager()); WebCustomEntityService EMSESP::webCustomEntityService = WebCustomEntityService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager());
WebModulesService EMSESP::webModulesService = WebModulesService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager()); WebModulesService EMSESP::webModulesService = WebModulesService(&webServer, &LittleFS, EMSESP::esp32React.getSecurityManager());
#endif #endif
@@ -682,6 +684,7 @@ void EMSESP::publish_other_values() {
// publish_device_values(EMSdevice::DeviceType::GENERIC); // publish_device_values(EMSdevice::DeviceType::GENERIC);
webSchedulerService.publish(); webSchedulerService.publish();
webCommandService.publish();
webCustomEntityService.publish(); webCustomEntityService.publish();
} }
@@ -788,6 +791,11 @@ bool EMSESP::get_device_value_info(JsonObject root, const char * cmd, const int8
return webSchedulerService.get_value_info(root, cmd); return webSchedulerService.get_value_info(root, cmd);
} }
// commands
if (devicetype == DeviceType::COMMAND) {
return webCommandService.get_value_info(root, cmd);
}
// custom entities // custom entities
if (devicetype == DeviceType::CUSTOM) { if (devicetype == DeviceType::CUSTOM) {
return webCustomEntityService.get_value_info(root, cmd); return webCustomEntityService.get_value_info(root, cmd);
@@ -1761,6 +1769,7 @@ void EMSESP::start() {
// this will also handle any MQTT subscriptions // this will also handle any MQTT subscriptions
webCustomizationService.begin(); // load the customizations webCustomizationService.begin(); // load the customizations
webSchedulerService.begin(); // load the scheduler events webSchedulerService.begin(); // load the scheduler events
webCommandService.begin(); // load the user commands
webCustomEntityService.begin(); // load the custom telegram reads webCustomEntityService.begin(); // load the custom telegram reads
// perform any system upgrades // perform any system upgrades

View File

@@ -51,6 +51,7 @@
#include "../web/WebSettingsService.h" #include "../web/WebSettingsService.h"
#include "../web/WebCustomizationService.h" #include "../web/WebCustomizationService.h"
#include "../web/WebSchedulerService.h" #include "../web/WebSchedulerService.h"
#include "../web/WebCommandService.h"
#include "../web/WebAPIService.h" #include "../web/WebAPIService.h"
#include "../web/WebLogService.h" #include "../web/WebLogService.h"
#include "../web/WebCustomEntityService.h" #include "../web/WebCustomEntityService.h"
@@ -260,6 +261,7 @@ class EMSESP {
static WebLogService webLogService; static WebLogService webLogService;
static WebCustomizationService webCustomizationService; static WebCustomizationService webCustomizationService;
static WebSchedulerService webSchedulerService; static WebSchedulerService webSchedulerService;
static WebCommandService webCommandService;
static WebCustomEntityService webCustomEntityService; static WebCustomEntityService webCustomEntityService;
static WebModulesService webModulesService; static WebModulesService webModulesService;

View File

@@ -76,6 +76,7 @@ MAKE_WORD_TRANSLATION(watch_cmd, "watch incoming telegrams", "Beobachte eingehen
MAKE_WORD_TRANSLATION(publish_cmd, "publish all to MQTT", "Publiziere MQTT", "publiceer alles naar MQTT", "publicera allt till MQTT", "opublikuj wszystko na MQTT", "Publiser alt til MQTT", "publier tout vers MQTT", "Hepsini MQTTye gönder", "pubblica tutto su MQTT", "zverejniť všetko na MQTT", "publikovat vše do MQTT") MAKE_WORD_TRANSLATION(publish_cmd, "publish all to MQTT", "Publiziere MQTT", "publiceer alles naar MQTT", "publicera allt till MQTT", "opublikuj wszystko na MQTT", "Publiser alt til MQTT", "publier tout vers MQTT", "Hepsini MQTTye gönder", "pubblica tutto su MQTT", "zverejniť všetko na MQTT", "publikovat vše do MQTT")
MAKE_WORD_TRANSLATION(system_info_cmd, "show system info", "Zeige Systeminformationen", "toon systeemstatus", "visa systeminformation", "pokaż status systemu", "vis system status", "afficher les informations système", "Sistem Durumunu Göster", "visualizza stati di sistema", "zobraziť stav systému", "zobrazit informace o systému") MAKE_WORD_TRANSLATION(system_info_cmd, "show system info", "Zeige Systeminformationen", "toon systeemstatus", "visa systeminformation", "pokaż status systemu", "vis system status", "afficher les informations système", "Sistem Durumunu Göster", "visualizza stati di sistema", "zobraziť stav systému", "zobrazit informace o systému")
MAKE_WORD_TRANSLATION(schedule_cmd, "enable schedule item", "Aktiviere Zeitplanelemente", "activeer tijdschema item", "aktivera schemalagt objekt", "aktywuj wybrany harmonogram", "aktiver planlagt element", "activer élément programmé", "program öğesini etkinleştir", "abilitare l'elemento programmato", "povoliť položku plánovania", "povolit položku plánování") MAKE_WORD_TRANSLATION(schedule_cmd, "enable schedule item", "Aktiviere Zeitplanelemente", "activeer tijdschema item", "aktivera schemalagt objekt", "aktywuj wybrany harmonogram", "aktiver planlagt element", "activer élément programmé", "program öğesini etkinleştir", "abilitare l'elemento programmato", "povoliť položku plánovania", "povolit položku plánování")
MAKE_WORD_TRANSLATION(command_cmd, "execute command", "Befehl ausführen", "opdracht uitvoeren", "kör kommando", "wykonaj polecenie", "kjør kommando", "exécuter commande", "komut çalıştır", "esegui comando", "vykonať príkaz", "provést příkaz")
MAKE_WORD_TRANSLATION(entity_cmd, "set custom value", "Sende eigene Entitäten", "verstuur custom waarde", "sätt ett eget värde", "wyślij własną wartość", "sett egendefinert verdi", "définir valeur personnalisée", "özel değer ayarla", "imposta valori personalizzati", "nastaviť vlastnú hodnotu", "nastavit vlastní hodnotu") MAKE_WORD_TRANSLATION(entity_cmd, "set custom value", "Sende eigene Entitäten", "verstuur custom waarde", "sätt ett eget värde", "wyślij własną wartość", "sett egendefinert verdi", "définir valeur personnalisée", "özel değer ayarla", "imposta valori personalizzati", "nastaviť vlastnú hodnotu", "nastavit vlastní hodnotu")
MAKE_WORD_TRANSLATION(commands_response, "get response", "Hole Antwort", "Verzoek om antwoord", "hämta svar", "uzyskaj odpowiedź", "få svar", "obtenir réponse", "yanıt al", "ottieni risposta", "získať odpoveď", "získat odpověď") MAKE_WORD_TRANSLATION(commands_response, "get response", "Hole Antwort", "Verzoek om antwoord", "hämta svar", "uzyskaj odpowiedź", "få svar", "obtenir réponse", "yanıt al", "ottieni risposta", "získať odpoveď", "získat odpověď")
MAKE_WORD_TRANSLATION(coldshot_cmd, "send a cold shot of water", "Zugabe einer Menge kalten Wassers", "stuur koud water", "sckicka en liten mängd kallvatten", "uruchom tryśnięcie zimnej wody", "send kaldtvannspuls", "envoyer de l'eau froide", "soğuk su gönder", "invia acqua fredda", "pošlite studenú dávku vody", "poslat studenou vodu") MAKE_WORD_TRANSLATION(coldshot_cmd, "send a cold shot of water", "Zugabe einer Menge kalten Wassers", "stuur koud water", "sckicka en liten mängd kallvatten", "uruchom tryśnięcie zimnej wody", "send kaldtvannspuls", "envoyer de l'eau froide", "soğuk su gönder", "invia acqua fredda", "pošlite studenú dávku vody", "poslat studenou vodu")

View File

@@ -510,6 +510,7 @@ void Mqtt::on_connect() {
// send initial MQTT messages for some of our services // send initial MQTT messages for some of our services
EMSESP::system_.send_heartbeat(); // send heartbeat EMSESP::system_.send_heartbeat(); // send heartbeat
EMSESP::webCustomEntityService.publish(true); EMSESP::webCustomEntityService.publish(true);
EMSESP::webCommandService.publish(true);
EMSESP::webSchedulerService.publish(true); EMSESP::webSchedulerService.publish(true);
EMSESP::analogsensor_.publish_values(true); EMSESP::analogsensor_.publish_values(true);
EMSESP::temperaturesensor_.publish_values(true); EMSESP::temperaturesensor_.publish_values(true);

View File

@@ -2610,6 +2610,12 @@ bool System::command_info(const char * value, const int8_t id, JsonObject output
obj["name"] = F_(scheduler); obj["name"] = F_(scheduler);
obj["entities"] = EMSESP::webSchedulerService.count_entities(); obj["entities"] = EMSESP::webSchedulerService.count_entities();
} }
if (EMSESP::webCommandService.count_entities()) {
JsonObject obj = devices.add<JsonObject>();
obj["type"] = F_(commands);
obj["name"] = F_(commands);
obj["entities"] = EMSESP::webCommandService.count_entities();
}
if (EMSESP::webCustomEntityService.count_entities()) { if (EMSESP::webCustomEntityService.count_entities()) {
JsonObject obj = devices.add<JsonObject>(); JsonObject obj = devices.add<JsonObject>();
obj["type"] = F_(custom); obj["type"] = F_(custom);

View File

@@ -0,0 +1,292 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2025 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "emsesp.h"
#include "WebCommandService.h"
#include "shuntingYard.h"
namespace emsesp {
WebCommandService::WebCommandService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager)
: _httpEndpoint(WebCommands::read, WebCommands::update, this, server, EMSESP_COMMAND_SERVICE_PATH, securityManager, AuthenticationPredicates::IS_AUTHENTICATED)
, _fsPersistence(WebCommands::read, WebCommands::update, this, fs, EMSESP_COMMAND_FILE) {
}
void WebCommandService::begin() {
_fsPersistence.readFromFS();
EMSESP::webCommandService.read([&](WebCommands & webCommands) { commandItems_ = &webCommands.commandItems; });
EMSESP::logger().info("Starting Commands service");
char topic[Mqtt::MQTT_TOPIC_MAX_SIZE];
snprintf(topic, sizeof(topic), "%s/#", F_(commands));
Mqtt::subscribe(EMSdevice::DeviceType::COMMAND, topic, nullptr);
}
void WebCommands::read(WebCommands & webCommands, JsonObject root) {
JsonArray items = root["commands"].to<JsonArray>();
uint8_t counter = 1;
for (const CommandItem & ci : webCommands.commandItems) {
JsonObject obj = items.add<JsonObject>();
obj["id"] = counter++;
obj["cmd"] = ci.cmd;
obj["value"] = ci.value;
obj["name"] = (const char *)ci.name;
}
}
StateUpdateResult WebCommands::update(JsonObject root, WebCommands & webCommands) {
Command::erase_device_commands(EMSdevice::DeviceType::COMMAND);
webCommands.commandItems.clear();
auto items = root["commands"].as<JsonArray>();
for (const JsonObject item : items) {
auto ci = CommandItem();
ci.cmd = item["cmd"].as<std::string>();
ci.value = item["value"].as<std::string>();
strlcpy(ci.name, item["name"].as<const char *>(), sizeof(ci.name));
webCommands.commandItems.push_back(ci);
if (webCommands.commandItems.back().name[0] != '\0') {
Command::add(
EMSdevice::DeviceType::COMMAND,
webCommands.commandItems.back().name,
[](const char * value, const int8_t id) {
return EMSESP::webCommandService.executeCommand(value);
},
FL_(command_cmd),
CommandFlag::ADMIN_ONLY);
}
}
return StateUpdateResult::CHANGED;
}
// find a command item by name (case-insensitive)
const CommandItem * WebCommandService::find(const char * name) {
if (name == nullptr || name[0] == '\0') {
return nullptr;
}
auto lower_name = Helpers::toLower(name);
for (const CommandItem & ci : *commandItems_) {
if (ci.name[0] != '\0' && Helpers::toLower(ci.name) == lower_name) {
return &ci;
}
}
return nullptr;
}
// execute a named command — looks up by name and runs it
// called from console 'call commands <name>', API/MQTT, web UI
bool WebCommandService::executeCommand(const char * name) {
const CommandItem * ci = find(name);
if (!ci) {
EMSESP::logger().warning("Command '%s' not found", name ? name : "");
return false;
}
return executeCommand(ci->name, ci->cmd, ci->value);
}
// execute a command with explicit cmd and value strings
// handles both HTTP URLs (JSON format) and internal API commands
bool WebCommandService::executeCommand(const char * name, const std::string & command, const std::string & data) {
std::string cmd = Helpers::toLower(command);
// handle HTTP commands (JSON with url/method/value)
JsonDocument doc;
if (deserializeJson(doc, cmd) == DeserializationError::Ok) {
std::string url = doc["url"] | "";
auto q = url.find_first_of('?');
if (q != std::string::npos) {
auto s = url.substr(q + 1);
auto l = s.length();
commands(s, false);
url.replace(q + 1, l, s);
}
std::string value = doc["value"] | data;
std::string method = doc["method"] | "GET";
commands(value, false);
auto lower_url = Helpers::toLower(url.c_str());
if (lower_url.starts_with("http://") || lower_url.starts_with("https://")) {
std::string result;
int httpResult = http_request(url, method, value, doc["header"].as<JsonObjectConst>(), result);
if (httpResult != 200) {
EMSESP::logger().warning("Command '%s': URL command failed with http code %d", name, httpResult);
return false;
}
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("Command '%s': URL '%s' successful with http code %d", name, url.c_str(), httpResult);
#endif
return true;
}
}
// handle internal API commands
doc.clear();
JsonObject input = doc.to<JsonObject>();
if (!data.empty()) {
input["data"] = data;
}
JsonDocument doc_output;
JsonObject output = doc_output.to<JsonObject>();
char command_str[COMMAND_MAX_LENGTH];
snprintf(command_str, sizeof(command_str), "/api/%s", cmd.c_str());
uint8_t return_code = Command::process(command_str, true, input, output);
if (return_code == CommandRet::OK) {
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("Command '%s' (%s with data '%s') was successful", name, cmd.c_str(), data.c_str());
#endif
if (data.empty() && output.size()) {
Mqtt::queue_publish("response", output);
}
return true;
}
char error[100];
if (output.size()) {
snprintf(error, sizeof(error), "Command '%s': %s", name ? name : "", (const char *)output["message"]);
} else {
snprintf(error, sizeof(error), "Command '%s': %s failed with error %s", name, cmd.c_str(), Command::return_code_string(return_code));
}
EMSESP::logger().warning(error);
return false;
}
bool WebCommandService::get_value_info(JsonObject output, const char * cmd) {
if (commandItems_->empty()) {
return true;
}
if (!strlen(cmd) || !strcmp(cmd, F_(values)) || !strcmp(cmd, F_(info))) {
for (const CommandItem & ci : *commandItems_) {
if (ci.name[0] != '\0') {
output[(const char *)ci.name] = ci.cmd;
}
}
return true;
}
if (!strcmp(cmd, F_(entities))) {
for (const CommandItem & ci : *commandItems_) {
if (ci.name[0] != '\0') {
get_value_json(output[ci.name].to<JsonObject>(), ci);
}
}
return true;
}
if (!strcmp(cmd, F_(metrics))) {
std::string metrics = get_metrics_prometheus();
if (!metrics.empty()) {
output["api_data"] = metrics;
return true;
}
return false;
}
// look up specific command by name
const char * attribute_s = Command::get_attribute(cmd);
for (const CommandItem & ci : *commandItems_) {
if (Helpers::toLower(ci.name) == cmd) {
get_value_json(output, ci);
return Command::get_attribute(output, cmd, attribute_s);
}
}
return false;
}
std::string WebCommandService::get_metrics_prometheus() {
std::string result;
result.reserve(commandItems_->size() * 100);
for (const CommandItem & ci : *commandItems_) {
if (ci.name[0] == '\0') {
continue;
}
result += (std::string) "# HELP emsesp_cmd_" + ci.name + " " + ci.name + "\n";
result += (std::string) "# TYPE emsesp_cmd_" + ci.name + " gauge\n";
result += (std::string) "emsesp_cmd_" + ci.name + " 1\n";
}
return result;
}
void WebCommandService::get_value_json(JsonObject output, const CommandItem & ci) {
output["name"] = (const char *)ci.name;
output["fullname"] = (const char *)ci.name;
output["type"] = "command";
output["command"] = ci.cmd;
output["cmd_data"] = ci.value;
bool hasName = ci.name[0] != '\0';
output["readable"] = hasName;
output["writeable"] = hasName;
output["visible"] = hasName;
}
void WebCommandService::publish(const bool force) {
if (!Mqtt::enabled() || commandItems_->empty()) {
return;
}
if (force && !EMSESP::mqtt_.get_publish_onchange(0)) {
return;
}
JsonDocument doc(PSRAM_DOC);
JsonObject output = doc.to<JsonObject>();
for (const CommandItem & ci : *commandItems_) {
if (ci.name[0] != '\0' && !output[ci.name].is<JsonVariantConst>()) {
output[(const char *)ci.name] = ci.cmd;
}
}
if (!doc.isNull()) {
char topic[Mqtt::MQTT_TOPIC_MAX_SIZE];
snprintf(topic, sizeof(topic), "%s_data", F_(commands));
Mqtt::queue_publish(topic, output);
}
}
uint8_t WebCommandService::count_entities() {
return static_cast<uint8_t>(commandItems_ ? commandItems_->size() : 0);
}
#if defined(EMSESP_TEST)
void WebCommandService::load_test_data() {
update([&](WebCommands & webCommands) {
webCommands.commandItems.clear();
auto ci = CommandItem();
ci.cmd = "system/fetch";
ci.value = "10";
strcpy(ci.name, "test_cmd1");
webCommands.commandItems.push_back(ci);
ci = CommandItem();
ci.cmd = "system/message";
ci.value = "hello";
strcpy(ci.name, "test_cmd2");
webCommands.commandItems.push_back(ci);
return StateUpdateResult::CHANGED;
});
}
#endif
} // namespace emsesp

View File

@@ -0,0 +1,75 @@
/*
* EMS-ESP - https://github.com/emsesp/EMS-ESP
* Copyright 2020-2025 emsesp.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <esp32-psram.h>
#ifndef WebCommandService_h
#define WebCommandService_h
#define EMSESP_COMMAND_FILE "/config/emsespCommands.json"
#define EMSESP_COMMAND_SERVICE_PATH "/rest/commands" // GET and POST
namespace emsesp {
class CommandItem {
public:
stringPSRAM cmd;
stringPSRAM value;
char name[20];
};
class WebCommands {
public:
std::list<CommandItem, AllocatorPSRAM<CommandItem>> commandItems;
static void read(WebCommands & webCommands, JsonObject root);
static StateUpdateResult update(JsonObject root, WebCommands & webCommands);
};
class WebCommandService : public StatefulService<WebCommands> {
public:
WebCommandService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager);
void begin();
void publish(const bool force = false);
bool get_value_info(JsonObject output, const char * cmd);
void get_value_json(JsonObject output, const CommandItem & commandItem);
bool executeCommand(const char * name);
bool executeCommand(const char * name, const std::string & cmd, const std::string & value);
const CommandItem * find(const char * name);
uint8_t count_entities();
std::string get_metrics_prometheus();
#if defined(EMSESP_TEST)
void load_test_data();
#endif
private:
HttpEndpoint<WebCommands> _httpEndpoint;
FSPersistence<WebCommands> _fsPersistence;
std::list<CommandItem, AllocatorPSRAM<CommandItem>> * commandItems_;
};
} // namespace emsesp
#endif

View File

@@ -250,6 +250,9 @@ void WebDataService::write_device_value(AsyncWebServerRequest * request, JsonVar
case EMSdevice::DeviceTypeUniqueID::SCHEDULER_UID: case EMSdevice::DeviceTypeUniqueID::SCHEDULER_UID:
device_type = EMSdevice::DeviceType::SCHEDULER; device_type = EMSdevice::DeviceType::SCHEDULER;
break; break;
case EMSdevice::DeviceTypeUniqueID::COMMAND_UID:
device_type = EMSdevice::DeviceType::COMMAND;
break;
case EMSdevice::DeviceTypeUniqueID::TEMPERATURESENSOR_UID: case EMSdevice::DeviceTypeUniqueID::TEMPERATURESENSOR_UID:
device_type = EMSdevice::DeviceType::TEMPERATURESENSOR; device_type = EMSdevice::DeviceType::TEMPERATURESENSOR;
break; break;
@@ -478,11 +481,11 @@ void WebDataService::dashboard_data(AsyncWebServerRequest * request) {
} }
} }
// show scheduler items // show scheduler items (active state toggles)
if (EMSESP::webSchedulerService.count_entities()) { if (EMSESP::webSchedulerService.count_entities()) {
JsonObject obj = nodes.add<JsonObject>(); JsonObject obj = nodes.add<JsonObject>();
obj["id"] = EMSdevice::DeviceTypeUniqueID::SCHEDULER_UID; // it's unique id obj["id"] = EMSdevice::DeviceTypeUniqueID::SCHEDULER_UID;
obj["t"] = EMSdevice::DeviceType::SCHEDULER; // device type number obj["t"] = EMSdevice::DeviceType::SCHEDULER;
JsonArray nodes = obj["nodes"].to<JsonArray>(); JsonArray nodes = obj["nodes"].to<JsonArray>();
uint8_t count = 0; uint8_t count = 0;
@@ -495,14 +498,31 @@ void WebDataService::dashboard_data(AsyncWebServerRequest * request) {
dv["id"] = std::string("00") + scheduleItem.name; dv["id"] = std::string("00") + scheduleItem.name;
dv["c"] = scheduleItem.name; dv["c"] = scheduleItem.name;
// for immediate schedules, we don't show the active/inactive state or on/off options char s[12];
if (scheduleItem.flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE) { dv["v"] = Helpers::render_boolean(s, scheduleItem.active, true);
char s[12]; JsonArray l = dv["l"].to<JsonArray>();
dv["v"] = Helpers::render_boolean(s, scheduleItem.active, true); l.add(Helpers::render_boolean(s, false, true));
JsonArray l = dv["l"].to<JsonArray>(); l.add(Helpers::render_boolean(s, true, true));
l.add(Helpers::render_boolean(s, false, true)); // False option }
l.add(Helpers::render_boolean(s, true, true)); // True option });
} }
// show command items (executable from dashboard)
if (EMSESP::webCommandService.count_entities()) {
JsonObject obj = nodes.add<JsonObject>();
obj["id"] = EMSdevice::DeviceTypeUniqueID::COMMAND_UID;
obj["t"] = EMSdevice::DeviceType::COMMAND;
JsonArray nodes = obj["nodes"].to<JsonArray>();
uint8_t count = 0;
EMSESP::webCommandService.read([&](const WebCommands & webCommands) {
for (const CommandItem & ci : webCommands.commandItems) {
JsonObject node = nodes.add<JsonObject>();
node["id"] = (EMSdevice::DeviceTypeUniqueID::COMMAND_UID * 100) + count++;
JsonObject dv = node["dv"].to<JsonObject>();
dv["id"] = std::string("00") + ci.name;
dv["c"] = ci.name;
} }
}); });
} }

View File

@@ -57,21 +57,18 @@ void WebScheduler::read(WebScheduler & webScheduler, JsonObject root) {
JsonArray schedule = root["schedule"].to<JsonArray>(); JsonArray schedule = root["schedule"].to<JsonArray>();
uint8_t counter = 1; uint8_t counter = 1;
for (const ScheduleItem & scheduleItem : webScheduler.scheduleItems) { for (const ScheduleItem & scheduleItem : webScheduler.scheduleItems) {
JsonObject si = schedule.add<JsonObject>(); JsonObject si = schedule.add<JsonObject>();
si["id"] = counter++; // id is only used to render the table and must be unique. 0 is for Dashboard si["id"] = counter++;
si["flags"] = scheduleItem.flags; si["flags"] = scheduleItem.flags;
si["active"] = scheduleItem.flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE ? scheduleItem.active : false; si["active"] = scheduleItem.active;
si["time"] = scheduleItem.flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE ? scheduleItem.time : ""; si["time"] = scheduleItem.time;
si["cmd"] = scheduleItem.cmd; si["cmd_name"] = scheduleItem.cmd_name;
si["value"] = scheduleItem.value; si["name"] = (const char *)scheduleItem.name;
si["name"] = (const char *)scheduleItem.name;
} }
} }
// call on initialization and also when the Schedule web page is saved // call on initialization and also when the Schedule web page is saved
// this loads the data into the internal class
StateUpdateResult WebScheduler::update(JsonObject root, WebScheduler & webScheduler) { StateUpdateResult WebScheduler::update(JsonObject root, WebScheduler & webScheduler) {
// reset the list
Command::erase_device_commands(EMSdevice::DeviceType::SCHEDULER); Command::erase_device_commands(EMSdevice::DeviceType::SCHEDULER);
for (ScheduleItem & scheduleItem : webScheduler.scheduleItems) { for (ScheduleItem & scheduleItem : webScheduler.scheduleItems) {
char key[sizeof(scheduleItem.name) + 2]; char key[sizeof(scheduleItem.name) + 2];
@@ -83,28 +80,23 @@ StateUpdateResult WebScheduler::update(JsonObject root, WebScheduler & webSchedu
webScheduler.scheduleItems.clear(); webScheduler.scheduleItems.clear();
EMSESP::webSchedulerService.ha_reset(); EMSESP::webSchedulerService.ha_reset();
// build up the list of schedule items
auto scheduleItems = root["schedule"].as<JsonArray>(); auto scheduleItems = root["schedule"].as<JsonArray>();
for (const JsonObject schedule : scheduleItems) { for (const JsonObject schedule : scheduleItems) {
// create each schedule item, overwriting any previous settings auto si = ScheduleItem();
// ignore the id (as this is only used in the web for table rendering) si.active = schedule["active"];
auto si = ScheduleItem(); si.flags = schedule["flags"];
si.active = schedule["active"]; si.time = schedule["time"].as<std::string>();
si.flags = schedule["flags"]; si.cmd_name = schedule["cmd_name"].as<std::string>();
si.time = si.flags == SCHEDULEFLAG_SCHEDULE_IMMEDIATE ? "" : schedule["time"].as<std::string>();
si.cmd = schedule["cmd"].as<std::string>();
si.value = schedule["value"].as<std::string>();
strlcpy(si.name, schedule["name"].as<const char *>(), sizeof(si.name)); strlcpy(si.name, schedule["name"].as<const char *>(), sizeof(si.name));
// calculated elapsed minutes
si.elapsed_min = Helpers::string2minutes(si.time.c_str()); si.elapsed_min = Helpers::string2minutes(si.time.c_str());
si.retry_cnt = 0xFF; // no startup retries si.retry_cnt = 0xFF;
webScheduler.scheduleItems.push_back(si); // add to list webScheduler.scheduleItems.push_back(si);
if (webScheduler.scheduleItems.back().name[0] != '\0') { if (webScheduler.scheduleItems.back().name[0] != '\0') {
char key[sizeof(webScheduler.scheduleItems.back().name) + 2]; char key[sizeof(webScheduler.scheduleItems.back().name) + 2];
snprintf(key, sizeof(key), "s:%s", webScheduler.scheduleItems.back().name); snprintf(key, sizeof(key), "s:%s", webScheduler.scheduleItems.back().name);
if (EMSESP::nvs_.isKey(key) && webScheduler.scheduleItems.back().flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE) { if (EMSESP::nvs_.isKey(key)) {
webScheduler.scheduleItems.back().active = EMSESP::nvs_.getBool(key); webScheduler.scheduleItems.back().active = EMSESP::nvs_.getBool(key);
} }
Command::add( Command::add(
@@ -140,12 +132,9 @@ bool WebSchedulerService::command_setvalue(const char * value, const int8_t id,
publish(); publish();
} }
// save new state to nvs #2946 char key[sizeof(scheduleItem.name) + 2];
if (scheduleItem.flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE) { snprintf(key, sizeof(key), "s:%s", scheduleItem.name);
char key[sizeof(scheduleItem.name) + 2]; EMSESP::nvs_.putBool(key, scheduleItem.active);
snprintf(key, sizeof(key), "s:%s", scheduleItem.name);
EMSESP::nvs_.putBool(key, scheduleItem.active);
}
return true; return true;
} }
} }
@@ -225,11 +214,10 @@ void WebSchedulerService::get_value_json(JsonObject output, const ScheduleItem &
output["onchange"] = scheduleItem.time; output["onchange"] = scheduleItem.time;
} else if (scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER) { } else if (scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER) {
output["timer"] = scheduleItem.time; output["timer"] = scheduleItem.time;
} else if (scheduleItem.flags != SCHEDULEFLAG_SCHEDULE_IMMEDIATE) { } else {
output["time"] = scheduleItem.time; output["time"] = scheduleItem.time;
} }
output["command"] = scheduleItem.cmd; output["cmd_name"] = scheduleItem.cmd_name;
output["cmd_data"] = scheduleItem.value;
bool hasName = scheduleItem.name[0] != '\0'; bool hasName = scheduleItem.name[0] != '\0';
output["readable"] = hasName; output["readable"] = hasName;
output["writeable"] = hasName; output["writeable"] = hasName;
@@ -339,80 +327,14 @@ uint8_t WebSchedulerService::count_entities() {
return static_cast<uint8_t>(scheduleItems_ ? scheduleItems_->size() : 0); return static_cast<uint8_t>(scheduleItems_ ? scheduleItems_->size() : 0);
} }
// execute scheduled command // execute the command associated with a schedule item
// return true if successful, false if not // looks up the named command in WebCommandService and runs it
bool WebSchedulerService::command(const char * name, const std::string & command, const std::string & data) { bool WebSchedulerService::runScheduleCommand(const ScheduleItem & si) {
std::string cmd = Helpers::toLower(command); if (si.cmd_name.empty()) {
EMSESP::logger().warning("Schedule '%s': no command assigned", si.name);
// check http commands. e.g. return false;
// tasmota(get): http://<tasmotaIP>/cm?cmnd=power%20ON
// shelly(get): http://<shellyIP>/relais/0?turn=on
// parse json
JsonDocument doc;
if (deserializeJson(doc, cmd) == DeserializationError::Ok) {
std::string url = doc["url"] | "";
// for a GET with parameters replace commands with values
// don't search the complete url, it may contain a devicename in path
auto q = url.find_first_of('?');
if (q != std::string::npos) {
auto s = url.substr(q + 1); // copy only parameters
auto l = s.length();
commands(s, false);
url.replace(q + 1, l, s);
}
std::string value = doc["value"] | data; // extract value if its in the command, or take the data
std::string method = doc["method"] | "GET"; // default GET
commands(value, false);
auto lower_url = Helpers::toLower(url.c_str());
if (lower_url.starts_with("http://") || lower_url.starts_with("https://")) {
std::string result;
int httpResult = http_request(url, method, value, doc["header"].as<JsonObjectConst>(), result);
if (httpResult != 200) {
EMSESP::logger().warning("Schedule '%s': URL command failed with http code %d", name, httpResult);
return false;
}
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("Schedule %s: URL '%s' command successful with http code %d", name, url.c_str(), httpResult);
#endif
return true;
}
// we can add other json tests here
} }
return EMSESP::webCommandService.executeCommand(si.cmd_name.c_str());
doc.clear();
JsonObject input = doc.to<JsonObject>();
if (!data.empty()) { // empty data queries a value
input["data"] = data;
}
JsonDocument doc_output; // only for commands without output
JsonObject output = doc_output.to<JsonObject>();
// prefix "api/" to command string
char command_str[COMMAND_MAX_LENGTH];
snprintf(command_str, sizeof(command_str), "/api/%s", cmd.c_str());
uint8_t return_code = Command::process(command_str, true, input, output); // admin set
if (return_code == CommandRet::OK) {
#if defined(EMSESP_DEBUG)
EMSESP::logger().debug("Schedule command '%s' with data '%s' was successful", cmd.c_str(), data.c_str());
#endif
if (data.empty() && output.size()) {
Mqtt::queue_publish("response", output);
}
return true;
}
char error[100];
if (output.size()) {
// check for empty name
snprintf(error, sizeof(error), "Schedule %s: %s", name ? name : "", (const char *)output["message"]); // use error message if we have it
} else {
snprintf(error, sizeof(error), "Schedule %s: command %s failed with error %s", name, cmd.c_str(), Command::return_code_string(return_code));
}
EMSESP::logger().warning(error);
return false;
} }
// called from emsesp.cpp on every entity-change // called from emsesp.cpp on every entity-change
@@ -427,31 +349,16 @@ bool WebSchedulerService::onChange(const char * cmd) {
return false; return false;
} }
// system/message evaluates its own argument later (deferred via raw_value, computed in loop()),
// so pre-computing it here would make any {url} or expression inside it run twice. Pass
// system/message its value raw; compute() everything else as before.
// templated because ScheduleItem's strings use a PSRAM allocator, not std::string.
template <typename C, typename V>
static std::string compute_cmd_value(const C & cmd, const V & value) {
if (Helpers::toLower(cmd.c_str()) == "system/message") {
return std::string(value.c_str());
}
return compute(value.c_str());
}
// handle condition schedules, parse string stored in schedule.time field // handle condition schedules, parse string stored in schedule.time field
void WebSchedulerService::condition() { void WebSchedulerService::condition() {
for (ScheduleItem & scheduleItem : *scheduleItems_) { for (ScheduleItem & scheduleItem : *scheduleItems_) {
if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_CONDITION) { if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_CONDITION) {
auto match = compute(scheduleItem.time.c_str()); auto match = compute(scheduleItem.time.c_str());
#ifdef EMESESP_DEBUG
// EMSESP::logger().debug("condition match: %s", match.c_str());
#endif
if (match.length() == 1 && match[0] == '1' && scheduleItem.retry_cnt == 0xFF) { if (match.length() == 1 && match[0] == '1' && scheduleItem.retry_cnt == 0xFF) {
scheduleItem.retry_cnt = command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value)) ? 1 : 0xFF; scheduleItem.retry_cnt = runScheduleCommand(scheduleItem) ? 1 : 0xFF;
} else if (match.length() == 1 && match[0] == '0' && scheduleItem.retry_cnt == 1) { } else if (match.length() == 1 && match[0] == '0' && scheduleItem.retry_cnt == 1) {
scheduleItem.retry_cnt = 0xFF; scheduleItem.retry_cnt = 0xFF;
} else if (match.length() != 1) { // the match is not boolean } else if (match.length() != 1) {
#if defined(EMSESP_DEBUG) #if defined(EMSESP_DEBUG)
EMSESP::logger().debug("condition result: %s", match.c_str()); EMSESP::logger().debug("condition result: %s", match.c_str());
#endif #endif
@@ -462,17 +369,15 @@ void WebSchedulerService::condition() {
// process any scheduled jobs // process any scheduled jobs
void WebSchedulerService::loop() { void WebSchedulerService::loop() {
// initialize static value on startup
static int8_t last_tm_min = -2; // invalid value also used for startup commands static int8_t last_tm_min = -2; // invalid value also used for startup commands
static uint32_t last_uptime_min = 0; static uint32_t last_uptime_min = 0;
static uint32_t last_uptime_sec = 0; static uint32_t last_uptime_sec = 0;
if (!raw_value.empty()) { // process a value from system/message command if (!raw_value.empty()) {
computed_value = compute(raw_value); computed_value = compute(raw_value);
raw_value.clear(); raw_value.clear();
} }
// get list of scheduler events and exit if it's empty
if (scheduleItems_->empty()) { if (scheduleItems_->empty()) {
return; return;
} }
@@ -480,21 +385,10 @@ void WebSchedulerService::loop() {
// check if we have onChange events // check if we have onChange events
while (!cmd_changed_.empty()) { while (!cmd_changed_.empty()) {
ScheduleItem si = *cmd_changed_.front(); ScheduleItem si = *cmd_changed_.front();
command(si.name, si.cmd.c_str(), compute_cmd_value(si.cmd, si.value)); runScheduleCommand(si);
cmd_changed_.pop_front(); cmd_changed_.pop_front();
} }
for (ScheduleItem & scheduleItem : *scheduleItems_) {
if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_IMMEDIATE) {
command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value));
scheduleItem.active = false;
publish_single(scheduleItem.name, false);
if (EMSESP::mqtt_.get_publish_onchange(0)) {
publish();
}
}
}
// check conditions every 10 seconds, start after one minute // check conditions every 10 seconds, start after one minute
uint32_t uptime_sec = uuid::get_uptime_sec() / 10; uint32_t uptime_sec = uuid::get_uptime_sec() / 10;
if (last_uptime_sec != uptime_sec && uptime_sec > 5) { if (last_uptime_sec != uptime_sec && uptime_sec > 5) {
@@ -506,64 +400,49 @@ void WebSchedulerService::loop() {
if (last_tm_min == -2) { if (last_tm_min == -2) {
for (ScheduleItem & scheduleItem : *scheduleItems_) { for (ScheduleItem & scheduleItem : *scheduleItems_) {
if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER && scheduleItem.elapsed_min == 0) { if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER && scheduleItem.elapsed_min == 0) {
scheduleItem.retry_cnt = command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value)) ? 0xFF : 0; scheduleItem.retry_cnt = runScheduleCommand(scheduleItem) ? 0xFF : 0;
} }
} }
last_tm_min = -1; // startup done, now use for RTC last_tm_min = -1;
} }
// check timer every minute, sync to EMS-ESP clock // check timer every minute, sync to EMS-ESP clock
uint32_t uptime_min = uuid::get_uptime_sec() / 60; uint32_t uptime_min = uuid::get_uptime_sec() / 60;
if (last_uptime_min != uptime_min) { if (last_uptime_min != uptime_min) {
for (ScheduleItem & scheduleItem : *scheduleItems_) { for (ScheduleItem & scheduleItem : *scheduleItems_) {
// retry startup commands not yet executed
if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER && scheduleItem.elapsed_min == 0 if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER && scheduleItem.elapsed_min == 0
&& scheduleItem.retry_cnt < MAX_STARTUP_RETRIES) { && scheduleItem.retry_cnt < MAX_STARTUP_RETRIES) {
scheduleItem.retry_cnt = command(scheduleItem.name, scheduleItem.cmd.c_str(), scheduleItem.value.c_str()) ? 0xFF : scheduleItem.retry_cnt + 1; scheduleItem.retry_cnt = runScheduleCommand(scheduleItem) ? 0xFF : scheduleItem.retry_cnt + 1;
} }
// scheduled timer commands
if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER && scheduleItem.elapsed_min > 0 if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER && scheduleItem.elapsed_min > 0
&& (uptime_min % scheduleItem.elapsed_min == 0)) { && (uptime_min % scheduleItem.elapsed_min == 0)) {
command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value)); runScheduleCommand(scheduleItem);
} }
} }
last_uptime_min = uptime_min; last_uptime_min = uptime_min;
} }
// check calender, sync to RTC, only execute if year is valid // check calendar, sync to RTC, only execute if year is valid
time_t now = time(nullptr); time_t now = time(nullptr);
tm * tm = localtime(&now); tm * tm = localtime(&now);
if (tm->tm_min != last_tm_min && tm->tm_year > 120) { if (tm->tm_min != last_tm_min && tm->tm_year > 120) {
// find the real dow and minute from RTC uint8_t real_dow = 1 << tm->tm_wday;
uint8_t real_dow = 1 << tm->tm_wday; // 1 is Sunday
uint16_t real_min = tm->tm_hour * 60 + tm->tm_min; uint16_t real_min = tm->tm_hour * 60 + tm->tm_min;
for (const ScheduleItem & scheduleItem : *scheduleItems_) { for (const ScheduleItem & scheduleItem : *scheduleItems_) {
uint8_t dow = scheduleItem.flags & SCHEDULEFLAG_SCHEDULE_TIMER ? 0 : scheduleItem.flags; uint8_t dow = scheduleItem.flags & SCHEDULEFLAG_SCHEDULE_TIMER ? 0 : scheduleItem.flags;
if (scheduleItem.active && (real_dow & dow) && real_min == scheduleItem.elapsed_min) { if (scheduleItem.active && (real_dow & dow) && real_min == scheduleItem.elapsed_min) {
command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value)); runScheduleCommand(scheduleItem);
} }
} }
last_tm_min = tm->tm_min; last_tm_min = tm->tm_min;
} }
} }
// execute a schedule item immediately
bool WebSchedulerService::executeSchedule(const char * name) {
for (ScheduleItem & scheduleItem : *scheduleItems_) {
if (scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_IMMEDIATE && strcmp(scheduleItem.name, name) == 0) {
EMSESP::logger().info("Executing schedule '%s'", name);
return command(scheduleItem.name, scheduleItem.cmd.c_str(), compute_cmd_value(scheduleItem.cmd, scheduleItem.value));
}
}
EMSESP::logger().warning("Schedule '%s' not found", name);
return false; // not found
}
// process schedules async // process schedules async
void WebSchedulerService::scheduler_task(void * pvParameters) { void WebSchedulerService::scheduler_task(void * pvParameters) {
while (1) { while (1) {
delay(10); // no need to hurry delay(10);
if (EMSESP::system_.systemStatus() == SYSTEM_STATUS::SYSTEM_STATUS_NORMAL) { if (EMSESP::system_.systemStatus() == SYSTEM_STATUS::SYSTEM_STATUS_NORMAL) {
EMSESP::webSchedulerService.loop(); EMSESP::webSchedulerService.loop();
} }
@@ -573,39 +452,34 @@ void WebSchedulerService::scheduler_task(void * pvParameters) {
#endif #endif
} }
// hard coded tests
#if defined(EMSESP_TEST) #if defined(EMSESP_TEST)
void WebSchedulerService::load_test_data() { void WebSchedulerService::load_test_data() {
update([&](WebScheduler & webScheduler) { update([&](WebScheduler & webScheduler) {
webScheduler.scheduleItems.clear(); // delete all existing schedules webScheduler.scheduleItems.clear();
// test 1 auto si = ScheduleItem();
auto si = ScheduleItem(); si.active = true;
si.active = true; si.flags = 1; // day schedule
si.flags = 1; // day schedule si.time = "12:00";
si.time = "12:00"; si.cmd_name = "test_cmd1";
si.cmd = "system/fetch";
si.value = "10";
strcpy(si.name, "test_scheduler1"); strcpy(si.name, "test_scheduler1");
si.elapsed_min = 0; si.elapsed_min = 0;
si.retry_cnt = 0xFF; // no startup retries si.retry_cnt = 0xFF;
webScheduler.scheduleItems.push_back(si); webScheduler.scheduleItems.push_back(si);
// test 2 si = ScheduleItem();
si = ScheduleItem(); si.active = true;
si.active = false; si.flags = SCHEDULEFLAG_SCHEDULE_TIMER;
si.flags = SCHEDULEFLAG_SCHEDULE_IMMEDIATE; // immediate si.time = "01:00";
si.time = "13:00"; si.cmd_name = "test_cmd2";
si.cmd = "system/message"; strcpy(si.name, "test_scheduler2");
si.value = "20"; si.elapsed_min = 60;
strcpy(si.name, "test_scheduler2"); // to make sure its excluded from Dashboard si.retry_cnt = 0xFF;
si.elapsed_min = 0;
si.retry_cnt = 0xFF; // no startup retries
webScheduler.scheduleItems.push_back(si); webScheduler.scheduleItems.push_back(si);
return StateUpdateResult::CHANGED; // persist the changes return StateUpdateResult::CHANGED;
}); });
} }
#endif #endif

View File

@@ -41,11 +41,9 @@
// 128 (0x80) is timer // 128 (0x80) is timer
// 129 (0x81) is on change // 129 (0x81) is on change
// 130 (0x82) is on condition // 130 (0x82) is on condition
// 132 (0x84) is immediate
#define SCHEDULEFLAG_SCHEDULE_TIMER 0x80 // 7th bit for Timer #define SCHEDULEFLAG_SCHEDULE_TIMER 0x80 // 7th bit for Timer
#define SCHEDULEFLAG_SCHEDULE_ONCHANGE 0x81 // 7th+1st bit for OnChange #define SCHEDULEFLAG_SCHEDULE_ONCHANGE 0x81 // 7th+1st bit for OnChange
#define SCHEDULEFLAG_SCHEDULE_CONDITION 0x82 // 7th+2nd bit for Condition #define SCHEDULEFLAG_SCHEDULE_CONDITION 0x82 // 7th+2nd bit for Condition
#define SCHEDULEFLAG_SCHEDULE_IMMEDIATE 0x84 // 7th+3rd bit for Immediate
#define MAX_STARTUP_RETRIES 3 // retry the start-up commands x times #define MAX_STARTUP_RETRIES 3 // retry the start-up commands x times
@@ -57,8 +55,7 @@ class ScheduleItem {
uint8_t flags; // bit flags, see SCHEDULEFLAG_* defines uint8_t flags; // bit flags, see SCHEDULEFLAG_* defines
uint16_t elapsed_min; // total mins from 00:00 uint16_t elapsed_min; // total mins from 00:00
stringPSRAM time; // HH:MM stringPSRAM time; // HH:MM
stringPSRAM cmd; stringPSRAM cmd_name; // references a named command from WebCommandService
stringPSRAM value;
char name[20]; char name[20];
uint8_t retry_cnt; uint8_t retry_cnt;
}; };
@@ -88,8 +85,6 @@ class WebSchedulerService : public StatefulService<WebScheduler> {
uint8_t count_entities(); uint8_t count_entities();
bool onChange(const char * cmd); bool onChange(const char * cmd);
bool executeSchedule(const char * name);
std::string get_metrics_prometheus(); std::string get_metrics_prometheus();
std::string raw_value; std::string raw_value;
@@ -105,7 +100,7 @@ class WebSchedulerService : public StatefulService<WebScheduler> {
#endif #endif
static void scheduler_task(void * pvParameters); static void scheduler_task(void * pvParameters);
bool command(const char * name, const std::string & cmd, const std::string & data); bool runScheduleCommand(const ScheduleItem & si);
void condition(); void condition();
HttpEndpoint<WebScheduler> _httpEndpoint; HttpEndpoint<WebScheduler> _httpEndpoint;

View File

@@ -232,8 +232,8 @@ void WebStatusService::action(AsyncWebServerRequest * request, JsonVariant json)
EMSESP::mqtt_.reset_mqtt(); EMSESP::mqtt_.reset_mqtt();
} else if (action == "upgradeImportantMessages") { } else if (action == "upgradeImportantMessages") {
root["upgradeImportantMessageType"] = upgradeImportantMessages(param); root["upgradeImportantMessageType"] = upgradeImportantMessages(param);
} else if (action == "executeSchedule") { } else if (action == "executeCommand") {
ok = EMSESP::webSchedulerService.executeSchedule(param.c_str()); ok = EMSESP::webCommandService.executeCommand(param.c_str());
} }
#if defined(EMSESP_STANDALONE) && !defined(EMSESP_UNITY) #if defined(EMSESP_STANDALONE) && !defined(EMSESP_UNITY)