From 1db8e48acfcad51dda2b956f12d92e95421dcbde Mon Sep 17 00:00:00 2001 From: Tomer27cz Date: Sat, 7 Mar 2026 13:06:16 +0100 Subject: [PATCH] new dlms_push component dlms_push is a complete rewrite of the component using common esphome structure. The new component no longer uses Gurux library so it is significantly smaller and easier to maintain. Update README (EN/CZ) to document dlms_push, replace xt211 examples, and expose new config options (show_log, receive_timeout, custom_pattern). Also update esphome-smartmeter.yaml accordingly. --- README.cz.md | 40 +- README.md | 29 +- components/dlms_push/__init__.py | 46 ++ components/dlms_push/binary_sensor.py | 21 + components/dlms_push/dlms_parser.cpp | 683 ++++++++++++++++++++++++++ components/dlms_push/dlms_parser.h | 133 +++++ components/dlms_push/dlms_push.cpp | 182 +++++++ components/dlms_push/dlms_push.h | 91 ++++ components/dlms_push/sensor.py | 26 + components/dlms_push/text_sensor.py | 21 + esphome-smartmeter.yaml | 114 +++-- 11 files changed, 1313 insertions(+), 73 deletions(-) create mode 100644 components/dlms_push/__init__.py create mode 100644 components/dlms_push/binary_sensor.py create mode 100644 components/dlms_push/dlms_parser.cpp create mode 100644 components/dlms_push/dlms_parser.h create mode 100644 components/dlms_push/dlms_push.cpp create mode 100644 components/dlms_push/dlms_push.h create mode 100644 components/dlms_push/sensor.py create mode 100644 components/dlms_push/text_sensor.py diff --git a/README.cz.md b/README.cz.md index cde5e45..d4f9e59 100644 --- a/README.cz.md +++ b/README.cz.md @@ -138,11 +138,11 @@ Power Led (volitelné): # Custom ESPHome komponenta -Pro čtení PUSH zpráv DLMS/Cosem z XT211 jsem upravil existující projekt [esphome-dlms-cosem](https://github.com/latonita/esphome-dlms-cosem). +Existují dvě verze komponenty v tomto repozitáři: +- `xt211` - původní verze, kterou jsem použil pro svůj setup, založená na [esphome-dlms-cosem](https://github.com/latonita/esphome-dlms-cosem)) +- `dlms_push` - napsaná od začátku (už nepoužívá Gurux knihovny) se strukturou esphome komponentů, je flexibilnější a snazší na údržbu. -Odstranil jsem polling, opravil pár chyb a přidal podporu pro binární senzory. - -Více detailů v projektu [esphome-dlms-cosem repository](https://github.com/latonita/esphome-dlms-cosem) od [latonita](https://github.com/latonita) +- Používám a budu udržovat verzi `dlms_push`, ale verze `xt211` je stále k dispozici pro referenci a pro ty, kteří ji chtějí použít tak, jak je. ## ESPHome konfigurace @@ -160,13 +160,24 @@ Přidej externí komponentu: ```yaml external_components: - source: github://Tomer27cz/xt211 - components: [xt211] + components: [dlms_push] refresh: 1s ``` -Poté nastav DLMS/Cosem komponentu: -- push_show_log: true (volitelné, bude zobrazovat surové PUSH zprávy v logu pro debug a testování) -Až to bude fungovat, logy vypni. +[//]: # (The configuration options are:) + +[//]: # (- `show_log` (optional, default: `false`) - whether to show the log of the DLMS/COSEM communication. This is useful for debugging and first setup, but it can be quite verbose.) + +[//]: # (- `receive_timeout` (optional, default: `50ms`) - the timeout for receiving data from the meter. If the meter does not send any data within this time, the communication is considered finished and it will be processed.) + +[//]: # (- `custom_pattern` (optional) - custom COSEM object pattern) + +Konfigurační možnosti jsou: +- `show_log` (volitelné, výchozí: `false`) - zda zobrazit log komunikace DLMS/COSEM. To je užitečné pro ladění a první nastavení, ale může být docela obsáhlé. +- `receive_timeout` (volitelné, výchozí: `50ms`) - časový limit pro přijímání dat z elektroměru. Pokud elektroměr během této doby nepošle žádná data, komunikace se považuje za ukončenou a bude zpracována. +- `custom_pattern` (volitelné) - vlastní vzor COSEM objektů + +Až bude komponenta fungovat, můžeš nastavit `show_log` na `false`, aby se logy přestaly zobrazovat. ```yaml uart: @@ -178,8 +189,9 @@ uart: parity: NONE stop_bits: 1 -xt211: - push_show_log: true +dlms_push: + id: my_dlms_meter + uart_id: bus_1 ``` ### Number sensor (`sensor`) @@ -187,7 +199,7 @@ Moje spotřeba elektřiny se měří v kWh, ale elektroměr odesílá hodnotu ve ```yaml sensor: - - platform: xt211 + - platform: dlms_push id: active_energy_consumed name: "Energy" obis_code: 1.0.1.8.0.255 @@ -204,7 +216,7 @@ Binární senzor má hodnotu `false`, pokud je hodnota 0, a hodnotu `true`, poku ```yaml binary_sensor: - - platform: xt211 + - platform: dlms_push name: "Relay 1" obis_code: 0.1.96.3.10.255 ``` @@ -213,7 +225,7 @@ binary_sensor: ```yaml text_sensor: - - platform: xt211 + - platform: dlms_push name: "Serial number" obis_code: 0.0.96.1.1.255 entity_category: diagnostic @@ -301,7 +313,7 @@ sensor: [... pulzní měřič z výše uvedeného configu ...] - - platform: xt211 + - platform: dlms_push id: active_power name: "Active power consumption" obis_code: 1.0.1.7.0.255 diff --git a/README.md b/README.md index 1e8df4b..0a78931 100644 --- a/README.md +++ b/README.md @@ -140,11 +140,11 @@ Power Led (optional): # Custom ESPHome component -To read out the DLMS/Cosem PUSH messages from the Sagecom XT211 meter, I modified the existing [esphome-dlms-cosem](https://github.com/latonita/esphome-dlms-cosem). +There are two versions in this repository: +- `xt211` - the original version that I used for my setup, which is based on the [esphome-dlms-cosem](https://github.com/latonita/esphome-dlms-cosem) +- `dlms_push` - written from scratch (no longer using Gurux Libraries) using common esphome component structure, it is more flexible and easier to maintain. -Removed the polling functionality, fixed some bugs, and added support for binary sensors. - -For a more detailed description of the component, see the [esphome-dlms-cosem repository](https://github.com/latonita/esphome-dlms-cosem) from [latonita](https://github.com/latonita) +I will be using and maintaining the `dlms_push` version, but the `xt211` version is still available for reference and for those who want to use it as is. ## ESPHome configuration @@ -164,11 +164,13 @@ Add the external component to your ESPHome configuration: ```yaml external_components: - source: github://Tomer27cz/xt211 - components: [xt211] + components: [dlms_push] refresh: 1s ``` -Then configure the DLMS/Cosem component: -- push_show_log: true (optional, for debugging purposes - shows all received PUSH messages in the log) +The configuration options are: +- `show_log` (optional, default: `false`) - whether to show the log of the DLMS/COSEM communication. This is useful for debugging and first setup, but it can be quite verbose. +- `receive_timeout` (optional, default: `50ms`) - the timeout for receiving data from the meter. If the meter does not send any data within this time, the communication is considered finished and it will be processed. +- `custom_pattern` (optional) - custom COSEM object pattern Disable the log onece everything is working fine. @@ -182,8 +184,9 @@ uart: parity: NONE stop_bits: 1 -xt211: - push_show_log: true +dlms_push: + id: my_dlms_meter + uart_id: bus_1 ``` ### Number sensor (`sensor`) @@ -191,7 +194,7 @@ My electricity consumption is measured in kWh, but the meter sends the value in ```yaml sensor: - - platform: xt211 + - platform: dlms_push id: active_energy_consumed name: "Energy" obis_code: 1.0.1.8.0.255 @@ -208,7 +211,7 @@ The binary sensor is `false` when the value is 0, and `true` when the value is a ```yaml binary_sensor: - - platform: xt211 + - platform: dlms_push name: "Relay 1" obis_code: 0.1.96.3.10.255 ``` @@ -218,7 +221,7 @@ The text sensor is used to display string values sent by the meter. ```yaml text_sensor: - - platform: xt211 + - platform: dlms_push name: "Serial number" obis_code: 0.0.96.1.1.255 entity_category: diagnostic @@ -306,7 +309,7 @@ sensor: [... pulse meter from above ...] - - platform: xt211 + - platform: dlms_push id: active_power name: "Active power consumption" obis_code: 1.0.1.7.0.255 diff --git a/components/dlms_push/__init__.py b/components/dlms_push/__init__.py new file mode 100644 index 0000000..e07e924 --- /dev/null +++ b/components/dlms_push/__init__.py @@ -0,0 +1,46 @@ +import re +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import uart +from esphome.const import CONF_ID + +DEPENDENCIES = ["uart"] + +CONF_RECEIVE_TIMEOUT = "receive_timeout" +CONF_SHOW_LOG = "show_log" +CONF_CUSTOM_PATTERN = "custom_pattern" + +# Define the namespace and the Hub component class +dlms_push_ns = cg.esphome_ns.namespace("dlms_push") +DlmsPushComponent = dlms_push_ns.class_("DlmsPushComponent", cg.Component, uart.UARTDevice) + +def obis_code(value): + value = cv.string(value) + # Validate standard OBIS format: A.B.C.D.E.F (e.g., 1.0.1.8.0.255) + match = re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", value) + if match is None: + raise cv.Invalid(f"{value} is not a valid OBIS code (expected format: A.B.C.D.E.F)") + return value + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(DlmsPushComponent), + cv.Optional(CONF_RECEIVE_TIMEOUT, default="50ms"): cv.positive_time_period_milliseconds, + cv.Optional(CONF_SHOW_LOG, default=False): cv.boolean, + cv.Optional(CONF_CUSTOM_PATTERN, default=""): cv.string, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + # Apply configuration to the C++ component + cg.add(var.set_receive_timeout(config[CONF_RECEIVE_TIMEOUT])) + cg.add(var.set_show_log(config[CONF_SHOW_LOG])) + cg.add(var.set_custom_pattern(config[CONF_CUSTOM_PATTERN])) \ No newline at end of file diff --git a/components/dlms_push/binary_sensor.py b/components/dlms_push/binary_sensor.py new file mode 100644 index 0000000..d157986 --- /dev/null +++ b/components/dlms_push/binary_sensor.py @@ -0,0 +1,21 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import binary_sensor +from . import DlmsPushComponent, obis_code + +DEPENDENCIES = ["dlms_push"] + +CONF_DLMS_PUSH_ID = "dlms_push_id" +CONF_OBIS_CODE = "obis_code" + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_DLMS_PUSH_ID): cv.use_id(DlmsPushComponent), + cv.Required(CONF_OBIS_CODE): obis_code, + } +) + +async def to_code(config): + hub = await cg.get_variable(config[CONF_DLMS_PUSH_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var)) \ No newline at end of file diff --git a/components/dlms_push/dlms_parser.cpp b/components/dlms_push/dlms_parser.cpp new file mode 100644 index 0000000..fa7b2cd --- /dev/null +++ b/components/dlms_push/dlms_parser.cpp @@ -0,0 +1,683 @@ +#include "dlms_parser.h" + +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +#include +#include +#include +#include +#include + +namespace esphome { +namespace dlms_push { + +static const char *const TAG = "dlms_parser"; + +DlmsParser::DlmsParser() { + this->load_default_patterns_(); +} + +void DlmsParser::load_default_patterns_() { + this->register_pattern_dsl_("T1", "TC,TO,TS,TV", 10); + this->register_pattern_dsl_("T2", "TO,TV,TSU", 10); + this->register_pattern_dsl_("T3", "TV,TC,TSU,TO", 10); + this->register_pattern_dsl_("U.ZPA", "F,C,O,A,TV", 10); +} + +void DlmsParser::register_custom_pattern(const std::string &dsl) { + this->register_pattern_dsl_("CUSTOM", dsl, 0); // Priority 0 to try this first +} + +size_t DlmsParser::parse(const uint8_t *buffer, size_t length, DlmsDataCallback callback, bool show_log) { + if (buffer == nullptr || length == 0) { + if (show_log) ESP_LOGV(TAG, "Buffer is null or empty"); + return 0; + } + + this->buffer_ = buffer; + this->buffer_len_ = length; + this->pos_ = 0; + this->callback_ = callback; + this->show_log_ = show_log; + this->objects_found_ = 0; + + if (this->show_log_) ESP_LOGD(TAG, "Starting to parse buffer of length %zu", length); + + // Skip to notification flag 0x0F + while (this->pos_ < this->buffer_len_) { + if (this->read_byte_() == 0x0F) { + if (this->show_log_) ESP_LOGD(TAG, "Found notification flag 0x0F at position %zu", this->pos_ - 1); + break; + } + } + + // Strictly skip Invoke-ID/Priority (5 bytes) + for (int i = 0; i < 5 && this->pos_ < this->buffer_len_; i++) { + this->pos_++; + } + + // Check for datetime object before the data and skip it if present + if (this->test_if_date_time_12b_()) { + if (this->show_log_) ESP_LOGV(TAG, "Skipping datetime object at position %zu", this->pos_); + this->pos_ += 12; + } + + // First byte after flag should be the data type (usually Structure or Array) + uint8_t start_type = this->read_byte_(); + if (start_type != DLMS_DATA_TYPE_STRUCTURE && start_type != DLMS_DATA_TYPE_ARRAY) { + if (this->show_log_) ESP_LOGW(TAG, "Expected STRUCTURE or ARRAY after header, found type %02X at position %zu", start_type, this->pos_ - 1); + return 0; + } + + // Trigger recursive parsing + bool success = this->parse_element_(start_type, 0); + if (!success && this->show_log_) { + ESP_LOGV(TAG, "Some errors occurred parsing DLMS data, or unexpected end of buffer."); + } + + if (this->show_log_) ESP_LOGD(TAG, "Parsing completed. Processed %zu bytes, found %zu objects", this->pos_, this->objects_found_); + return this->objects_found_; +} + +uint8_t DlmsParser::read_byte_() { + if (this->pos_ >= this->buffer_len_) return 0xFF; + return this->buffer_[this->pos_++]; +} + +uint16_t DlmsParser::read_u16_() { + if (this->pos_ + 1 >= this->buffer_len_) return 0xFFFF; + uint16_t val = (this->buffer_[this->pos_] << 8) | this->buffer_[this->pos_ + 1]; + this->pos_ += 2; + return val; +} + +uint32_t DlmsParser::read_u32_() { + if (this->pos_ + 3 >= this->buffer_len_) return 0xFFFFFFFF; + uint32_t val = (this->buffer_[this->pos_] << 24) | (this->buffer_[this->pos_ + 1] << 16) | + (this->buffer_[this->pos_ + 2] << 8) | this->buffer_[this->pos_ + 3]; + this->pos_ += 4; + return val; +} + +bool DlmsParser::test_if_date_time_12b_() { + if (this->pos_ + 12 > this->buffer_len_) return false; + const uint8_t *buf = &this->buffer_[this->pos_]; + + uint16_t year = (buf[0] << 8) | buf[1]; + if (!(year == 0x0000 || (year >= 1970 && year <= 2100))) return false; + if (!(buf[2] == 0xFF || (buf[2] >= 1 && buf[2] <= 12))) return false; + if (!(buf[3] == 0xFF || (buf[3] >= 1 && buf[3] <= 31))) return false; + if (!(buf[4] == 0xFF || (buf[4] >= 1 && buf[4] <= 7))) return false; + if (!(buf[5] == 0xFF || buf[5] <= 23)) return false; + if (!(buf[6] == 0xFF || buf[6] <= 59)) return false; + if (!(buf[7] == 0xFF || buf[7] <= 59)) return false; + + // Hundredths of second + uint8_t ms = buf[8]; + if (!(ms == 0xFF || ms <= 99)) return false; + + // Deviation (timezone offset, signed, 2 bytes) + uint16_t u_dev = (buf[9] << 8) | buf[10]; + int16_t s_dev = (int16_t) u_dev; + if (!((s_dev == (int16_t) 0x8000 || (s_dev >= -720 && s_dev <= 720)))) return false; + + return true; +} + +int DlmsParser::get_data_type_size_(DlmsDataType type) { + switch (type) { + case DLMS_DATA_TYPE_NONE: return 0; + case DLMS_DATA_TYPE_BOOLEAN: + case DLMS_DATA_TYPE_INT8: + case DLMS_DATA_TYPE_UINT8: + case DLMS_DATA_TYPE_ENUM: return 1; + case DLMS_DATA_TYPE_INT16: + case DLMS_DATA_TYPE_UINT16: return 2; + case DLMS_DATA_TYPE_INT32: + case DLMS_DATA_TYPE_UINT32: + case DLMS_DATA_TYPE_FLOAT32: return 4; + case DLMS_DATA_TYPE_INT64: + case DLMS_DATA_TYPE_UINT64: + case DLMS_DATA_TYPE_FLOAT64: return 8; + case DLMS_DATA_TYPE_DATETIME: return 12; + case DLMS_DATA_TYPE_DATE: return 5; + case DLMS_DATA_TYPE_TIME: return 4; + default: return -1; // Variable or complex + } +} + +bool DlmsParser::is_value_data_type_(DlmsDataType type) { + switch (type) { + case DLMS_DATA_TYPE_ARRAY: + case DLMS_DATA_TYPE_STRUCTURE: + case DLMS_DATA_TYPE_COMPACT_ARRAY: + return false; + case DLMS_DATA_TYPE_NONE: + case DLMS_DATA_TYPE_BOOLEAN: + case DLMS_DATA_TYPE_BIT_STRING: + case DLMS_DATA_TYPE_INT32: + case DLMS_DATA_TYPE_UINT32: + case DLMS_DATA_TYPE_OCTET_STRING: + case DLMS_DATA_TYPE_STRING: + case DLMS_DATA_TYPE_BINARY_CODED_DESIMAL: + case DLMS_DATA_TYPE_STRING_UTF8: + case DLMS_DATA_TYPE_INT8: + case DLMS_DATA_TYPE_INT16: + case DLMS_DATA_TYPE_UINT8: + case DLMS_DATA_TYPE_UINT16: + case DLMS_DATA_TYPE_INT64: + case DLMS_DATA_TYPE_UINT64: + case DLMS_DATA_TYPE_ENUM: + case DLMS_DATA_TYPE_FLOAT32: + case DLMS_DATA_TYPE_FLOAT64: + case DLMS_DATA_TYPE_DATETIME: + case DLMS_DATA_TYPE_DATE: + case DLMS_DATA_TYPE_TIME: + return true; + default: + return false; + } +} + +bool DlmsParser::skip_data_(uint8_t type) { + int data_size = this->get_data_type_size_((DlmsDataType)type); + + if (data_size == 0) return true; + if (data_size > 0) { + if (this->pos_ + data_size > this->buffer_len_) return false; + this->pos_ += data_size; + } else { + uint8_t first_byte = this->read_byte_(); + if (first_byte == 0xFF) return false; + + uint32_t length = first_byte; + // Handle DLMS multi-byte length fields + if (first_byte > 127) { + uint8_t num_bytes = first_byte & 0x7F; + length = 0; + for (int i = 0; i < num_bytes; i++) { + uint8_t b = this->read_byte_(); + if (b == 0xFF && this->pos_ >= this->buffer_len_) return false; + length = (length << 8) | b; + } + } + + uint32_t skip_bytes = length; + // DLMS Bit strings designate their length in bits, so we must adjust the byte skip + if (type == DLMS_DATA_TYPE_BIT_STRING) { + skip_bytes = (length + 7) / 8; + } + + if (this->pos_ + skip_bytes > this->buffer_len_) return false; + + if (this->show_log_) { + ESP_LOGVV(TAG, "Skipping variable data of type %s (bytes: %u) at position %zu", + this->dlms_data_type_to_string_((DlmsDataType)type), skip_bytes, this->pos_); + } + this->pos_ += skip_bytes; + } + return true; +} + +bool DlmsParser::parse_element_(uint8_t type, uint8_t depth) { + if (type == DLMS_DATA_TYPE_STRUCTURE || type == DLMS_DATA_TYPE_ARRAY) { + return this->parse_sequence_(type, depth); + } + return this->skip_data_(type); +} + +bool DlmsParser::parse_sequence_(uint8_t type, uint8_t depth) { + uint8_t elements_count = this->read_byte_(); + if (elements_count == 0xFF) { + if (this->show_log_) ESP_LOGVV(TAG, "Invalid sequence length at position %zu", this->pos_ - 1); + return false; + } + + if (this->show_log_) { + ESP_LOGD(TAG, "Parsing %s with %d elements at position %zu (depth %d)", + type == DLMS_DATA_TYPE_STRUCTURE ? "STRUCTURE" : "ARRAY", elements_count, this->pos_ - 1, depth); + } + + uint8_t elements_consumed = 0; + while (elements_consumed < elements_count) { + size_t original_position = this->pos_; + + if (this->try_match_patterns_(elements_consumed)) { + elements_consumed += this->last_pattern_elements_consumed_ ? this->last_pattern_elements_consumed_ : 1; + this->last_pattern_elements_consumed_ = 0; + continue; + } + + if (this->pos_ >= this->buffer_len_) { + if (this->show_log_) ESP_LOGV(TAG, "Unexpected end while reading element %d of %s", elements_consumed + 1, type == DLMS_DATA_TYPE_STRUCTURE ? "STRUCTURE" : "ARRAY"); + return false; + } + + uint8_t elem_type = this->read_byte_(); + if (!this->parse_element_(elem_type, depth + 1)) return false; + elements_consumed++; + + if (this->pos_ == original_position) { + if (this->show_log_) ESP_LOGV(TAG, "No progress parsing element %d at position %zu, aborting to avoid infinite loop", elements_consumed, original_position); + return false; + } + } + return true; +} + +bool DlmsParser::capture_generic_value_(AxdrCaptures &c) { + uint8_t vt = this->read_byte_(); + if (!this->is_value_data_type_((DlmsDataType)vt)) return false; + + int ds = this->get_data_type_size_((DlmsDataType)vt); + if (ds > 0) { + if (this->pos_ + ds > this->buffer_len_) return false; + c.value_ptr = &this->buffer_[this->pos_]; + c.value_len = ds; + this->pos_ += ds; + } else if (ds == 0) { + c.value_ptr = nullptr; + c.value_len = 0; + } else { + uint8_t first_byte = this->read_byte_(); + if (first_byte == 0xFF) return false; + + uint32_t length = first_byte; + if (first_byte > 127) { + uint8_t num_bytes = first_byte & 0x7F; + length = 0; + for (int i = 0; i < num_bytes; i++) { + uint8_t b = this->read_byte_(); + if (b == 0xFF && this->pos_ >= this->buffer_len_) return false; + length = (length << 8) | b; + } + } + + uint32_t data_bytes = length; + if (vt == DLMS_DATA_TYPE_BIT_STRING) { + data_bytes = (length + 7) / 8; + } + + if (this->pos_ + data_bytes > this->buffer_len_) return false; + c.value_ptr = &this->buffer_[this->pos_]; + c.value_len = data_bytes > 255 ? 255 : data_bytes; + this->pos_ += data_bytes; + } + c.value_type = (DlmsDataType)vt; + return true; +} + +bool DlmsParser::try_match_patterns_(uint8_t elem_idx) { + for (const auto &p : this->patterns_) { + uint8_t consumed = 0; + size_t saved_position = this->pos_; + if (this->match_pattern_(elem_idx, p, consumed)) { + this->last_pattern_elements_consumed_ = consumed; + return true; + } + this->pos_ = saved_position; // Backtrack if match failed + } + return false; +} + +bool DlmsParser::match_pattern_(uint8_t elem_idx, const AxdrDescriptorPattern &pat, uint8_t &elements_consumed_at_level0) { + AxdrCaptures cap{}; + elements_consumed_at_level0 = 0; + uint8_t level = 0; + auto consume_one = [&]() { if (level == 0) elements_consumed_at_level0++; }; + size_t initial_position = this->pos_; + + for (const auto &step : pat.steps) { + switch (step.type) { + case AxdrTokenType::EXPECT_TO_BE_FIRST: + if (elem_idx != 0) return false; + break; + case AxdrTokenType::EXPECT_TYPE_EXACT: + if (this->read_byte_() != step.param_u8_a) return false; + consume_one(); + break; + case AxdrTokenType::EXPECT_TYPE_U_I_8: { + uint8_t t = this->read_byte_(); + if (t != DLMS_DATA_TYPE_INT8 && t != DLMS_DATA_TYPE_UINT8) return false; + consume_one(); + break; + } + case AxdrTokenType::EXPECT_CLASS_ID_UNTAGGED: { + uint16_t v = this->read_u16_(); + if (v > 0x00FF) return false; // Match max typical class ID + cap.class_id = v; + break; + } + case AxdrTokenType::EXPECT_OBIS6_TAGGED: + if (this->read_byte_() != DLMS_DATA_TYPE_OCTET_STRING) return false; + if (this->read_byte_() != 6) return false; + if (this->pos_ + 6 > this->buffer_len_) return false; + cap.obis = &this->buffer_[this->pos_]; + this->pos_ += 6; + consume_one(); + break; + case AxdrTokenType::EXPECT_OBIS6_UNTAGGED: + if (this->pos_ + 6 > this->buffer_len_) return false; + cap.obis = &this->buffer_[this->pos_]; + this->pos_ += 6; + break; + case AxdrTokenType::EXPECT_ATTR8_UNTAGGED: + if (this->read_byte_() == 0) return false; + break; + case AxdrTokenType::EXPECT_VALUE_GENERIC: + if (!this->capture_generic_value_(cap)) return false; + consume_one(); + break; + case AxdrTokenType::EXPECT_STRUCTURE_N: + if (this->read_byte_() != DLMS_DATA_TYPE_STRUCTURE) return false; + if (this->read_byte_() != step.param_u8_a) return false; + consume_one(); + break; + case AxdrTokenType::EXPECT_SCALER_TAGGED: + if (this->read_byte_() != DLMS_DATA_TYPE_INT8) return false; + cap.scaler = (int8_t)this->read_byte_(); + cap.has_scaler_unit = true; + consume_one(); + break; + case AxdrTokenType::EXPECT_UNIT_ENUM_TAGGED: + if (this->read_byte_() != DLMS_DATA_TYPE_ENUM) return false; + cap.unit_enum = this->read_byte_(); + cap.has_scaler_unit = true; + consume_one(); + break; + case AxdrTokenType::GOING_DOWN: level++; break; + case AxdrTokenType::GOING_UP: level--; break; + } + } + + if (elements_consumed_at_level0 == 0) elements_consumed_at_level0 = 1; + cap.elem_idx = initial_position; + this->emit_object_(pat, cap); + return true; +} + +void DlmsParser::emit_object_(const AxdrDescriptorPattern &pat, const AxdrCaptures &c) { + if (!c.obis || !this->callback_) return; + + std::string obis_str = this->obis_to_string_(c.obis); + float raw_val_f = this->data_as_float_(c.value_type, c.value_ptr, c.value_len); + float val_f = raw_val_f; + std::string val_s = this->data_as_string_(c.value_type, c.value_ptr, c.value_len); + + bool is_numeric = (c.value_type != DLMS_DATA_TYPE_OCTET_STRING && + c.value_type != DLMS_DATA_TYPE_STRING && + c.value_type != DLMS_DATA_TYPE_STRING_UTF8); + + if (c.has_scaler_unit && is_numeric) { + val_f *= std::pow(10, c.scaler); + } + + if (this->show_log_) { + ESP_LOGD(TAG, "Pattern match '%s' at idx %u ===============", pat.name.c_str(), c.elem_idx); + uint16_t cid = c.class_id ? c.class_id : pat.default_class_id; + + ESP_LOGI(TAG, "Found attribute descriptor: class_id=%d, obis=%s", cid, obis_str.c_str()); + + if (c.has_scaler_unit) { + ESP_LOGI(TAG, "Value type: %s, len %d, scaler %d, unit %d", + this->dlms_data_type_to_string_(c.value_type), c.value_len, c.scaler, c.unit_enum); + } else { + ESP_LOGI(TAG, "Value type: %s, len %d", this->dlms_data_type_to_string_(c.value_type), c.value_len); + } + + if (c.value_ptr && c.value_len > 0) { + ESP_LOGI(TAG, " as hex dump : %s", esphome::format_hex_pretty(c.value_ptr, c.value_len).c_str()); + } + ESP_LOGI(TAG, " as string :'%s'", val_s.c_str()); + ESP_LOGI(TAG, " as number : %f", raw_val_f); + + if (c.has_scaler_unit && is_numeric) { + ESP_LOGI(TAG, " as number * scaler : %f", val_f); + } + } + + this->callback_(obis_str, val_f, val_s, is_numeric); + this->objects_found_++; +} + +float DlmsParser::data_as_float_(DlmsDataType value_type, const uint8_t *ptr, uint8_t len) { + if (!ptr || len == 0) return 0.0f; + + auto be16 = [](const uint8_t *p) { return (uint16_t)((p[0] << 8) | p[1]); }; + auto be32 = [](const uint8_t *p) { return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3]; }; + auto be64 = [](const uint8_t *p) { + return ((uint64_t)p[0] << 56) | ((uint64_t)p[1] << 48) | ((uint64_t)p[2] << 40) | ((uint64_t)p[3] << 32) | + ((uint64_t)p[4] << 24) | ((uint64_t)p[5] << 16) | ((uint64_t)p[6] << 8) | (uint64_t)p[7]; + }; + + switch (value_type) { + case DLMS_DATA_TYPE_BOOLEAN: + case DLMS_DATA_TYPE_ENUM: + case DLMS_DATA_TYPE_UINT8: return static_cast(ptr[0]); + case DLMS_DATA_TYPE_INT8: return static_cast(static_cast(ptr[0])); + case DLMS_DATA_TYPE_BIT_STRING: return (len > 0 && ptr) ? static_cast(ptr[0]) : 0.0f; + case DLMS_DATA_TYPE_UINT16: return len >= 2 ? static_cast(be16(ptr)) : 0.0f; + case DLMS_DATA_TYPE_INT16: return len >= 2 ? static_cast(static_cast(be16(ptr))) : 0.0f; + case DLMS_DATA_TYPE_UINT32: return len >= 4 ? static_cast(be32(ptr)) : 0.0f; + case DLMS_DATA_TYPE_INT32: return len >= 4 ? static_cast(static_cast(be32(ptr))) : 0.0f; + case DLMS_DATA_TYPE_UINT64: return len >= 8 ? static_cast(be64(ptr)) : 0.0f; + case DLMS_DATA_TYPE_INT64: return len >= 8 ? static_cast(static_cast(be64(ptr))) : 0.0f; + case DLMS_DATA_TYPE_FLOAT32: { + if (len < 4) return 0.0f; + uint32_t i32 = be32(ptr); + float f; + std::memcpy(&f, &i32, sizeof(float)); + return f; + } + case DLMS_DATA_TYPE_FLOAT64: { + if (len < 8) return 0.0f; + uint64_t i64 = be64(ptr); + double d; + std::memcpy(&d, &i64, sizeof(double)); + return static_cast(d); + } + default: return 0.0f; + } +} + +std::string DlmsParser::data_as_string_(DlmsDataType value_type, const uint8_t *ptr, uint8_t len) { + if (!ptr || len == 0) return ""; + + auto hex_of = [](const uint8_t *p, uint8_t l) { + std::ostringstream ss; + ss << std::hex << std::setfill('0'); + for (uint8_t i = 0; i < l; i++) { + ss << std::setw(2) << static_cast(p[i]); + } + return ss.str(); + }; + + auto be16 = [](const uint8_t *p) { return (uint16_t)((p[0] << 8) | p[1]); }; + auto be32 = [](const uint8_t *p) { return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | (uint32_t)p[3]; }; + auto be64 = [](const uint8_t *p) { + uint64_t v = 0; + for (int i = 0; i < 8; i++) v = (v << 8) | p[i]; + return v; + }; + + switch (value_type) { + case DLMS_DATA_TYPE_OCTET_STRING: + case DLMS_DATA_TYPE_STRING: + case DLMS_DATA_TYPE_STRING_UTF8: + return std::string(reinterpret_cast(ptr), len); + + case DLMS_DATA_TYPE_BIT_STRING: + case DLMS_DATA_TYPE_BINARY_CODED_DESIMAL: + case DLMS_DATA_TYPE_DATETIME: + case DLMS_DATA_TYPE_DATE: + case DLMS_DATA_TYPE_TIME: + return hex_of(ptr, len); + + case DLMS_DATA_TYPE_BOOLEAN: + case DLMS_DATA_TYPE_ENUM: + case DLMS_DATA_TYPE_UINT8: + return std::to_string(static_cast(ptr[0])); + + case DLMS_DATA_TYPE_INT8: + return std::to_string(static_cast(static_cast(ptr[0]))); + + case DLMS_DATA_TYPE_UINT16: + return len >= 2 ? std::to_string(be16(ptr)) : ""; + + case DLMS_DATA_TYPE_INT16: + return len >= 2 ? std::to_string(static_cast(be16(ptr))) : ""; + + case DLMS_DATA_TYPE_UINT32: + return len >= 4 ? std::to_string(be32(ptr)) : ""; + + case DLMS_DATA_TYPE_INT32: + return len >= 4 ? std::to_string(static_cast(be32(ptr))) : ""; + + case DLMS_DATA_TYPE_UINT64: + return len >= 8 ? std::to_string(be64(ptr)) : ""; + + case DLMS_DATA_TYPE_INT64: + return len >= 8 ? std::to_string(static_cast(be64(ptr))) : ""; + + case DLMS_DATA_TYPE_FLOAT32: + case DLMS_DATA_TYPE_FLOAT64: { + std::ostringstream ss; + ss << this->data_as_float_(value_type, ptr, len); + return ss.str(); + } + + default: + return ""; + } +} + +std::string DlmsParser::obis_to_string_(const uint8_t *obis) { + char buf[32]; + snprintf(buf, sizeof(buf), "%u.%u.%u.%u.%u.%u", obis[0], obis[1], obis[2], obis[3], obis[4], obis[5]); + return std::string(buf); +} + +const char *DlmsParser::dlms_data_type_to_string_(DlmsDataType vt) { + switch (vt) { + case DLMS_DATA_TYPE_NONE: return "NONE"; + case DLMS_DATA_TYPE_ARRAY: return "ARRAY"; + case DLMS_DATA_TYPE_STRUCTURE: return "STRUCTURE"; + case DLMS_DATA_TYPE_BOOLEAN: return "BOOLEAN"; + case DLMS_DATA_TYPE_BIT_STRING: return "BIT_STRING"; + case DLMS_DATA_TYPE_INT32: return "INT32"; + case DLMS_DATA_TYPE_UINT32: return "UINT32"; + case DLMS_DATA_TYPE_OCTET_STRING: return "OCTET_STRING"; + case DLMS_DATA_TYPE_STRING: return "STRING"; + case DLMS_DATA_TYPE_STRING_UTF8: return "STRING_UTF8"; + case DLMS_DATA_TYPE_BINARY_CODED_DESIMAL: return "BINARY_CODED_DESIMAL"; + case DLMS_DATA_TYPE_INT8: return "INT8"; + case DLMS_DATA_TYPE_INT16: return "INT16"; + case DLMS_DATA_TYPE_UINT8: return "UINT8"; + case DLMS_DATA_TYPE_UINT16: return "UINT16"; + case DLMS_DATA_TYPE_COMPACT_ARRAY: return "COMPACT_ARRAY"; + case DLMS_DATA_TYPE_INT64: return "INT64"; + case DLMS_DATA_TYPE_UINT64: return "UINT64"; + case DLMS_DATA_TYPE_ENUM: return "ENUM"; + case DLMS_DATA_TYPE_FLOAT32: return "FLOAT32"; + case DLMS_DATA_TYPE_FLOAT64: return "FLOAT64"; + case DLMS_DATA_TYPE_DATETIME: return "DATETIME"; + case DLMS_DATA_TYPE_DATE: return "DATE"; + case DLMS_DATA_TYPE_TIME: return "TIME"; + default: return "UNKNOWN"; + } +} + +void DlmsParser::register_pattern_dsl_(const std::string &name, const std::string &dsl, int priority) { + AxdrDescriptorPattern pat{name, priority, {}, 0}; + + auto trim = [](const std::string &s) { + size_t b = s.find_first_not_of(" \t\r\n"); + size_t e = s.find_last_not_of(" \t\r\n"); + if (b == std::string::npos) return std::string(); + return s.substr(b, e - b + 1); + }; + + std::list tokens; + std::string current; + int paren = 0; + for (char c : dsl) { + if (c == '(') { + paren++; + current.push_back(c); + } else if (c == ')') { + paren--; + current.push_back(c); + } else if (c == ',' && paren == 0) { + tokens.push_back(trim(current)); + current.clear(); + } else { + current.push_back(c); + } + } + if (!current.empty()) tokens.push_back(trim(current)); + + for (auto it = tokens.begin(); it != tokens.end(); ++it) { + std::string tok = *it; + if (tok.empty()) continue; + + if (tok == "F") pat.steps.push_back({AxdrTokenType::EXPECT_TO_BE_FIRST}); + else if (tok == "C") pat.steps.push_back({AxdrTokenType::EXPECT_CLASS_ID_UNTAGGED}); + else if (tok == "TC") { + pat.steps.push_back({AxdrTokenType::EXPECT_TYPE_EXACT, DLMS_DATA_TYPE_UINT16}); + pat.steps.push_back({AxdrTokenType::EXPECT_CLASS_ID_UNTAGGED}); + } + else if (tok == "O") pat.steps.push_back({AxdrTokenType::EXPECT_OBIS6_UNTAGGED}); + else if (tok == "TO") pat.steps.push_back({AxdrTokenType::EXPECT_OBIS6_TAGGED}); + else if (tok == "A") pat.steps.push_back({AxdrTokenType::EXPECT_ATTR8_UNTAGGED}); + else if (tok == "TA") { + pat.steps.push_back({AxdrTokenType::EXPECT_TYPE_U_I_8}); + pat.steps.push_back({AxdrTokenType::EXPECT_ATTR8_UNTAGGED}); + } + else if (tok == "TS") pat.steps.push_back({AxdrTokenType::EXPECT_SCALER_TAGGED}); + else if (tok == "TU") pat.steps.push_back({AxdrTokenType::EXPECT_UNIT_ENUM_TAGGED}); + else if (tok == "TSU") { + pat.steps.push_back({AxdrTokenType::EXPECT_STRUCTURE_N, 2}); + pat.steps.push_back({AxdrTokenType::GOING_DOWN}); + pat.steps.push_back({AxdrTokenType::EXPECT_SCALER_TAGGED}); + pat.steps.push_back({AxdrTokenType::EXPECT_UNIT_ENUM_TAGGED}); + pat.steps.push_back({AxdrTokenType::GOING_UP}); + } + else if (tok == "V" || tok == "TV") pat.steps.push_back({AxdrTokenType::EXPECT_VALUE_GENERIC}); + else if (tok.size() >= 2 && tok.substr(0, 2) == "S(") { + size_t l = tok.find('('); + size_t r = tok.rfind(')'); + if (l != std::string::npos && r != std::string::npos && r > l + 1) { + std::string inner = tok.substr(l + 1, r - l - 1); + std::list inner_tokens; + std::string cur; + for (char c2 : inner) { + if (c2 == ',') { + inner_tokens.push_back(trim(cur)); + cur.clear(); + } else { + cur.push_back(c2); + } + } + if (!cur.empty()) inner_tokens.push_back(trim(cur)); + + if (!inner_tokens.empty()) { + pat.steps.push_back({AxdrTokenType::EXPECT_STRUCTURE_N, static_cast(inner_tokens.size())}); + inner_tokens.push_front("DN"); + inner_tokens.push_back("UP"); + tokens.insert(std::next(it), inner_tokens.begin(), inner_tokens.end()); + } + } + } + else if (tok == "DN") pat.steps.push_back({AxdrTokenType::GOING_DOWN}); + else if (tok == "UP") pat.steps.push_back({AxdrTokenType::GOING_UP}); + } + + // Insert maintaining priority sort order + auto it = std::upper_bound(this->patterns_.begin(), this->patterns_.end(), pat, + [](const AxdrDescriptorPattern &a, const AxdrDescriptorPattern &b) { return a.priority < b.priority; }); + this->patterns_.insert(it, pat); +} + +} // namespace dlms_push +} // namespace esphome \ No newline at end of file diff --git a/components/dlms_push/dlms_parser.h b/components/dlms_push/dlms_parser.h new file mode 100644 index 0000000..10b0314 --- /dev/null +++ b/components/dlms_push/dlms_parser.h @@ -0,0 +1,133 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace esphome { +namespace dlms_push { + +enum DlmsDataType : uint8_t { + DLMS_DATA_TYPE_NONE = 0, + DLMS_DATA_TYPE_ARRAY = 1, + DLMS_DATA_TYPE_STRUCTURE = 2, + DLMS_DATA_TYPE_BOOLEAN = 3, + DLMS_DATA_TYPE_BIT_STRING = 4, + DLMS_DATA_TYPE_INT32 = 5, + DLMS_DATA_TYPE_UINT32 = 6, + DLMS_DATA_TYPE_OCTET_STRING = 9, + DLMS_DATA_TYPE_STRING = 10, + DLMS_DATA_TYPE_STRING_UTF8 = 12, + DLMS_DATA_TYPE_BINARY_CODED_DESIMAL = 13, + DLMS_DATA_TYPE_INT8 = 15, + DLMS_DATA_TYPE_INT16 = 16, + DLMS_DATA_TYPE_UINT8 = 17, + DLMS_DATA_TYPE_UINT16 = 18, + DLMS_DATA_TYPE_COMPACT_ARRAY = 19, + DLMS_DATA_TYPE_INT64 = 20, + DLMS_DATA_TYPE_UINT64 = 21, + DLMS_DATA_TYPE_ENUM = 22, + DLMS_DATA_TYPE_FLOAT32 = 23, + DLMS_DATA_TYPE_FLOAT64 = 24, + DLMS_DATA_TYPE_DATETIME = 25, + DLMS_DATA_TYPE_DATE = 26, + DLMS_DATA_TYPE_TIME = 27 +}; + +// Callback for the hub: OBIS code (e.g. "1.0.1.8.0.255"), numeric value, string value, is_numeric flag +using DlmsDataCallback = std::function; + +// --- Pattern Matching Enums & Structs --- +enum class AxdrTokenType : uint8_t { + EXPECT_TO_BE_FIRST, + EXPECT_TYPE_EXACT, + EXPECT_TYPE_U_I_8, + EXPECT_CLASS_ID_UNTAGGED, + EXPECT_OBIS6_TAGGED, + EXPECT_OBIS6_UNTAGGED, + EXPECT_ATTR8_UNTAGGED, + EXPECT_VALUE_GENERIC, + EXPECT_STRUCTURE_N, + EXPECT_SCALER_TAGGED, + EXPECT_UNIT_ENUM_TAGGED, + GOING_DOWN, + GOING_UP, +}; + +struct AxdrPatternStep { + AxdrTokenType type; + uint8_t param_u8_a{0}; +}; + +struct AxdrDescriptorPattern { + std::string name; + int priority{0}; + std::vector steps; + uint16_t default_class_id{0}; +}; + +struct AxdrCaptures { + uint32_t elem_idx{0}; + uint16_t class_id{0}; + const uint8_t *obis{nullptr}; + DlmsDataType value_type{DlmsDataType::DLMS_DATA_TYPE_NONE}; + const uint8_t *value_ptr{nullptr}; + uint8_t value_len{0}; + + bool has_scaler_unit{false}; + int8_t scaler{0}; + uint8_t unit_enum{0}; +}; + +class DlmsParser { + public: + DlmsParser(); + + // Registers a custom parsing pattern from the YAML config + void register_custom_pattern(const std::string &dsl); + + // Parses the buffer and fires callbacks for each found sensor value + size_t parse(const uint8_t *buffer, size_t length, DlmsDataCallback callback, bool show_log); + + private: + void register_pattern_dsl_(const std::string &name, const std::string &dsl, int priority); + void load_default_patterns_(); + + uint8_t read_byte_(); + uint16_t read_u16_(); + uint32_t read_u32_(); + + bool test_if_date_time_12b_(); + int get_data_type_size_(DlmsDataType type); + bool is_value_data_type_(DlmsDataType type); + + bool skip_data_(uint8_t type); + bool parse_element_(uint8_t type, uint8_t depth = 0); + bool parse_sequence_(uint8_t type, uint8_t depth = 0); + + bool capture_generic_value_(AxdrCaptures &c); + bool try_match_patterns_(uint8_t elem_idx); + bool match_pattern_(uint8_t elem_idx, const AxdrDescriptorPattern &pat, uint8_t &elements_consumed_at_level0); + void emit_object_(const AxdrDescriptorPattern &pat, const AxdrCaptures &c); + + float data_as_float_(DlmsDataType value_type, const uint8_t *ptr, uint8_t len); + std::string data_as_string_(DlmsDataType value_type, const uint8_t *ptr, uint8_t len); + std::string obis_to_string_(const uint8_t *obis); + const char *dlms_data_type_to_string_(DlmsDataType vt); + + const uint8_t *buffer_{nullptr}; + size_t buffer_len_{0}; + + size_t pos_{0}; + DlmsDataCallback callback_; + bool show_log_{false}; + size_t objects_found_{0}; + uint8_t last_pattern_elements_consumed_{0}; + + std::vector patterns_; +}; + +} // namespace dlms_push +} // namespace esphome \ No newline at end of file diff --git a/components/dlms_push/dlms_push.cpp b/components/dlms_push/dlms_push.cpp new file mode 100644 index 0000000..52ab54e --- /dev/null +++ b/components/dlms_push/dlms_push.cpp @@ -0,0 +1,182 @@ +#include "dlms_push.h" +#include "dlms_parser.h" + +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace dlms_push { + +static const char *const TAG = "dlms_push"; + +DlmsPushComponent::DlmsPushComponent() { + this->parser_ = new DlmsParser(); +} + +void DlmsPushComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up DLMS PUSH Component..."); + + if (!this->custom_pattern_.empty()) { + this->parser_->register_custom_pattern(this->custom_pattern_); + } + + this->rx_buffer_ = std::make_unique(MAX_RX_BUFFER_SIZE); + this->rx_buffer_len_ = 0; +} + +void DlmsPushComponent::dump_config() { + ESP_LOGCONFIG(TAG, "DLMS PUSH Component:"); + ESP_LOGCONFIG(TAG, " Receive Timeout: %u ms", this->receive_timeout_ms_); + ESP_LOGCONFIG(TAG, " Show Log: %s", this->show_log_ ? "True" : "False"); + if (!this->custom_pattern_.empty()) { + ESP_LOGCONFIG(TAG, " Custom Pattern: %s", this->custom_pattern_.c_str()); + } + +#ifdef USE_SENSOR + for (const auto &entry : this->sensors_) { + LOG_SENSOR(" ", "Numeric Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis.c_str()); + } +#endif + +#ifdef USE_TEXT_SENSOR + for (const auto &entry : this->text_sensors_) { + LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis.c_str()); + } +#endif + +#ifdef USE_BINARY_SENSOR + for (const auto &entry : this->binary_sensors_) { + LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis.c_str()); + } +#endif +} + +void DlmsPushComponent::loop() { + this->read_rx_buffer_(); + + if (this->receiving_ && (millis() - this->last_rx_char_time_ > this->receive_timeout_ms_)) { + this->receiving_ = false; + this->process_frame_(); + } +} + +void DlmsPushComponent::read_rx_buffer_() { + int available = this->available(); + if (available == 0) return; + + this->receiving_ = true; + this->last_rx_char_time_ = millis(); + + while (this->available()) { + if (this->rx_buffer_len_ >= MAX_RX_BUFFER_SIZE) { + ESP_LOGW(TAG, "RX Buffer overflow. Frame too large! Truncating."); + break; + } + + uint8_t byte; + if (this->read_byte(&byte)) { + this->rx_buffer_[this->rx_buffer_len_++] = byte; + } else { + ESP_LOGW(TAG, "Failed to read byte from UART."); + break; + } + } +} + +void DlmsPushComponent::process_frame_() { + if (this->rx_buffer_len_ == 0) return; + + if (this->show_log_) { + ESP_LOGD(TAG, "Processing received push data"); + ESP_LOGD(TAG, "Processing PUSH data frame with DLMS parser"); + ESP_LOGD(TAG, "PUSH frame size: %zu bytes", this->rx_buffer_len_); + } + + auto callback = [this](const std::string &obis_code, float float_val, const std::string &str_val, bool is_numeric) { + this->on_data_parsed_(obis_code, float_val, str_val, is_numeric); + }; + + size_t parsed_objects = this->parser_->parse(this->rx_buffer_.get(), this->rx_buffer_len_, callback, this->show_log_); + + if (this->show_log_) { + ESP_LOGD(TAG, "PUSH data parsing complete: %zu objects, bytes consumed %zu/%zu", parsed_objects, this->rx_buffer_len_, this->rx_buffer_len_); + } + + this->rx_buffer_len_ = 0; +} + +void DlmsPushComponent::on_data_parsed_(const std::string &obis_code, float float_val, const std::string &str_val, bool is_numeric) { + int updated_count = 0; + +#ifdef USE_SENSOR + if (is_numeric) { + for (const auto &entry : this->sensors_) { + if (entry.obis == obis_code) { + if (this->show_log_) { + ESP_LOGD(TAG, "Found sensor for OBIS code %s: '%s'", obis_code.c_str(), entry.sensor->get_name().c_str()); + ESP_LOGD(TAG, "Publishing data"); + } + entry.sensor->publish_state(float_val); + updated_count++; + } + } + } +#endif + +#ifdef USE_TEXT_SENSOR + for (const auto &entry : this->text_sensors_) { + if (entry.obis == obis_code) { + if (this->show_log_) { + ESP_LOGD(TAG, "Found sensor for OBIS code %s: '%s'", obis_code.c_str(), entry.sensor->get_name().c_str()); + ESP_LOGD(TAG, "Publishing data"); + } + entry.sensor->publish_state(str_val); + updated_count++; + } + } +#endif + +#ifdef USE_BINARY_SENSOR + if (is_numeric) { + bool state = float_val != 0.0f; + for (const auto &entry : this->binary_sensors_) { + if (entry.obis == obis_code) { + if (this->show_log_) { + ESP_LOGD(TAG, "Found sensor for OBIS code %s: '%s'", obis_code.c_str(), entry.sensor->get_name().c_str()); + ESP_LOGD(TAG, "Publishing data"); + } + entry.sensor->publish_state(state); + updated_count++; + } + } + } +#endif + + if (this->show_log_ && updated_count == 0) { + ESP_LOGV(TAG, "Received OBIS %s, but no sensors are registered for it.", obis_code.c_str()); + } +} + +#ifdef USE_SENSOR +void DlmsPushComponent::register_sensor(const std::string &obis_code, sensor::Sensor *sensor) { + this->sensors_.push_back({obis_code, sensor}); +} +#endif + +#ifdef USE_TEXT_SENSOR +void DlmsPushComponent::register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor) { + this->text_sensors_.push_back({obis_code, sensor}); +} +#endif + +#ifdef USE_BINARY_SENSOR +void DlmsPushComponent::register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor) { + this->binary_sensors_.push_back({obis_code, sensor}); +} +#endif + +} // namespace dlms_push +} // namespace esphome \ No newline at end of file diff --git a/components/dlms_push/dlms_push.h b/components/dlms_push/dlms_push.h new file mode 100644 index 0000000..434394a --- /dev/null +++ b/components/dlms_push/dlms_push.h @@ -0,0 +1,91 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" + +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +#include +#include +#include +#include + +namespace esphome { +namespace dlms_push { + +class DlmsParser; + +class DlmsPushComponent : public Component, public uart::UARTDevice { + public: + DlmsPushComponent(); + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + void set_receive_timeout(uint32_t timeout_ms) { this->receive_timeout_ms_ = timeout_ms; } + void set_show_log(bool show_log) { this->show_log_ = show_log; } + void set_custom_pattern(const std::string &pattern) { this->custom_pattern_ = pattern; } + +#ifdef USE_SENSOR + void register_sensor(const std::string &obis_code, sensor::Sensor *sensor); +#endif +#ifdef USE_TEXT_SENSOR + void register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor); +#endif +#ifdef USE_BINARY_SENSOR + void register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor); +#endif + + protected: + void read_rx_buffer_(); + void process_frame_(); + + void on_data_parsed_(const std::string &obis_code, float float_val, const std::string &str_val, bool is_numeric); + + uint32_t receive_timeout_ms_{50}; + bool show_log_{false}; + std::string custom_pattern_{""}; + + static const size_t MAX_RX_BUFFER_SIZE = 2048; + std::unique_ptr rx_buffer_; + size_t rx_buffer_len_{0}; + uint32_t last_rx_char_time_{0}; + bool receiving_{false}; + + DlmsParser *parser_{nullptr}; + +#ifdef USE_SENSOR + struct NumericSensorEntry { + std::string obis; + sensor::Sensor *sensor; + }; + std::vector sensors_; +#endif +#ifdef USE_TEXT_SENSOR + struct TextSensorEntry { + std::string obis; + text_sensor::TextSensor *sensor; + }; + std::vector text_sensors_; +#endif +#ifdef USE_BINARY_SENSOR + struct BinarySensorEntry { + std::string obis; + binary_sensor::BinarySensor *sensor; + }; + std::vector binary_sensors_; +#endif +}; + +} // namespace dlms_push +} // namespace esphome \ No newline at end of file diff --git a/components/dlms_push/sensor.py b/components/dlms_push/sensor.py new file mode 100644 index 0000000..fe32852 --- /dev/null +++ b/components/dlms_push/sensor.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor +from . import DlmsPushComponent, obis_code + +DEPENDENCIES = ["dlms_push"] + +CONF_DLMS_PUSH_ID = "dlms_push_id" +CONF_OBIS_CODE = "obis_code" + +CONFIG_SCHEMA = sensor.sensor_schema().extend( + { + cv.GenerateID(CONF_DLMS_PUSH_ID): cv.use_id(DlmsPushComponent), + cv.Required(CONF_OBIS_CODE): obis_code, + } +) + +async def to_code(config): + # Retrieve the hub component + hub = await cg.get_variable(config[CONF_DLMS_PUSH_ID]) + + # Create the standard ESPHome sensor + var = await sensor.new_sensor(config) + + # Register the sensor with the hub using its OBIS code + cg.add(hub.register_sensor(config[CONF_OBIS_CODE], var)) \ No newline at end of file diff --git a/components/dlms_push/text_sensor.py b/components/dlms_push/text_sensor.py new file mode 100644 index 0000000..e5ab2b7 --- /dev/null +++ b/components/dlms_push/text_sensor.py @@ -0,0 +1,21 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import text_sensor +from . import DlmsPushComponent, obis_code + +DEPENDENCIES = ["dlms_push"] + +CONF_DLMS_PUSH_ID = "dlms_push_id" +CONF_OBIS_CODE = "obis_code" + +CONFIG_SCHEMA = text_sensor.text_sensor_schema().extend( + { + cv.GenerateID(CONF_DLMS_PUSH_ID): cv.use_id(DlmsPushComponent), + cv.Required(CONF_OBIS_CODE): obis_code, + } +) + +async def to_code(config): + hub = await cg.get_variable(config[CONF_DLMS_PUSH_ID]) + var = await text_sensor.new_text_sensor(config) + cg.add(hub.register_text_sensor(config[CONF_OBIS_CODE], var)) \ No newline at end of file diff --git a/esphome-smartmeter.yaml b/esphome-smartmeter.yaml index dd4ae96..e94a71f 100644 --- a/esphome-smartmeter.yaml +++ b/esphome-smartmeter.yaml @@ -1,5 +1,5 @@ esphome: - name: "esp32c3-2" + name: "smartmeter" friendly_name: SmartMeter min_version: 2025.9.0 name_add_mac_suffix: false @@ -8,7 +8,6 @@ esp32: variant: esp32c3 framework: type: esp-idf - version: 5.4.1 # Enable logging logger: @@ -26,18 +25,12 @@ wifi: external_components: - source: github://Tomer27cz/xt211 - components: [xt211] + components: [dlms_push] refresh: 1s -switch: - - platform: gpio - pin: GPIO4 - id: indicator_led - internal: True - -time: - - platform: homeassistant - id: homeassistant_time +dlms_push: + id: my_dlms_meter + uart_id: bus_1 uart: id: bus_1 @@ -48,12 +41,20 @@ uart: parity: NONE stop_bits: 1 -xt211: +time: + - platform: homeassistant + id: homeassistant_time + +switch: + - platform: gpio + pin: GPIO4 + id: indicator_led + internal: True number: - platform: template id: select_pulse_rate - name: 'Puls rate - imp/kWh' + name: 'Puls rate - imp⁄kWh' optimistic: true mode: box min_value: 100 @@ -67,42 +68,63 @@ button: name: "Restart" text_sensor: - - platform: xt211 + - platform: wifi_info + ip_address: + name: "ZZ - IP Address" + ssid: + name: "ZZ - SSID" + bssid: + name: "ZZ - BSSID" + mac_address: + name: "ZZ - Address" + dns_address: + name: "ZZ - DNS Address" + + - platform: dlms_push name: "Serial number" obis_code: 0.0.96.1.1.255 entity_category: diagnostic - - platform: xt211 + - platform: dlms_push name: "Limmiter" obis_code: 0.0.17.0.0.255 - entity_category: diagnostic + entity_category: diagnostic - - platform: xt211 + - platform: dlms_push name: "Current tariff" obis_code: 0.0.96.14.0.255 entity_category: diagnostic binary_sensor: - - platform: xt211 + - platform: status + name: "ZZ - Status" + + - platform: dlms_push name: "Disconnector state" obis_code: 0.0.96.3.10.255 - - platform: xt211 + - platform: dlms_push name: "Relay 1" obis_code: 0.1.96.3.10.255 - - platform: xt211 + - platform: dlms_push name: "Relay 2" obis_code: 0.2.96.3.10.255 - - platform: xt211 + - platform: dlms_push name: "Relay 3" obis_code: 0.3.96.3.10.255 - - platform: xt211 + - platform: dlms_push name: "Relay 4" obis_code: 0.4.96.3.10.255 sensor: - - platform: wifi_signal - name: "WiFi Signal" + - platform: uptime + name: 'ZZ - Uptime' update_interval: 60s - + - platform: wifi_signal + name: "ZZ - WiFi Signal" + update_interval: 60s + - platform: internal_temperature + name: "ZZ - CPU Temp" + update_interval: 60s + - platform: template id: power_consumption name: "Power Consumption" @@ -122,20 +144,20 @@ sensor: accuracy_decimals: 0 pin: GPIO6 - on_raw_value: + on_raw_value: then: - switch.turn_on: indicator_led - delay: 100ms - - switch.turn_off: indicator_led + - switch.turn_off: indicator_led on_value: then: - sensor.template.publish: id: power_consumption state: !lambda 'return x;' - + # dont know what this does but it was commented out internal_filter: 100ms - + filters: # Sensor can quickly transition between on and off (unintendet bevaior) # this meter has max load of 13.27 kW (32A - 3 Phase - 240V) = pulse every 270ms at full load @@ -152,11 +174,11 @@ sensor: # https://github.com/klaasnicolaas/home-assistant-glow/#reduce-the-amount-of-data-the-sensors-produce # for more information. #- throttle_average: 10s - + # filter out impossible numbers - filter_out: NaN - - - platform: xt211 + + - platform: dlms_push id: active_energy_consumed name: "Energy" obis_code: 1.0.1.8.0.255 @@ -167,7 +189,7 @@ sensor: filters: - lambda: "return x/1000.0;" - - platform: xt211 + - platform: dlms_push id: active_energy_consumed_t1 name: "Energy T1" obis_code: 1.0.1.8.1.255 @@ -177,7 +199,7 @@ sensor: state_class: total_increasing filters: - lambda: "return x/1000.0;" - - platform: xt211 + - platform: dlms_push id: active_energy_consumed_t2 name: "Energy T2" obis_code: 1.0.1.8.2.255 @@ -187,7 +209,7 @@ sensor: state_class: total_increasing filters: - lambda: "return x/1000.0;" - - platform: xt211 + - platform: dlms_push id: active_energy_consumed_t3 name: "Energy T3" obis_code: 1.0.1.8.3.255 @@ -197,7 +219,7 @@ sensor: state_class: total_increasing filters: - lambda: "return x/1000.0;" - - platform: xt211 + - platform: dlms_push id: active_energy_consumed_t4 name: "Energy T4" obis_code: 1.0.1.8.4.255 @@ -208,7 +230,7 @@ sensor: filters: - lambda: "return x/1000.0;" - - platform: xt211 + - platform: dlms_push id: active_power name: "Active power consumption" obis_code: 1.0.1.7.0.255 @@ -222,7 +244,7 @@ sensor: id: power_consumption state: !lambda 'return x;' - - platform: xt211 + - platform: dlms_push id: active_power_l1 name: "Active power consumption L1" obis_code: 1.0.21.7.0.255 @@ -230,7 +252,7 @@ sensor: accuracy_decimals: 0 device_class: power state_class: measurement - - platform: xt211 + - platform: dlms_push id: active_power_l2 name: "Active power consumption L2" obis_code: 1.0.41.7.0.255 @@ -238,7 +260,7 @@ sensor: accuracy_decimals: 0 device_class: power state_class: measurement - - platform: xt211 + - platform: dlms_push id: active_power_l3 name: "Active power consumption L3" obis_code: 1.0.61.7.0.255 @@ -246,8 +268,8 @@ sensor: accuracy_decimals: 0 device_class: power state_class: measurement - - - platform: xt211 + + - platform: dlms_push id: active_power_delivery name: "Active power delivery" obis_code: 1.0.2.7.0.255 @@ -256,7 +278,7 @@ sensor: device_class: power state_class: measurement - - platform: xt211 + - platform: dlms_push id: active_power_l1_delivery name: "Active power L1 delivery" obis_code: 1.0.22.7.0.255 @@ -264,7 +286,7 @@ sensor: accuracy_decimals: 0 device_class: power state_class: measurement - - platform: xt211 + - platform: dlms_push id: active_power_l2_delivery name: "Active power L2 delivery" obis_code: 1.0.42.7.0.255 @@ -272,7 +294,7 @@ sensor: accuracy_decimals: 0 device_class: power state_class: measurement - - platform: xt211 + - platform: dlms_push id: active_power_l3_delivery name: "Active power L3 delivery" obis_code: 1.0.62.7.0.255