/* * 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 WebCustomEntityService::WebCustomEntityService(AsyncWebServer * server, FS * fs, SecurityManager * securityManager) : _httpEndpoint(WebCustomEntity::read, WebCustomEntity::update, this, server, EMSESP_CUSTOMENTITY_SERVICE_PATH, securityManager, AuthenticationPredicates::IS_AUTHENTICATED) , _fsPersistence(WebCustomEntity::read, WebCustomEntity::update, this, fs, EMSESP_CUSTOMENTITY_FILE, FS_BUFFER_SIZE) { } // load the settings when the service starts void WebCustomEntityService::begin() { _fsPersistence.readFromFS(); EMSESP::logger().info("Starting Custom entity service"); Mqtt::subscribe(EMSdevice::DeviceType::CUSTOM, "custom/#", nullptr); // use empty function callback } // this creates the entity file, saving it to the FS // and also calls when the Entity web page is refreshed void WebCustomEntity::read(WebCustomEntity & webEntity, JsonObject & root) { JsonArray entity = root.createNestedArray("entities"); uint8_t counter = 0; for (const CustomEntityItem & entityItem : webEntity.customEntityItems) { JsonObject ei = entity.createNestedObject(); ei["id"] = counter++; // id is only used to render the table and must be unique ei["device_id"] = entityItem.device_id; ei["type_id"] = entityItem.type_id; ei["offset"] = entityItem.offset; ei["factor"] = entityItem.factor; ei["name"] = entityItem.name; ei["uom"] = entityItem.uom; ei["value_type"] = entityItem.value_type; ei["writeable"] = entityItem.writeable; EMSESP::webCustomEntityService.render_value(ei, entityItem, true, true); } } // call on initialization and also when the Entity web page is updated // this loads the data into the internal class StateUpdateResult WebCustomEntity::update(JsonObject & root, WebCustomEntity & webCustomEntity) { #ifdef EMSESP_STANDALONE // invoke some fake data for testing // clang-format off /* prettier-ignore */ const char * json = "{\"entities\": [{\"id\":0,\"device_id\":8,\"type_id\":24,\"offset\":0,\"factor\":1,\"name\":\"boiler_flowtemp\",\"uom\":1,\"value_type\":1,\"writeable\":true}]}"; // clang-format on StaticJsonDocument<500> doc; deserializeJson(doc, json); root = doc.as(); Serial.println(COLOR_BRIGHT_MAGENTA); Serial.print(" Using fake custom entity file: "); serializeJson(root, Serial); Serial.println(COLOR_RESET); #endif for (CustomEntityItem & entityItem : webCustomEntity.customEntityItems) { Command::erase_command(EMSdevice::DeviceType::CUSTOM, entityItem.name.c_str()); } webCustomEntity.customEntityItems.clear(); EMSESP::webCustomEntityService.ha_reset(); if (root["entities"].is()) { for (const JsonObject ei : root["entities"].as()) { auto entityItem = CustomEntityItem(); entityItem.device_id = ei["device_id"]; // send as numeric, will be converted to string in web entityItem.type_id = ei["type_id"]; entityItem.offset = ei["offset"]; entityItem.factor = ei["factor"]; entityItem.name = ei["name"].as(); entityItem.uom = ei["uom"]; entityItem.value_type = ei["value_type"]; entityItem.writeable = ei["writeable"]; if (entityItem.value_type == DeviceValueType::BOOL) { entityItem.value = EMS_VALUE_DEFAULT_BOOL; } else if (entityItem.value_type == DeviceValueType::INT) { entityItem.value = EMS_VALUE_DEFAULT_INT; } else if (entityItem.value_type == DeviceValueType::UINT) { entityItem.value = EMS_VALUE_DEFAULT_UINT; } else if (entityItem.value_type == DeviceValueType::SHORT) { entityItem.value = EMS_VALUE_DEFAULT_SHORT; } else if (entityItem.value_type == DeviceValueType::USHORT) { entityItem.value = EMS_VALUE_DEFAULT_USHORT; } else { // if (entityItem.value_type == DeviceValueType::ULONG || entityItem.value_type == DeviceValueType::TIME) { entityItem.value = EMS_VALUE_DEFAULT_ULONG; } if (entityItem.factor == 0) { entityItem.factor = 1; } webCustomEntity.customEntityItems.push_back(entityItem); // add to list if (entityItem.writeable) { Command::add( EMSdevice::DeviceType::CUSTOM, webCustomEntity.customEntityItems.back().name.c_str(), [webCustomEntity](const char * value, const int8_t id) { return EMSESP::webCustomEntityService.command_setvalue(value, webCustomEntity.customEntityItems.back().name); }, FL_(entity_cmd), CommandFlag::ADMIN_ONLY); } } } return StateUpdateResult::CHANGED; } // set value by api command bool WebCustomEntityService::command_setvalue(const char * value, const std::string name) { EMSESP::webCustomEntityService.read([&](WebCustomEntity & webEntity) { customEntityItems = &webEntity.customEntityItems; }); for (CustomEntityItem & entityItem : *customEntityItems) { if (Helpers::toLower(entityItem.name) == Helpers::toLower(name)) { if (entityItem.value_type == DeviceValueType::STRING) { char telegram[84]; strlcpy(telegram, value, sizeof(telegram)); uint8_t data[EMS_MAX_TELEGRAM_LENGTH]; uint8_t count = 0; char * p = strtok(telegram, " ,"); // delimiter while (p != nullptr) { data[count++] = (uint8_t)strtol(p, 0, 16); p = strtok(nullptr, " ,"); } if (count == 0) { return false; } EMSESP::send_write_request(entityItem.type_id, entityItem.device_id, entityItem.offset, data, count, 0); } else if (entityItem.value_type == 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.value_type == DeviceValueType::UINT || entityItem.value_type == DeviceValueType::INT) { EMSESP::send_write_request(entityItem.type_id, entityItem.device_id, entityItem.offset, v, 0); } else if (entityItem.value_type == DeviceValueType::USHORT || entityItem.value_type == 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 WebCustomEntityService::render_value(JsonObject & output, CustomEntityItem entity, const bool useVal, const bool web) { char payload[12]; std::string name = useVal ? "value" : entity.name; switch (entity.value_type) { case DeviceValueType::BOOL: if ((uint8_t)entity.value != EMS_VALUE_BOOL_NOTSET) { if (web) { output[name] = Helpers::render_boolean(payload, (uint8_t)entity.value, true); } else if (EMSESP::system_.bool_format() == BOOL_FORMAT_TRUEFALSE) { output[name] = (uint8_t)entity.value ? true : false; } else if (EMSESP::system_.bool_format() == BOOL_FORMAT_10) { output[name] = (uint8_t)entity.value ? 1 : 0; } else { output[name] = Helpers::render_boolean(payload, (uint8_t)entity.value); } } break; case DeviceValueType::INT: if ((int8_t)entity.value != EMS_VALUE_INT_NOTSET) { output[name] = serialized(Helpers::render_value(payload, entity.factor * (int8_t)entity.value, 2)); } break; case DeviceValueType::UINT: if ((uint8_t)entity.value != EMS_VALUE_UINT_NOTSET) { output[name] = serialized(Helpers::render_value(payload, entity.factor * (uint8_t)entity.value, 2)); } break; case DeviceValueType::SHORT: if ((int16_t)entity.value != EMS_VALUE_SHORT_NOTSET) { output[name] = serialized(Helpers::render_value(payload, entity.factor * (int16_t)entity.value, 2)); } break; case DeviceValueType::USHORT: if ((uint16_t)entity.value != EMS_VALUE_USHORT_NOTSET) { output[name] = serialized(Helpers::render_value(payload, entity.factor * (uint16_t)entity.value, 2)); } break; case DeviceValueType::ULONG: case DeviceValueType::TIME: if (entity.value != EMS_VALUE_ULONG_NOTSET) { output[name] = serialized(Helpers::render_value(payload, entity.factor * entity.value, 2)); } break; case DeviceValueType::STRING: if (entity.data.length() > 0) { output[name] = entity.data; } break; default: // EMSESP::logger().warning("unknown value type"); break; } } // process json output for info/commands and value_info bool WebCustomEntityService::get_value_info(JsonObject & output, const char * cmd) { EMSESP::webCustomEntityService.read([&](WebCustomEntity & webEntity) { customEntityItems = &webEntity.customEntityItems; }); // if no entries, return a message instead of an error // https://github.com/emsesp/EMS-ESP32/issues/1297 if (customEntityItems->size() == 0) { output["message"] = "no entries"; return true; } if (Helpers::toLower(cmd) == "commands") { output["info"] = "list all values"; output["commands"] = "list all commands"; for (const auto & entity : *customEntityItems) { output[entity.name] = "custom entitiy"; } return true; } if (strlen(cmd) == 0 || Helpers::toLower(cmd) == "values" || Helpers::toLower(cmd) == "info") { // list all names for (const CustomEntityItem & entity : *customEntityItems) { 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 : *customEntityItems) { if (Helpers::toLower(entity.name) == Helpers::toLower(command_s)) { output["name"] = entity.name; if (entity.uom > 0) { output["uom"] = EMSdevice::uom_to_string(entity.uom); } output["type"] = entity.value_type == DeviceValueType::BOOL ? "boolean" : entity.value_type == DeviceValueType::STRING ? "string" : F_(number); output["readable"] = true; output["writeable"] = entity.writeable; output["visible"] = true; output["device_id"] = Helpers::hextoa(entity.device_id); output["type_id"] = Helpers::hextoa(entity.type_id); output["offset"] = entity.offset; if (entity.value_type != DeviceValueType::BOOL && entity.value_type != DeviceValueType::STRING) { output["factor"] = entity.factor; } else if (entity.value_type == DeviceValueType::STRING) { output["bytes"] = (uint8_t)entity.factor; } 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 WebCustomEntityService::publish_single(const CustomEntityItem & 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 doc; JsonObject output = doc.to(); render_value(output, entity, true); Mqtt::queue_publish(topic, output["value"].as()); } // publish to Mqtt void WebCustomEntityService::publish(const bool force) { if (force) { ha_registered_ = false; } if (!Mqtt::enabled()) { return; } EMSESP::webCustomEntityService.read([&](WebCustomEntity & webEntity) { customEntityItems = &webEntity.customEntityItems; }); if (customEntityItems->size() == 0) { return; } if (Mqtt::publish_single() && force) { for (const CustomEntityItem & entityItem : *customEntityItems) { publish_single(entityItem); } } DynamicJsonDocument doc(EMSESP_JSON_SIZE_XLARGE); JsonObject output = doc.to(); bool ha_created = ha_registered_; for (const CustomEntityItem & entityItem : *customEntityItems) { 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::basename().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]; if (entityItem.writeable) { if (entityItem.value_type == DeviceValueType::BOOL) { snprintf(topic, sizeof(topic), "switch/%s/custom_%s/config", Mqtt::basename().c_str(), entityItem.name.c_str()); } else if (entityItem.value_type == DeviceValueType::STRING) { snprintf(topic, sizeof(topic), "sensor/%s/custom_%s/config", Mqtt::basename().c_str(), entityItem.name.c_str()); } else if (Mqtt::discovery_type() == Mqtt::discoveryType::HOMEASSISTANT) { snprintf(topic, sizeof(topic), "number/%s/custom_%s/config", Mqtt::basename().c_str(), entityItem.name.c_str()); } else { 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; } else { if (entityItem.value_type == DeviceValueType::BOOL) { snprintf(topic, sizeof(topic), "binary_sensor/%s/custom_%s/config", Mqtt::basename().c_str(), entityItem.name.c_str()); } else { snprintf(topic, sizeof(topic), "sensor/%s/custom_%s/config", Mqtt::basename().c_str(), entityItem.name.c_str()); } } if (entityItem.value_type == DeviceValueType::BOOL) { // applies to both Binary Sensor (read only) and a Switch (for a command) if (EMSESP::system_.bool_format() == BOOL_FORMAT_TRUEFALSE) { config["pl_on"] = true; config["pl_off"] = false; } else if (EMSESP::system_.bool_format() == BOOL_FORMAT_10) { config["pl_on"] = 1; config["pl_off"] = 0; } else { char result[12]; config["pl_on"] = Helpers::render_boolean(result, true); config["pl_off"] = Helpers::render_boolean(result, false); } } JsonObject dev = config.createNestedObject("dev"); JsonArray ids = dev.createNestedArray("ids"); ids.add(Mqtt::basename()); // add "availability" section Mqtt::add_avty_to_doc(stat_t, config.as(), val_cond); ha_created |= Mqtt::queue_ha(topic, config.as()); } } ha_registered_ = ha_created; 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 or command to show in dashboard uint8_t WebCustomEntityService::count_entities() { EMSESP::webCustomEntityService.read([&](WebCustomEntity & webEntity) { customEntityItems = &webEntity.customEntityItems; }); if (customEntityItems->size() == 0) { return 0; } DynamicJsonDocument doc(EMSESP_JSON_SIZE_XLARGE); JsonObject output = doc.to(); uint8_t count = 0; for (const CustomEntityItem & entity : *customEntityItems) { render_value(output, entity); count += (output.containsKey(entity.name) || entity.writeable) ? 1 : 0; } return count; } uint8_t WebCustomEntityService::has_commands() { EMSESP::webCustomEntityService.read([&](WebCustomEntity & webEntity) { customEntityItems = &webEntity.customEntityItems; }); uint8_t count = 0; for (const CustomEntityItem & entity : *customEntityItems) { count += entity.writeable ? 1 : 0; } return count; } // send to dashboard, msgpack don't like serialized, use number void WebCustomEntityService::generate_value_web(JsonObject & output) { EMSESP::webCustomEntityService.read([&](WebCustomEntity & webEntity) { customEntityItems = &webEntity.customEntityItems; }); output["label"] = (std::string) "Custom Entities"; JsonArray data = output.createNestedArray("data"); uint8_t index = 0; for (const CustomEntityItem & entity : *customEntityItems) { JsonObject obj = data.createNestedObject(); // create the object, we know there is a value obj["id"] = "00" + entity.name; obj["u"] = entity.uom; if (entity.writeable) { obj["c"] = entity.name; if (entity.value_type != DeviceValueType::BOOL && entity.value_type != DeviceValueType::STRING) { char s[10]; obj["s"] = Helpers::render_value(s, entity.factor, 1); } } switch (entity.value_type) { case DeviceValueType::BOOL: { char s[12]; obj["v"] = Helpers::render_boolean(s, (uint8_t)entity.value, true); 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.value != EMS_VALUE_INT_NOTSET) { obj["v"] = Helpers::transformNumFloat(entity.factor * (int8_t)entity.value, 0); } break; case DeviceValueType::UINT: if ((uint8_t)entity.value != EMS_VALUE_UINT_NOTSET) { obj["v"] = Helpers::transformNumFloat(entity.factor * (uint8_t)entity.value, 0); } break; case DeviceValueType::SHORT: if ((int16_t)entity.value != EMS_VALUE_SHORT_NOTSET) { obj["v"] = Helpers::transformNumFloat(entity.factor * (int16_t)entity.value, 0); } break; case DeviceValueType::USHORT: if ((uint16_t)entity.value != EMS_VALUE_USHORT_NOTSET) { obj["v"] = Helpers::transformNumFloat(entity.factor * (uint16_t)entity.value, 0); } break; case DeviceValueType::ULONG: case DeviceValueType::TIME: if (entity.value != EMS_VALUE_ULONG_NOTSET) { obj["v"] = Helpers::transformNumFloat(entity.factor * entity.value, 0); } break; case DeviceValueType::STRING: if (entity.data.length() > 0) { obj["v"] = entity.data; } break; default: break; } // show only entities with value or command if (!obj.containsKey("v") && !obj.containsKey("c")) { data.remove(index); } else { index++; } } } // fetch telegram, called from emsesp::fetch void WebCustomEntityService::fetch() { EMSESP::webCustomEntityService.read([&](WebCustomEntity & webEntity) { customEntityItems = &webEntity.customEntityItems; }); const uint8_t len[] = {1, 1, 1, 2, 2, 3, 3}; for (auto & entity : *customEntityItems) { EMSESP::send_read_request(entity.type_id, entity.device_id, entity.offset, entity.value_type == DeviceValueType::STRING ? (uint8_t)entity.factor : len[entity.value_type]); } // EMSESP::logger().debug("fetch custom entities"); } // called on process telegram, read from telegram bool WebCustomEntityService::get_value(std::shared_ptr telegram) { bool has_change = false; EMSESP::webCustomEntityService.read([&](WebCustomEntity & webEntity) { customEntityItems = &webEntity.customEntityItems; }); // read-length of BOOL, INT, UINT, SHORT, USHORT, ULONG, TIME const uint8_t len[] = {1, 1, 1, 2, 2, 3, 3}; for (auto & entity : *customEntityItems) { if (entity.value_type == DeviceValueType::STRING && telegram->type_id == entity.type_id && telegram->src == entity.device_id && telegram->offset == entity.offset) { auto data = Helpers::data_to_hex(telegram->message_data, telegram->message_length); if (entity.data != data) { entity.data = data; if (Mqtt::publish_single()) { publish_single(entity); } else if (EMSESP::mqtt_.get_publish_onchange(0)) { has_change = true; } } } if (entity.value_type != DeviceValueType::STRING && telegram->type_id == entity.type_id && telegram->src == entity.device_id && telegram->offset <= entity.offset && (telegram->offset + telegram->message_length) >= (entity.offset + len[entity.value_type])) { uint32_t value = 0; for (uint8_t i = 0; i < len[entity.value_type]; i++) { value = (value << 8) + telegram->message_data[i + entity.offset - telegram->offset]; } if (value != entity.value) { entity.value = value; 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); } } if (has_change) { publish(); return true; } return false; } } // namespace emsesp