diff --git a/interface/src/framework/system/GeneralFileUpload.tsx b/interface/src/framework/system/GeneralFileUpload.tsx index 8edc79442..79cce8622 100644 --- a/interface/src/framework/system/GeneralFileUpload.tsx +++ b/interface/src/framework/system/GeneralFileUpload.tsx @@ -67,6 +67,19 @@ const GeneralFileUpload: FC = ({ uploadGeneralFile }) => { } }; + const downloadEntities = async () => { + try { + const response = await EMSESP.getEntities(); + if (response.status !== 200) { + toast.error(LL.PROBLEM_LOADING()); + } else { + saveFile(response.data, 'entities'); + } + } catch (error) { + toast.error(extractErrorMessage(error, LL.PROBLEM_LOADING())); + } + }; + const downloadSchedule = async () => { try { const response = await EMSESP.getSchedule(); @@ -125,6 +138,14 @@ const GeneralFileUpload: FC = ({ uploadGeneralFile }) => { > {LL.CUSTOMIZATIONS()} + {LL.DOWNLOAD_SCHEDULE_TEXT()}{' '} diff --git a/interface/src/i18n/de/index.ts b/interface/src/i18n/de/index.ts index c3140937b..db5fbcbc4 100644 --- a/interface/src/i18n/de/index.ts +++ b/interface/src/i18n/de/index.ts @@ -318,7 +318,9 @@ const de: Translation = { SCHEDULE_SAVED: 'Plan gespeichert', SCHEDULE_TIMER_1: 'beim Start', SCHEDULE_TIMER_2: 'jede Minute', - SCHEDULE_TIMER_3: 'jede Stunde' + SCHEDULE_TIMER_3: 'jede Stunde', + CUSTOM_ENTITIES: 'Individuelle Entitäten', + ENTITIES_HELP_1: 'Abfrage von Werten auf dem EMS-Bus' }; export default de; diff --git a/interface/src/i18n/en/index.ts b/interface/src/i18n/en/index.ts index 0cd8899b9..3f0b829f9 100644 --- a/interface/src/i18n/en/index.ts +++ b/interface/src/i18n/en/index.ts @@ -318,7 +318,9 @@ const en: Translation = { SCHEDULE_SAVED: 'Schedule updated', SCHEDULE_TIMER_1: 'on startup', SCHEDULE_TIMER_2: 'every minute', - SCHEDULE_TIMER_3: 'every hour' + SCHEDULE_TIMER_3: 'every hour', + CUSTOM_ENTITIES: 'Custom entities', + ENTITIES_HELP_1: 'Fetch custom entities from the EMS-bus' }; export default en; diff --git a/interface/src/i18n/fr/index.ts b/interface/src/i18n/fr/index.ts index 8a4b9a8a6..aa91b2ca3 100644 --- a/interface/src/i18n/fr/index.ts +++ b/interface/src/i18n/fr/index.ts @@ -318,7 +318,9 @@ const fr: Translation = { SCHEDULE_SAVED: 'Schedule updated', // TODO translate SCHEDULE_TIMER_1: 'on startup', // TODO translate SCHEDULE_TIMER_2: 'every minute', // TODO translate - SCHEDULE_TIMER_3: 'every hour' // TODO translate + SCHEDULE_TIMER_3: 'every hour', // TODO translate + CUSTOM_ENTITIES: 'Custom entities', + ENTITIES_HELP_1: 'Fetch custom entities from the EMS-bus' }; export default fr; diff --git a/interface/src/i18n/nl/index.ts b/interface/src/i18n/nl/index.ts index e3c46292f..ff0257861 100644 --- a/interface/src/i18n/nl/index.ts +++ b/interface/src/i18n/nl/index.ts @@ -318,7 +318,9 @@ const nl: Translation = { SCHEDULE_SAVED: 'Schedule updated', // TODO translate SCHEDULE_TIMER_1: 'on startup', // TODO translate SCHEDULE_TIMER_2: 'every minute', // TODO translate - SCHEDULE_TIMER_3: 'every hour' // TODO translate + SCHEDULE_TIMER_3: 'every hour', // TODO translate + CUSTOM_ENTITIES: 'Custom entities', + ENTITIES_HELP_1: 'Fetch custom entities from the EMS-bus' }; export default nl; diff --git a/interface/src/i18n/no/index.ts b/interface/src/i18n/no/index.ts index bab6505f8..ac7dfe79b 100644 --- a/interface/src/i18n/no/index.ts +++ b/interface/src/i18n/no/index.ts @@ -318,7 +318,9 @@ const no: Translation = { SCHEDULE_SAVED: 'Planlegger er oppdatert', SCHEDULE_TIMER_1: 'ved oppstart', SCHEDULE_TIMER_2: 'hvert minutt', - SCHEDULE_TIMER_3: 'hver time' + SCHEDULE_TIMER_3: 'hver time', + CUSTOM_ENTITIES: 'Custom entities', + ENTITIES_HELP_1: 'Fetch custom entities from the EMS-bus' }; export default no; diff --git a/interface/src/i18n/pl/index.ts b/interface/src/i18n/pl/index.ts index 856d2b31d..5f1fff143 100644 --- a/interface/src/i18n/pl/index.ts +++ b/interface/src/i18n/pl/index.ts @@ -318,7 +318,9 @@ const pl: BaseTranslation = { SCHEDULE_SAVED: 'Harmonogram został uaktualniony.', SCHEDULE_TIMER_1: 'przy starcie', SCHEDULE_TIMER_2: 'co minutę', - SCHEDULE_TIMER_3: 'co godzinę' + SCHEDULE_TIMER_3: 'co godzinę', + CUSTOM_ENTITIES: 'Custom entities', + ENTITIES_HELP_1: 'Fetch custom entities from the EMS-bus' }; export default pl; diff --git a/interface/src/i18n/sv/index.ts b/interface/src/i18n/sv/index.ts index 5fa2ee34e..e2f21039b 100644 --- a/interface/src/i18n/sv/index.ts +++ b/interface/src/i18n/sv/index.ts @@ -318,7 +318,9 @@ const sv: Translation = { SCHEDULE_SAVED: 'Schedule updated', // TODO translate SCHEDULE_TIMER_1: 'on startup', // TODO translate SCHEDULE_TIMER_2: 'every minute', // TODO translate - SCHEDULE_TIMER_3: 'every hour' // TODO translate + SCHEDULE_TIMER_3: 'every hour', // TODO translate + CUSTOM_ENTITIES: 'Custom entities', + ENTITIES_HELP_1: 'Fetch custom entities from the EMS-bus' }; export default sv; diff --git a/interface/src/i18n/tr/index.ts b/interface/src/i18n/tr/index.ts index 6947c2983..ddbb41cb6 100644 --- a/interface/src/i18n/tr/index.ts +++ b/interface/src/i18n/tr/index.ts @@ -318,7 +318,9 @@ const tr: Translation = { SCHEDULE_SAVED: 'Schedule updated', // TODO translate SCHEDULE_TIMER_1: 'on startup', // TODO translate SCHEDULE_TIMER_2: 'every minute', // TODO translate - SCHEDULE_TIMER_3: 'every hour' // TODO translate + SCHEDULE_TIMER_3: 'every hour', // TODO translate + CUSTOM_ENTITIES: 'Custom entities', + ENTITIES_HELP_1: 'Fetch custom entities from the EMS-bus' }; export default tr; diff --git a/interface/src/project/DeviceIcon.tsx b/interface/src/project/DeviceIcon.tsx index baf785a42..d199db075 100644 --- a/interface/src/project/DeviceIcon.tsx +++ b/interface/src/project/DeviceIcon.tsx @@ -2,7 +2,7 @@ import { FC } from 'react'; import { CgSmartHomeBoiler } from 'react-icons/cg'; import { FaSolarPanel } from 'react-icons/fa'; -import { MdThermostatAuto, MdOutlineSensors } from 'react-icons/md'; +import { MdThermostatAuto, MdOutlineSensors, MdOutlineExtension } from 'react-icons/md'; import { GiHeatHaze } from 'react-icons/gi'; import { TiFlowSwitch } from 'react-icons/ti'; import { VscVmConnect } from 'react-icons/vsc'; @@ -31,6 +31,7 @@ const enum DeviceType { PUMP, GENERIC, HEATSOURCE, + CUSTOM, UNKNOWN } @@ -61,6 +62,8 @@ const DeviceIcon: FC = ({ type_id }) => { return ; case DeviceType.PUMP: return ; + case DeviceType.CUSTOM: + return ; default: return null; } diff --git a/interface/src/project/Settings.tsx b/interface/src/project/Settings.tsx index ca26a6583..1b5a62c89 100644 --- a/interface/src/project/Settings.tsx +++ b/interface/src/project/Settings.tsx @@ -10,6 +10,7 @@ import { useI18nContext } from 'i18n/i18n-react'; import SettingsApplication from './SettingsApplication'; import SettingsCustomization from './SettingsCustomization'; import SettingsScheduler from './SettingsScheduler'; +import SettingsEntities from './SettingsEntities'; const Settings: FC = () => { const { LL } = useI18nContext(); @@ -23,11 +24,13 @@ const Settings: FC = () => { + } /> } /> } /> + } /> } /> diff --git a/interface/src/project/SettingsCustomization.tsx b/interface/src/project/SettingsCustomization.tsx index 00e2b5711..344d8abae 100644 --- a/interface/src/project/SettingsCustomization.tsx +++ b/interface/src/project/SettingsCustomization.tsx @@ -245,6 +245,7 @@ const SettingsCustomization: FC = () => { if (devices) { const selected_device = parseInt(event.target.value, 10); setSelectedDevice(selected_device); + setNumChanges(0); fetchDeviceEntities(devices?.devices[selected_device].i); setRestartNeeded(false); } diff --git a/interface/src/project/SettingsEntities.tsx b/interface/src/project/SettingsEntities.tsx new file mode 100644 index 000000000..588888119 --- /dev/null +++ b/interface/src/project/SettingsEntities.tsx @@ -0,0 +1,491 @@ +import { FC, useState, useEffect, useCallback } from 'react'; +import { unstable_useBlocker as useBlocker } from 'react-router-dom'; + +import { + Button, + Typography, + Box, + Grid, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + MenuItem, + InputAdornment +} from '@mui/material'; + +import { useTheme } from '@table-library/react-table-library/theme'; +import { Table, Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table'; + +import { toast } from 'react-toastify'; + +import RemoveIcon from '@mui/icons-material/RemoveCircleOutline'; +import WarningIcon from '@mui/icons-material/Warning'; +import CancelIcon from '@mui/icons-material/Cancel'; +import DoneIcon from '@mui/icons-material/Done'; +import AddIcon from '@mui/icons-material/Add'; + +import { ValidatedTextField, ButtonRow, FormLoader, SectionContent, BlockNavigation } from 'components'; + +import { DeviceValueUOM_s, EntityItem } from './types'; +import { extractErrorMessage, updateValue } from 'utils'; + +import { validate } from 'validators'; +import { entityItemValidation } from './validators'; + +import { useI18nContext } from 'i18n/i18n-react'; + +import { ValidateFieldsError } from 'async-validator'; + +import * as EMSESP from './api'; + +const SettingsEntities: FC = () => { + const { LL, locale } = useI18nContext(); + + const [numChanges, setNumChanges] = useState(0); + const blocker = useBlocker(numChanges !== 0); + + const emptyEntity = { + device_id: 8, + type_id: 2, + offset: 0, + factor: 1, + uom: 0, + val_type: 2, + name: 'name', + deleted: false + }; + const [entity, setEntity] = useState([emptyEntity]); + const [entityItem, setEntityItem] = useState(); + const [errorMessage, setErrorMessage] = useState(); + const [creating, setCreating] = useState(false); + + const [fieldErrors, setFieldErrors] = useState(); + + useEffect(() => { + setNumChanges(getNumChanges()); + }); + + const entity_theme = useTheme({ + Table: ` + --data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) 80px 80px 80px 80px; + `, + BaseRow: ` + font-size: 14px; + .td { + height: 32px; + } + `, + BaseCell: ` + &:nth-of-type(2) { + text-align: center; + } + &:nth-of-type(3) { + text-align: center; + } + &:nth-of-type(4) { + text-align: center; + } + &:nth-of-type(5) { + text-align: right; + } + `, + 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-top: 1px solid #565656; + border-bottom: 1px solid #565656; + } + &:hover .td { + border-top: 1px solid #177ac9; + border-bottom: 1px solid #177ac9; + } + &:nth-of-type(odd) .td { + background-color: #303030; + } + ` + }); + + const fetchEntities = useCallback(async () => { + try { + const response = await EMSESP.readEntities(); + setOriginalEntity(response.data.entity); + } catch (error) { + setErrorMessage(extractErrorMessage(error, LL.PROBLEM_LOADING())); + } + }, [LL]); + + useEffect(() => { + fetchEntities(); + }, [fetchEntities]); + + const setOriginalEntity = (data: EntityItem[]) => { + setEntity( + data.map((ei) => ({ + ...ei, + o_device_id: ei.device_id, + o_type_id: ei.type_id, + o_offset: ei.offset, + o_factor: ei.factor, + o_uom: ei.uom, + o_val_type: ei.val_type, + o_name: ei.name, + o_deleted: ei.deleted + })) + ); + }; + + function hasEntityChanged(ei: EntityItem) { + return ( + ei.device_id !== ei.o_device_id || + ei.type_id !== ei.o_type_id || + ei.name !== ei.o_name || + ei.offset !== ei.o_offset || + ei.uom !== ei.o_uom || + ei.factor !== ei.o_factor || + ei.val_type !== ei.o_val_type || + ei.deleted !== ei.o_deleted + ); + } + + const getNumChanges = () => { + if (!entity) { + return 0; + } + return entity.filter((ei) => hasEntityChanged(ei)).length; + }; + + const saveEntity = async () => { + if (entity) { + try { + const response = await EMSESP.writeEntities({ + entity: entity + .filter((ei) => !ei.deleted) + .map((condensed_ei) => { + return { + device_id: condensed_ei.device_id, + type_id: condensed_ei.type_id, + offset: condensed_ei.offset, + factor: condensed_ei.factor, + val_type: condensed_ei.val_type, + uom: condensed_ei.uom, + name: condensed_ei.name + }; + }) + }); + if (response.status === 200) { + toast.success(LL.SUCCESS()); + } else { + toast.error(LL.PROBLEM_UPDATING()); + } + } catch (error) { + toast.error(extractErrorMessage(error, LL.PROBLEM_UPDATING())); + } + setOriginalEntity(entity); + } + }; + + const editEntityItem = (ei: EntityItem) => { + setCreating(false); + setEntityItem(ei); + }; + + const addEntityItem = () => { + setCreating(true); + setEntityItem({ + device_id: 8, + type_id: 2, + offset: 0, + factor: 1, + val_type: 2, + uom: 0, + name: 'name', + deleted: false + }); + }; + + const updateEntityItem = () => { + if (entityItem) { + setEntity([...entity.filter((ei) => creating || ei.o_name !== entityItem.o_name), entityItem]); + } + setEntityItem(undefined); + }; + + function formatValue(value: any, uom: number) { + if (value === undefined) { + return ''; + } + if (uom === 0) { + return new Intl.NumberFormat().format(value); + } + return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; + } + + function showHex(value: string, digit: number) { + if (digit === 4) { + return '0x' + ('000' + value).slice(-4); + } + return '0x' + ('0' + value).slice(-2); + } + + const renderEntity = () => { + if (!entity) { + return ; + } + + return ( + !ei.deleted).sort((a, b) => a.name.localeCompare(b.time)) }} + theme={entity_theme} + layout={{ custom: true }} + > + {(tableList: any) => ( + <> +
+ + {LL.NAME(0)} + Device ID + Type ID + Offset + {LL.VALUE()} + +
+ + {tableList.map((ei: EntityItem) => ( + editEntityItem(ei)}> + {ei.name} + {showHex(ei.device_id, 2)} + {showHex(ei.type_id, 4)} + {ei.offset} + {formatValue(ei.value, ei.uom)} + + ))} + + + )} +
+ ); + }; + + const removeEntityItem = (ei: EntityItem) => { + ei.deleted = true; + setEntityItem(ei); + updateEntityItem(); + }; + + const validateEntityItem = async () => { + if (entityItem) { + try { + setFieldErrors(undefined); + await validate(entityItemValidation(entity, entityItem), entityItem); + updateEntityItem(); + } catch (errors: any) { + setFieldErrors(errors); + } + } + }; + + const closeDialog = () => { + setEntityItem(undefined); + setFieldErrors(undefined); + }; + + const renderEditEntity = () => { + if (entityItem) { + return ( + closeDialog()}> + + {creating ? LL.ADD(1) + ' ' + LL.NEW() : LL.EDIT()} {LL.CUSTOM_ENTITIES()} + + + + + + + + + + + + 0x + }} + /> + + + 0x + }} + /> + + + + + + + BOOL + INT + UINT + SHORT + USHORT + ULONG + TIME + + + {entityItem.val_type !== 0 && ( + <> + + + + + + {DeviceValueUOM_s.map((val, i) => ( + + {val} + + ))} + + + + )} + + + + {!creating && ( + + + + )} + + + + + ); + } + }; + + return ( + + {blocker ? : null} + + {LL.ENTITIES_HELP_1()} + + {renderEntity()} + {renderEditEntity()} + + + {numChanges !== 0 && ( + + + + + )} + + + + + + + + + ); +}; + +export default SettingsEntities; diff --git a/interface/src/project/api.ts b/interface/src/project/api.ts index 586a632fa..ab394168a 100644 --- a/interface/src/project/api.ts +++ b/interface/src/project/api.ts @@ -17,7 +17,8 @@ import { WriteSensor, WriteAnalog, SensorData, - Schedule + Schedule, + Entities } from './types'; export function restart(): AxiosPromise { @@ -96,6 +97,18 @@ export function getCustomizations(): AxiosPromise { return AXIOS.get('/getCustomizations'); } +export function getEntities(): AxiosPromise { + return AXIOS.get('/getEntities'); +} + +export function readEntities(): AxiosPromise { + return AXIOS.get('/entity'); +} + +export function writeEntities(entities: Entities): AxiosPromise { + return AXIOS.post('/entity', entities); +} + export function getSchedule(): AxiosPromise { return AXIOS.get('/getSchedule'); } diff --git a/interface/src/project/types.ts b/interface/src/project/types.ts index 53edba81f..2989614eb 100644 --- a/interface/src/project/types.ts +++ b/interface/src/project/types.ts @@ -337,3 +337,27 @@ export enum ScheduleFlag { SCHEDULE_SAT = 64, SCHEDULE_TIMER = 128 } + +export interface EntityItem { + name: string; + device_id: string; + type_id: string; + offset: number; + factor: number; + uom: number; + val_type: number; + value?: number; + o_name?: string; + o_device_id?: string; + o_type_id?: string; + o_offset?: number; + o_factor?: number; + o_uom?: number; + o_val_type?: number; + deleted?: boolean; // optional + o_deleted?: boolean; +} + +export interface Entities { + entity: EntityItem[]; +} diff --git a/interface/src/project/validators.ts b/interface/src/project/validators.ts index 7c2900572..300dff467 100644 --- a/interface/src/project/validators.ts +++ b/interface/src/project/validators.ts @@ -1,6 +1,6 @@ import Schema, { InternalRuleItem } from 'async-validator'; import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared'; -import { Settings, ScheduleItem } from './types'; +import { Settings, ScheduleItem, EntityItem } from './types'; export const GPIO_VALIDATOR = { validator(rule: InternalRuleItem, value: number, callback: (error?: string) => void) { @@ -101,12 +101,47 @@ export const schedulerItemValidation = (schedule: ScheduleItem[], scheduleItem: ] }); -export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => ({ - validator(rule: InternalRuleItem, name: string, callback: (error?: string) => void) { - if (name && o_name && o_name !== name && schedule.find((si) => si.name === name)) { - callback('Name already in use'); - } else { - callback(); + export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => ({ + validator(rule: InternalRuleItem, name: string, callback: (error?: string) => void) { + if (name && o_name && o_name !== name && schedule.find((si) => si.name === name)) { + callback('Name already in use'); + } else { + callback(); + } } - } -}); + }); + + export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) => + new Schema({ + name: [ + { required: true, message: 'Name is required' }, + { + type: 'string', + pattern: /^[a-zA-Z0-9_\\.]{1,15}$/, + message: "Must be <15 characters: alpha numeric, '_' or '.'" + }, + ...[uniqueEntityNameValidator(entity, entityItem.o_name)] + ], + device_id: [ + { required: true, message: 'Device_id is required' }, + { type: 'string', pattern: /^[A-F0-9]{1,2}$/, message: 'Must be a hex number' } + ], + type_id: [ + { required: true, message: 'Type_id is required' }, + { type: 'string', pattern: /^[A-F0-9]{1,4}$/, message: 'Must be a hex number' } + ], + offset: [ + { required: true, message: 'Offset is required' }, + { type: 'number', min: 0, max: 255, message: 'Must be between 0 and 255' } + ] + }); + + export const uniqueEntityNameValidator = (entity: EntityItem[], o_name?: string) => ({ + validator(rule: InternalRuleItem, name: string, callback: (error?: string) => void) { + if (name && o_name && o_name !== name && entity.find((ei) => ei.name === name)) { + callback('Name already in use'); + } else { + callback(); + } + } + }); diff --git a/src/command.cpp b/src/command.cpp index 1649b2fcd..d75d58ebb 100644 --- a/src/command.cpp +++ b/src/command.cpp @@ -539,6 +539,10 @@ bool Command::device_has_commands(const uint8_t device_type) { return EMSESP::webSchedulerService.has_commands(); } + if (device_type == EMSdevice::DeviceType::CUSTOM) { + return (EMSESP::webEntityService.count_entities() != 0); + } + if (device_type == EMSdevice::DeviceType::DALLASSENSOR) { return (EMSESP::dallassensor_.have_sensors()); } diff --git a/src/emsdevice.cpp b/src/emsdevice.cpp index 7d9e0b416..f7bd9d2e8 100644 --- a/src/emsdevice.cpp +++ b/src/emsdevice.cpp @@ -135,6 +135,8 @@ const char * EMSdevice::device_type_2_device_name(const uint8_t device_type) { return F_(pump); case DeviceType::HEATSOURCE: return F_(heatsource); + case DeviceType::CUSTOM: + return F_(custom); default: return Helpers::translated_word(FL_(unknown), true); } @@ -229,6 +231,9 @@ uint8_t EMSdevice::device_name_2_device_type(const char * topic) { if (!strcmp(lowtopic, F_(heatsource))) { return DeviceType::HEATSOURCE; } + if (!strcmp(lowtopic, F_(custom))) { + return DeviceType::CUSTOM; + } return DeviceType::UNKNOWN; } diff --git a/src/emsdevice.h b/src/emsdevice.h index e2c1a8ad4..5e2729e88 100644 --- a/src/emsdevice.h +++ b/src/emsdevice.h @@ -336,6 +336,7 @@ class EMSdevice { PUMP, GENERIC, HEATSOURCE, + CUSTOM, UNKNOWN }; diff --git a/src/emsesp.cpp b/src/emsesp.cpp index f6bf067b9..3f2a8de16 100644 --- a/src/emsesp.cpp +++ b/src/emsesp.cpp @@ -32,11 +32,13 @@ ESP8266React EMSESP::esp8266React(&webServer, &dummyFS); WebSettingsService EMSESP::webSettingsService = WebSettingsService(&webServer, &dummyFS, EMSESP::esp8266React.getSecurityManager()); WebCustomizationService EMSESP::webCustomizationService = WebCustomizationService(&webServer, &dummyFS, EMSESP::esp8266React.getSecurityManager()); WebSchedulerService EMSESP::webSchedulerService = WebSchedulerService(&webServer, &dummyFS, EMSESP::esp8266React.getSecurityManager()); +WebEntityService EMSESP::webEntityService = WebEntityService(&webServer, &dummyFS, EMSESP::esp8266React.getSecurityManager()); #else ESP8266React EMSESP::esp8266React(&webServer, &LittleFS); WebSettingsService EMSESP::webSettingsService = WebSettingsService(&webServer, &LittleFS, EMSESP::esp8266React.getSecurityManager()); WebCustomizationService EMSESP::webCustomizationService = WebCustomizationService(&webServer, &LittleFS, EMSESP::esp8266React.getSecurityManager()); WebSchedulerService EMSESP::webSchedulerService = WebSchedulerService(&webServer, &LittleFS, EMSESP::esp8266React.getSecurityManager()); +WebEntityService EMSESP::webEntityService = WebEntityService(&webServer, &LittleFS, EMSESP::esp8266React.getSecurityManager()); #endif WebStatusService EMSESP::webStatusService = WebStatusService(&webServer, EMSESP::esp8266React.getSecurityManager()); @@ -480,6 +482,7 @@ void EMSESP::publish_all(bool force) { publish_device_values(EMSdevice::DeviceType::MIXER); publish_other_values(); // switch and heat pump, ... webSchedulerService.publish(); + webEntityService.publish(); publish_sensor_values(true); // includes dallas and analog sensors system_.send_heartbeat(); } @@ -512,6 +515,7 @@ void EMSESP::publish_all_loop() { case 5: publish_other_values(); // switch and heat pump webSchedulerService.publish(true); + webEntityService.publish(true); break; case 6: publish_sensor_values(true, true); @@ -601,6 +605,7 @@ void EMSESP::publish_other_values() { // publish_device_values(EMSdevice::DeviceType::ALERT); // publish_device_values(EMSdevice::DeviceType::PUMP); // publish_device_values(EMSdevice::DeviceType::GENERIC); + webEntityService.publish(); } // publish both the dallas and analog sensor values @@ -666,6 +671,11 @@ bool EMSESP::get_device_value_info(JsonObject & root, const char * cmd, const in return EMSESP::webSchedulerService.get_value_info(root, cmd); } + // own entities + if (devicetype == DeviceType::CUSTOM) { + return EMSESP::webEntityService.get_value_info(root, cmd); + } + char error[100]; snprintf(error, sizeof(error), "cannot find values for entity '%s'", cmd); root["message"] = error; @@ -866,6 +876,9 @@ bool EMSESP::process_telegram(std::shared_ptr telegram) { return false; } + // Check for custom entities reding this telegram + webEntityService.get_value(telegram); + // check for common types, like the Version(0x02) if (telegram->type_id == EMSdevice::EMS_TYPE_VERSION) { process_version(telegram); @@ -1063,6 +1076,7 @@ bool EMSESP::add_device(const uint8_t device_id, const uint8_t product_id, const name = "RF room temperature sensor"; device_type = DeviceType::THERMOSTAT; } else if (device_id == EMSdevice::EMS_DEVICE_ID_ROOMTHERMOSTAT || device_id == EMSdevice::EMS_DEVICE_ID_TADO_OLD) { + // see https://github.com/emsesp/EMS-ESP32/issues/174 name = "Generic thermostat"; device_type = DeviceType::THERMOSTAT; flags = DeviceFlags::EMS_DEVICE_FLAG_RC10 | DeviceFlags::EMS_DEVICE_FLAG_NO_WRITE; @@ -1078,7 +1092,8 @@ bool EMSESP::add_device(const uint8_t device_id, const uint8_t product_id, const } else if (device_id == EMSdevice::EMS_DEVICE_ID_CASCADE) { name = "Cascade"; device_type = DeviceType::CONNECT; - } else if (device_id == EMSdevice::EMS_DEVICE_ID_EASYCOM) { + } else if (device_id == EMSdevice::EMS_DEVICE_ID_EASYCOM + || (device_id >= EMSdevice::EMS_DEVICE_ID_MODEM && device_id <= EMSdevice::EMS_DEVICE_ID_MODEM + 5)) { // see https://github.com/emsesp/EMS-ESP/issues/460#issuecomment-709553012 name = "Modem"; device_type = DeviceType::CONNECT; @@ -1372,6 +1387,7 @@ void EMSESP::scheduled_fetch_values() { return; } } + webEntityService.fetch(); no = 0; } } @@ -1453,6 +1469,7 @@ void EMSESP::start() { webCustomizationService.begin(); // load the customizations webSchedulerService.begin(); // load the scheduler events + webEntityService.begin(); // load the custom telegram reads // start telnet service if it's enabled // default idle is 10 minutes, default write timeout is 0 (automatic) diff --git a/src/emsesp.h b/src/emsesp.h index c01d8849a..3e4fa77ac 100644 --- a/src/emsesp.h +++ b/src/emsesp.h @@ -46,6 +46,7 @@ #include "web/WebSchedulerService.h" #include "web/WebAPIService.h" #include "web/WebLogService.h" +#include "web/WebEntityService.h" #include "emsdevicevalue.h" #include "emsdevice.h" @@ -230,6 +231,7 @@ class EMSESP { static WebLogService webLogService; static WebCustomizationService webCustomizationService; static WebSchedulerService webSchedulerService; + static WebEntityService webEntityService; private: static std::string device_tostring(const uint8_t device_id); diff --git a/src/locale_common.h b/src/locale_common.h index b069e6d7e..e45141b1a 100644 --- a/src/locale_common.h +++ b/src/locale_common.h @@ -98,6 +98,7 @@ MAKE_WORD(alert) MAKE_WORD(pump) MAKE_WORD(heatsource) MAKE_WORD(scheduler) +MAKE_WORD(custom) // brands MAKE_WORD_CUSTOM(bosch, "Bosch") diff --git a/src/locale_translations.h b/src/locale_translations.h index 767e02532..2546e277d 100644 --- a/src/locale_translations.h +++ b/src/locale_translations.h @@ -49,6 +49,7 @@ MAKE_WORD_TRANSLATION(pump_device, "Pump Module", "Pumpenmodul", "Pump Module", MAKE_WORD_TRANSLATION(heatsource_device, "Heatsource", "Heizquelle", "Heatsource", "Värmekälla", "Źródło ciepła", "Varmekilde", "", "Isı Kaynağı") // TODO translate MAKE_WORD_TRANSLATION(sensors_device, "Sensors", "Sensoren", "Sensoren", "Sensorer", "Czujniki", "Sensorer", "Capteurs", "Sensör Cihazı") MAKE_WORD_TRANSLATION(unknown_device, "Unknown", "Unbekannt", "Onbekend", "Okänt", "Nieznane urządzenie", "Ukjent", "Inconnu", "") // TODO translate +MAKE_WORD_TRANSLATION(custom_device, "User defined entities", "Nutzer deklarierte Entitäten", "", "", "", "", "", "") // TODO translate // commands // TODO translate @@ -64,6 +65,7 @@ MAKE_WORD_TRANSLATION(watch_cmd, "watch incoming telegrams", "Watch auf eingehen MAKE_WORD_TRANSLATION(publish_cmd, "publish all to MQTT", "Publiziere MQTT", "", "", "opublikuj wszystko na MQTT", "Publiser alt til MQTT", "", "Hepsini MQTTye gönder") // TODO translate MAKE_WORD_TRANSLATION(system_info_cmd, "show system status", "Zeige System-Status", "", "", "pokaż status systemu", "vis system status", "", "Sistem Durumunu Göster") // TODO translate MAKE_WORD_TRANSLATION(schedule_cmd, "enable schedule item", "Aktiviere Zeitplan", "", "", "aktywuj wybrany harmonogram", "", "", "") // TODO translate +MAKE_WORD_TRANSLATION(entity_cmd, "set custom value on ems", "Sende eigene Entitäten zu EMS", "", "", "", "", "", "") // TODO translate // tags MAKE_WORD_TRANSLATION(tag_boiler_data_ww, "dhw", "WW", "dhw", "VV", "CWU", "dhw", "ecs", "SKS") diff --git a/src/system.cpp b/src/system.cpp index 5d2b05de0..a814c704a 100644 --- a/src/system.cpp +++ b/src/system.cpp @@ -1005,6 +1005,9 @@ bool System::check_restore() { } else if (settings_type == "schedule") { // it's a schedule file, just replace it and there's no need to reboot saveSettings(EMSESP_SCHEDULER_FILE, "Schedule", input); + } else if (settings_type == "entities") { + // it's a entity file, just replace it and there's no need to reboot + saveSettings(EMSESP_ENTITY_FILE, "Entities", input); } else { LOG_ERROR("Unrecognized file uploaded"); } diff --git a/src/web/WebAPIService.cpp b/src/web/WebAPIService.cpp index cd5ac4894..0e31d8a96 100644 --- a/src/web/WebAPIService.cpp +++ b/src/web/WebAPIService.cpp @@ -37,6 +37,7 @@ WebAPIService::WebAPIService(AsyncWebServer * server, SecurityManager * security HTTP_GET, securityManager->wrapRequest(std::bind(&WebAPIService::getCustomizations, this, _1), AuthenticationPredicates::IS_ADMIN)); server->on(GET_SCHEDULE_PATH, HTTP_GET, securityManager->wrapRequest(std::bind(&WebAPIService::getSchedule, this, _1), AuthenticationPredicates::IS_ADMIN)); + server->on(GET_ENTITIES_PATH, HTTP_GET, securityManager->wrapRequest(std::bind(&WebAPIService::getEntities, this, _1), AuthenticationPredicates::IS_ADMIN)); } // HTTP GET @@ -209,4 +210,16 @@ void WebAPIService::getSchedule(AsyncWebServerRequest * request) { request->send(response); } +void WebAPIService::getEntities(AsyncWebServerRequest * request) { + auto * response = new AsyncJsonResponse(false, FS_BUFFER_SIZE); + JsonObject root = response->getRoot(); + + root["type"] = "entities"; + + System::extractSettings(EMSESP_ENTITY_FILE, "Entites", root); + + response->setLength(); + request->send(response); +} + } // namespace emsesp diff --git a/src/web/WebAPIService.h b/src/web/WebAPIService.h index c72a9ab89..2570317b5 100644 --- a/src/web/WebAPIService.h +++ b/src/web/WebAPIService.h @@ -23,6 +23,7 @@ #define GET_SETTINGS_PATH "/rest/getSettings" #define GET_CUSTOMIZATIONS_PATH "/rest/getCustomizations" #define GET_SCHEDULE_PATH "/rest/getSchedule" +#define GET_ENTITIES_PATH "/rest/getEntities" namespace emsesp { @@ -53,6 +54,7 @@ class WebAPIService { void getSettings(AsyncWebServerRequest * request); void getCustomizations(AsyncWebServerRequest * request); void getSchedule(AsyncWebServerRequest * request); + void getEntities(AsyncWebServerRequest * request); }; } // namespace emsesp diff --git a/src/web/WebDataService.cpp b/src/web/WebDataService.cpp index 9aace51a5..05ecdd447 100644 --- a/src/web/WebDataService.cpp +++ b/src/web/WebDataService.cpp @@ -91,6 +91,18 @@ void WebDataService::core_data(AsyncWebServerRequest * request) { obj["e"] = emsdevice->count_entities(); // number of entities (device values) } } + if (EMSESP::webEntityService.count_entities()) { + JsonObject obj = devices.createNestedObject(); + obj["id"] = "99"; // the last unique id as a string + obj["tn"] = "Custom"; // translated device type name + obj["t"] = EMSdevice::DeviceType::CUSTOM; // device type number + obj["b"] = 0; // brand + obj["n"] = Helpers::translated_word(FL_(custom_device)); // name + obj["d"] = 0; // deviceid + obj["p"] = 0; // productid + obj["v"] = 0; // version + obj["e"] = EMSESP::webEntityService.count_entities(); // number of entities (device values) + } // sensors stuff root["s_n"] = Helpers::translated_word(FL_(sensors_device)); @@ -196,6 +208,15 @@ void WebDataService::device_data(AsyncWebServerRequest * request, JsonVariant & return; } } +#ifndef EMSESP_STANDALONE + if (json["id"] == 99) { + JsonObject output = response->getRoot(); + EMSESP::webEntityService.generate_value_web(output); + response->setLength(); + request->send(response); + return; + } +#endif } // invalid but send ok @@ -256,6 +277,37 @@ void WebDataService::write_value(AsyncWebServerRequest * request, JsonVariant & return; } } + if (unique_id == 99) { + // parse the command as it could have a hc or wwc prefixed, e.g. hc2/seltemp + const char * cmd = dv["c"]; + int8_t id = -1; + cmd = Command::parse_command_string(cmd, id); + auto * response = new AsyncJsonResponse(false, EMSESP_JSON_SIZE_SMALL); + JsonObject output = response->getRoot(); + JsonVariant data = dv["v"]; // the value in any format + uint8_t return_code = CommandRet::OK; + uint8_t device_type = EMSdevice::DeviceType::CUSTOM; + if (data.is()) { + char s[10]; + return_code = Command::call(device_type, cmd, Helpers::render_value(s, data.as(), 0), true, id, output); + } else if (data.is()) { + char s[10]; + return_code = Command::call(device_type, cmd, Helpers::render_value(s, data.as(), 1), true, id, output); + } else if (data.is()) { + return_code = Command::call(device_type, cmd, data.as() ? "true" : "false", true, id, output); + } + if (return_code != CommandRet::OK) { + EMSESP::logger().err("Write command failed %s (%s)", (const char *)output["message"], Command::return_code_string(return_code).c_str()); + } else { +#if defined(EMSESP_DEBUG) + EMSESP::logger().debug("Write command successful"); +#endif + } + response->setCode((return_code == CommandRet::OK) ? 200 : 204); + response->setLength(); + request->send(response); + return; + } } AsyncWebServerResponse * response = request->beginResponse(204); // Write command failed diff --git a/src/web/WebEntityService.cpp b/src/web/WebEntityService.cpp new file mode 100644 index 000000000..c5ff61ce4 --- /dev/null +++ b/src/web/WebEntityService.cpp @@ -0,0 +1,429 @@ +/* + * EMS-ESP - https://github.com/emsesp/EMS-ESP + * Copyright 2020-2023 Paul Derbyshire + * + * 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 . + */ + +#include "emsesp.h" + +namespace emsesp { + +using namespace std::placeholders; // for `_1` etc + +WebEntityService::WebEntityService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager) + : _httpEndpoint(WebEntity::read, WebEntity::update, this, server, EMSESP_ENTITY_SERVICE_PATH, securityManager, AuthenticationPredicates::IS_AUTHENTICATED) + , _fsPersistence(WebEntity::read, WebEntity::update, this, fs, EMSESP_ENTITY_FILE, FS_BUFFER_SIZE) { +} + +// load the settings when the service starts +void WebEntityService::begin() { + _fsPersistence.readFromFS(); + EMSESP::logger().info("Starting custom entity service"); +} + +// this creates the scheduler file, saving it to the FS +// and also calls when the Scheduler web page is refreshed +void WebEntity::read(WebEntity & webEntity, JsonObject & root) { + JsonArray entity = root.createNestedArray("entity"); + for (const EntityItem & entityItem : webEntity.entityItems) { + JsonObject ei = entity.createNestedObject(); + ei["device_id"] = Helpers::hextoa(entityItem.device_id, false); + ei["type_id"] = Helpers::hextoa(entityItem.type_id, false); + ei["offset"] = entityItem.offset; + ei["factor"] = entityItem.factor; + ei["name"] = entityItem.name; + ei["uom"] = entityItem.uom; + ei["val_type"] = entityItem.valuetype; + EMSESP::webEntityService.render_value(ei, entityItem, true); + } +} + +// call on initialization and also when the Schedule web page is updated +// this loads the data into the internal class +StateUpdateResult WebEntity::update(JsonObject & root, WebEntity & webEntity) { + for (EntityItem & entityItem : webEntity.entityItems) { + Command::erase_command(EMSdevice::DeviceType::CUSTOM, entityItem.name.c_str()); + } + webEntity.entityItems.clear(); + + if (root["entity"].is()) { + for (const JsonObject ei : root["entity"].as()) { + auto entityItem = EntityItem(); + entityItem.device_id = Helpers::hextoint(ei["device_id"]); + entityItem.type_id = Helpers::hextoint(ei["type_id"]); + entityItem.offset = ei["offset"]; + entityItem.factor = ei["factor"]; + entityItem.name = ei["name"].as(); + entityItem.uom = ei["uom"]; + entityItem.valuetype = ei["val_type"]; + + if (entityItem.valuetype == DeviceValueType::BOOL) { + entityItem.val = EMS_VALUE_DEFAULT_BOOL; + } else if (entityItem.valuetype == DeviceValueType::INT) { + entityItem.val = EMS_VALUE_DEFAULT_INT; + } else if (entityItem.valuetype == DeviceValueType::UINT) { + entityItem.val = EMS_VALUE_DEFAULT_UINT; + } else if (entityItem.valuetype == DeviceValueType::SHORT) { + entityItem.val = EMS_VALUE_DEFAULT_SHORT; + } else if (entityItem.valuetype == DeviceValueType::USHORT) { + entityItem.val = EMS_VALUE_DEFAULT_USHORT; + } else { // if (entityItem.valuetype == DeviceValueType::ULONG || entityItem.valuetype == DeviceValueType::TIME) { + entityItem.val = EMS_VALUE_DEFAULT_ULONG; + } + + webEntity.entityItems.push_back(entityItem); // add to list + Command::add( + EMSdevice::DeviceType::CUSTOM, + webEntity.entityItems.back().name.c_str(), + [webEntity](const char * value, const int8_t id) { return EMSESP::webEntityService.command_setvalue(value, webEntity.entityItems.back().name); }, + FL_(entity_cmd), + CommandFlag::ADMIN_ONLY); + } + } + return StateUpdateResult::CHANGED; +} + +// set value by api command +bool WebEntityService::command_setvalue(const char * value, const std::string name) { + EMSESP::webEntityService.read([&](WebEntity & webEntity) { entityItems = &webEntity.entityItems; }); + for (EntityItem & entityItem : *entityItems) { + if (entityItem.name == name) { + if (entityItem.valuetype == DeviceValueType::BOOL) { + bool v; + if (!Helpers::value2bool(value, v)) { + return false; + } + EMSESP::send_write_request(entityItem.type_id, entityItem.device_id, entityItem.offset, v ? 0xFF : 0, 0); + } else { + float f; + if (!Helpers::value2float(value, f)) { + return false; + } + int v = f / entityItem.factor; + if (entityItem.valuetype == DeviceValueType::UINT || entityItem.valuetype == DeviceValueType::INT) { + EMSESP::send_write_request(entityItem.type_id, entityItem.device_id, entityItem.offset, v, 0); + } else if (entityItem.valuetype == DeviceValueType::USHORT || entityItem.valuetype == DeviceValueType::SHORT) { + uint8_t v1[2] = {(uint8_t)(v >> 8), (uint8_t)(v & 0xFF)}; + EMSESP::send_write_request(entityItem.type_id, entityItem.device_id, entityItem.offset, v1, 2, 0); + } else { + uint8_t v1[3] = {(uint8_t)(v >> 16), (uint8_t)((v & 0xFF00) >> 8), (uint8_t)(v & 0xFF)}; + EMSESP::send_write_request(entityItem.type_id, entityItem.device_id, entityItem.offset, v1, 3, 0); + } + } + publish_single(entityItem); + if (EMSESP::mqtt_.get_publish_onchange(0)) { + publish(); + } + return true; + } + } + return false; +} + +// output of a single value +void WebEntityService::render_value(JsonObject & output, EntityItem entity, const bool useVal) { + char payload[12]; + std::string name = useVal ? "value" : entity.name; + switch (entity.valuetype) { + case DeviceValueType::BOOL: + if ((uint8_t)entity.val != EMS_VALUE_BOOL_NOTSET) { + if (EMSESP::system_.bool_format() == BOOL_FORMAT_TRUEFALSE) { + output[name] = (uint8_t)entity.val ? true : false; + } else if (EMSESP::system_.bool_format() == BOOL_FORMAT_10) { + output[name] = (uint8_t)entity.val ? 1 : 0; + } else { + output[name] = Helpers::render_boolean(payload, (uint8_t)entity.val); + } + } + break; + case DeviceValueType::INT: + if ((int8_t)entity.val != EMS_VALUE_INT_NOTSET) { + output[name] = serialized(Helpers::render_value(payload, entity.factor * (int8_t)entity.val, 2)); + } + break; + case DeviceValueType::UINT: + if ((uint8_t)entity.val != EMS_VALUE_UINT_NOTSET) { + output[name] = serialized(Helpers::render_value(payload, entity.factor * (uint8_t)entity.val, 2)); + } + break; + case DeviceValueType::SHORT: + if ((int16_t)entity.val != EMS_VALUE_SHORT_NOTSET) { + output[name] = serialized(Helpers::render_value(payload, entity.factor * (int16_t)entity.val, 2)); + } + break; + case DeviceValueType::USHORT: + if ((uint16_t)entity.val != EMS_VALUE_USHORT_NOTSET) { + output[name] = serialized(Helpers::render_value(payload, entity.factor * (uint16_t)entity.val, 2)); + } + break; + case DeviceValueType::ULONG: + case DeviceValueType::TIME: + if (entity.val != EMS_VALUE_ULONG_NOTSET) { + output[name] = serialized(Helpers::render_value(payload, entity.factor * entity.val, 2)); + } + break; + default: + // EMSESP::logger().warning("unknown value type"); + break; + } +} + +// process json output for info/commands and value_info +bool WebEntityService::get_value_info(JsonObject & output, const char * cmd) { + EMSESP::webEntityService.read([&](WebEntity & webEntity) { entityItems = &webEntity.entityItems; }); + if (entityItems->size() == 0) { + return false; + } + if (Helpers::toLower(cmd) == "commands") { + output["info"] = "lists all values"; + output["commands"] = "lists all commands"; + for (const auto & entity : *entityItems) { + output[entity.name] = "custom entitiy"; + } + return true; + } + if (strlen(cmd) == 0 || Helpers::toLower(cmd) == "values" || Helpers::toLower(cmd) == "info") { + // list all names + for (const EntityItem & entity : *entityItems) { + render_value(output, entity); + } + return (output.size() != 0); + } + char command_s[30]; + strlcpy(command_s, cmd, sizeof(command_s)); + char * attribute_s = nullptr; + // check specific attribute to fetch instead of the complete record + char * breakp = strchr(command_s, '/'); + if (breakp) { + *breakp = '\0'; + attribute_s = breakp + 1; + } + for (const auto & entity : *entityItems) { + if (Helpers::toLower(entity.name) == Helpers::toLower(command_s)) { + output["name"] = entity.name; + output["uom"] = EMSdevice::uom_to_string(entity.uom); + output["readable"] = true; + output["writeable"] = true; + output["visible"] = true; + render_value(output, entity, true); + if (attribute_s) { + if (output.containsKey(attribute_s)) { + JsonVariant data = output[attribute_s]; + output.clear(); + output["api_data"] = data; + } else { + char error[100]; + snprintf(error, sizeof(error), "cannot find attribute %s in entity %s", attribute_s, command_s); + output.clear(); + output["message"] = error; + } + } + } + if (output.size()) { + return true; + } + } + output["message"] = "unknown command"; + return false; +} + +// publish single value +void WebEntityService::publish_single(const EntityItem & entity) { + if (!Mqtt::enabled() || !Mqtt::publish_single()) { + return; + } + char topic[Mqtt::MQTT_TOPIC_MAX_SIZE]; + if (Mqtt::publish_single2cmd()) { + snprintf(topic, sizeof(topic), "%s/%s", "custom", entity.name.c_str()); + } else { + snprintf(topic, sizeof(topic), "%s/%s", "custom_data", entity.name.c_str()); + } + StaticJsonDocument<256> doc; + JsonObject output = doc.to(); + render_value(output, entity, true); + Mqtt::queue_publish(topic, output["value"].as()); +} + +// publish to Mqtt +void WebEntityService::publish(const bool force) { + if (force) { + ha_registered_ = false; + } + if (!Mqtt::enabled()) { + return; + } + EMSESP::webEntityService.read([&](WebEntity & webEntity) { entityItems = &webEntity.entityItems; }); + if (entityItems->size() == 0) { + return; + } + if (Mqtt::publish_single() && force) { + for (const EntityItem & entityItem : *entityItems) { + publish_single(entityItem); + } + } + + DynamicJsonDocument doc(EMSESP_JSON_SIZE_XLARGE); + JsonObject output = doc.to(); + for (const EntityItem & entityItem : *entityItems) { + render_value(output, entityItem); + // create HA config + if (Mqtt::ha_enabled() && !ha_registered_) { + StaticJsonDocument config; + char stat_t[50]; + snprintf(stat_t, sizeof(stat_t), "%s/custom_data", Mqtt::base().c_str()); + config["stat_t"] = stat_t; + + char val_obj[50]; + char val_cond[65]; + snprintf(val_obj, sizeof(val_obj), "value_json['%s']", entityItem.name.c_str()); + snprintf(val_cond, sizeof(val_cond), "%s is defined", val_obj); + config["val_tpl"] = (std::string) "{{" + val_obj + " if " + val_cond + "}}"; + + char uniq_s[70]; + snprintf(uniq_s, sizeof(uniq_s), "custom_%s", entityItem.name.c_str()); + + config["obj_id"] = uniq_s; + config["uniq_id"] = uniq_s; // same as object_id + config["name"] = entityItem.name.c_str(); + + char topic[Mqtt::MQTT_TOPIC_MAX_SIZE]; + snprintf(topic, sizeof(topic), "sensor/%s/custom_%s/config", Mqtt::basename().c_str(), entityItem.name.c_str()); + //char command_topic[Mqtt::MQTT_TOPIC_MAX_SIZE]; + // snprintf(command_topic, sizeof(command_topic), "%s/custom/%s", Mqtt::basename().c_str(), entityItem.name.c_str()); + // config["cmd_t"] = command_topic; + + JsonObject dev = config.createNestedObject("dev"); + JsonArray ids = dev.createNestedArray("ids"); + ids.add("ems-esp"); + + // add "availability" section + Mqtt::add_avty_to_doc(stat_t, config.as(), val_cond); + Mqtt::queue_ha(topic, config.as()); + ha_registered_ = true; + } + } + if (output.size() > 0) { + Mqtt::queue_publish("custom_data", output); + } + // EMSESP::logger().debug("publish %d custom entities", output.size()); +} + +// count only entities with valid value +uint8_t WebEntityService::count_entities() { + EMSESP::webEntityService.read([&](WebEntity & webEntity) { entityItems = &webEntity.entityItems; }); + if (entityItems->size() == 0) { + return 0; + } + DynamicJsonDocument doc(EMSESP_JSON_SIZE_XLARGE); + JsonObject output = doc.to(); + for (const EntityItem & entity : *entityItems) { + render_value(output, entity); + } + return output.size(); +} + +// send to dashboard, msgpack don't like serialized, use number +void WebEntityService::generate_value_web(JsonObject & output) { + EMSESP::webEntityService.read([&](WebEntity & webEntity) { entityItems = &webEntity.entityItems; }); + output["label"] = (std::string) "Custom Entities"; + JsonArray data = output.createNestedArray("data"); + for (const EntityItem & entity : *entityItems) { + JsonObject obj = data.createNestedObject(); // create the object, we know there is a value + obj["id"] = "00" + entity.name; + obj["u"] = entity.uom; + obj["c"] = entity.name; + switch (entity.valuetype) { + case DeviceValueType::BOOL: { + char s[12]; + obj["v"] = Helpers::render_boolean(s, (uint8_t)entity.val); + JsonArray l = obj.createNestedArray("l"); + l.add(Helpers::render_boolean(s, false, true)); + l.add(Helpers::render_boolean(s, true, true)); + break; + } + case DeviceValueType::INT: + if ((int8_t)entity.val != EMS_VALUE_INT_NOTSET) { + obj["v"] = Helpers::transformNumFloat(entity.factor * (int8_t)entity.val, 0); + } + break; + case DeviceValueType::UINT: + if ((uint8_t)entity.val != EMS_VALUE_UINT_NOTSET) { + obj["v"] = Helpers::transformNumFloat(entity.factor * (uint8_t)entity.val, 0); + } + break; + case DeviceValueType::SHORT: + if ((int16_t)entity.val != EMS_VALUE_SHORT_NOTSET) { + obj["v"] = Helpers::transformNumFloat(entity.factor * (int16_t)entity.val, 0); + } + break; + case DeviceValueType::USHORT: + if ((uint16_t)entity.val != EMS_VALUE_USHORT_NOTSET) { + obj["v"] = Helpers::transformNumFloat(entity.factor * (uint16_t)entity.val, 0); + } + break; + case DeviceValueType::ULONG: + case DeviceValueType::TIME: + if (entity.val != EMS_VALUE_ULONG_NOTSET) { + obj["v"] = Helpers::transformNumFloat(entity.factor * entity.val, 0); + } + break; + default: + break; + } + } +} + +// fetch telegram, called from emsesp::fetch +void WebEntityService::fetch() { + EMSESP::webEntityService.read([&](WebEntity & webEntity) { entityItems = &webEntity.entityItems; }); + for (auto & entity : *entityItems) { + EMSESP::send_read_request(entity.type_id, entity.device_id, entity.offset); + } + // EMSESP::logger().debug("fetch custom entities"); +} + +// called on process telegram, read from telegram +bool WebEntityService::get_value(std::shared_ptr telegram) { + bool has_change = false; + EMSESP::webEntityService.read([&](WebEntity & webEntity) { entityItems = &webEntity.entityItems; }); + // read-length of BOOL, INT, UINT, SHORT, USHORT, ULONG, TIME + const uint8_t len[] = {1, 1, 1, 2, 2, 3, 3}; + for (auto & entity : *entityItems) { + if (telegram->type_id == entity.type_id && telegram->src == entity.device_id && telegram->offset <= entity.offset + && (telegram->offset + telegram->message_length) >= (entity.offset + len[entity.valuetype])) { + uint32_t val = 0; + for (uint8_t i = 0; i < len[entity.valuetype]; i++) { + val = (val << 8) + telegram->message_data[i + entity.offset - telegram->offset]; + } + if (val != entity.val) { + entity.val = val; + if (Mqtt::publish_single()) { + publish_single(entity); + } else if (EMSESP::mqtt_.get_publish_onchange(0)) { + has_change = true; + } + } + // EMSESP::logger().debug("custom entity %s received with value %d", entity.name.c_str(), (int)entity.val); + break; + } + } + if (has_change) { + publish(); + return true; + } + return false; +} + +} // namespace emsesp diff --git a/src/web/WebEntityService.h b/src/web/WebEntityService.h new file mode 100644 index 000000000..fc58370cb --- /dev/null +++ b/src/web/WebEntityService.h @@ -0,0 +1,74 @@ +/* + * EMS-ESP - https://github.com/emsesp/EMS-ESP + * Copyright 2020-2023 Paul Derbyshire + * + * 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 . + */ +#include "../telegram.h" + +#ifndef WebEntityService_h +#define WebEntityService_h + +#define EMSESP_ENTITY_FILE "/config/emsespEntity.json" +#define EMSESP_ENTITY_SERVICE_PATH "/rest/entity" // GET and POST + +namespace emsesp { + +class EntityItem { + public: + uint8_t device_id; + uint16_t type_id; + uint8_t offset; + int8_t valuetype; + uint8_t uom; + std::string name; + double factor; + uint32_t val; +}; + +class WebEntity { + public: + std::list entityItems; + + static void read(WebEntity & webEntity, JsonObject & root); + static StateUpdateResult update(JsonObject & root, WebEntity & webEntity); +}; + +class WebEntityService : public StatefulService { + public: + WebEntityService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager); + + void begin(); + void publish_single(const EntityItem & entity); + void publish(const bool force = false); + bool command_setvalue(const char * value, const std::string name); + bool get_value_info(JsonObject & output, const char * cmd); + bool get_value(std::shared_ptr telegram); + void fetch(); + void render_value(JsonObject & output, EntityItem entity, const bool useVal = false); + uint8_t count_entities(); + void generate_value_web(JsonObject & output); + + + private: + HttpEndpoint _httpEndpoint; + FSPersistence _fsPersistence; + + std::list * entityItems; // pointer to the list of schedule events + bool ha_registered_ = false; +}; + +} // namespace emsesp + +#endif \ No newline at end of file