diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 566cac066e..3c1c2be289 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -def25306bb0f5e09b94fe7b74ffa6995a56bb951e7a27d9ad0a21103532a74a9 +fe0fe4fde52c61eb40b1214675af8db44d2678c6b7bc2674d51ed4836ecf94da diff --git a/CODEOWNERS b/CODEOWNERS index c5beba8c0b..c69f8bccd4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -138,7 +138,7 @@ esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 -esphome/components/dlms_meter/* @SimonFischer04 +esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/ds2484/* @mrk-its diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index c22ab7b552..7094699b0b 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,57 +1,258 @@ -import esphome.codegen as cg -from esphome.components import uart -import esphome.config_validation as cv -from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 +import logging +import re -CODEOWNERS = ["@SimonFischer04"] +import esphome.codegen as cg +from esphome.components import esp32, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_NAME, + CONF_PATTERN, + CONF_PRIORITY, + CONF_RECEIVE_TIMEOUT, +) +from esphome.core import CORE + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"] DEPENDENCIES = ["uart"] CONF_DLMS_METER_ID = "dlms_meter_id" CONF_DECRYPTION_KEY = "decryption_key" +CONF_AUTH_KEY = "auth_key" +CONF_OBIS_CODE = "obis_code" +CONF_CUSTOM_PATTERNS = "custom_patterns" +CONF_SKIP_CRC = "skip_crc" +CONF_DEFAULT_OBIS = "default_obis" CONF_PROVIDER = "provider" -PROVIDERS = {"generic": 0, "netznoe": 1} - dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter") DlmsMeterComponent = dlms_meter_component_ns.class_( "DlmsMeterComponent", cg.Component, uart.UARTDevice ) -def validate_key(value): - value = cv.string_strict(value) - if len(value) != 32: - raise cv.Invalid("Decryption key must be 32 hex characters (16 bytes)") - try: - return [int(value[i : i + 2], 16) for i in range(0, 32, 2)] - except ValueError as exc: - raise cv.Invalid("Decryption key must be hex values from 00 to FF") from exc +def obis_code(value): + # Normalize the OBIS code to the strict A.B.C.D.E.F format + bytes_list = parse_obis_code_bytes(value) + return ".".join(str(b) for b in bytes_list) +def parse_obis_code_bytes(value): + value = cv.string(value) + normalized = re.sub(r"[\-\:\*]", ".", value) + parts = normalized.split(".") + if len(parts) < 5 or len(parts) > 6: + raise cv.Invalid("OBIS code must have 5 or 6 parts") + try: + bytes_list = [int(p) for p in parts] + except ValueError as exc: + raise cv.Invalid("OBIS code parts must be integers") from exc + for b in bytes_list: + if b < 0 or b > 255: + raise cv.Invalid("OBIS code parts must be between 0 and 255") + if len(bytes_list) == 5: + bytes_list.append(255) + return bytes_list + + +def custom_pattern_dict(value): + if isinstance(value, str): + return {CONF_PATTERN: value} + return value + + +def validate_custom_pattern(value): + if CONF_DEFAULT_OBIS in value and CONF_NAME not in value: + raise cv.Invalid(f"'{CONF_DEFAULT_OBIS}' requires '{CONF_NAME}' to be set") + return value + + +def validate_provider_deprecation(config): + if CONF_PROVIDER in config: + provider = str(config[CONF_PROVIDER]).lower() + if provider == "netznoe": + _LOGGER.warning( + "The 'provider: netznoe' option is deprecated and will be removed in 2026.11.0. " + "The required custom patterns have been added automatically for this release, but you must update your configuration.\n" + "Please remove the 'provider' key and explicitly replace it with the following:\n\n" + "custom_patterns:\n" + ' - pattern: "L, TSTR"\n' + ' name: "MeterID"\n' + ' default_obis: "0.0.96.1.0.255"\n' + ' - pattern: "F, TDTM"\n' + ' name: "DateTime"\n' + ' default_obis: "0.0.1.0.0.255"\n' + ) + patterns = config.get(CONF_CUSTOM_PATTERNS, []) + + # Ensure "L, TSTR" for MeterID is present + if not any(p.get(CONF_PATTERN) == "L, TSTR" for p in patterns): + patterns.append( + { + CONF_PATTERN: "L, TSTR", + CONF_NAME: "MeterID", + CONF_DEFAULT_OBIS: [0, 0, 96, 1, 0, 255], + CONF_PRIORITY: 0, + } + ) + + # Ensure "F, TDTM" for DateTime is present + if not any(p.get(CONF_PATTERN) == "F, TDTM" for p in patterns): + patterns.append( + { + CONF_PATTERN: "F, TDTM", + CONF_NAME: "DateTime", + CONF_DEFAULT_OBIS: [0, 0, 1, 0, 0, 255], + CONF_PRIORITY: 0, + } + ) + + config[CONF_CUSTOM_PATTERNS] = patterns + else: + _LOGGER.warning( + "The 'provider' option is deprecated and will be removed in 2026.11.0. " + "The dlms_parser library now handles quirks dynamically. " + "Please remove this option from your configuration." + ) + return config + + +CUSTOM_PATTERN_SCHEMA = cv.All( + custom_pattern_dict, + cv.Schema( + { + cv.Required(CONF_PATTERN): cv.string, + cv.Optional(CONF_NAME): cv.string, + cv.Optional(CONF_PRIORITY, default=0): cv.int_, + cv.Optional(CONF_DEFAULT_OBIS): parse_obis_code_bytes, + } + ), + validate_custom_pattern, +) + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), - cv.Required(CONF_DECRYPTION_KEY): validate_key, - cv.Optional(CONF_PROVIDER, default="generic"): cv.enum( - PROVIDERS, lower=True + cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( + value, name="Decryption key" ), + cv.Optional(CONF_AUTH_KEY): lambda value: cv.bind_key( + value, name="Authentication key" + ), + cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA), + cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean, + cv.Optional(CONF_PROVIDER): cv.string, + cv.Optional( + CONF_RECEIVE_TIMEOUT, default="1000ms" + ): cv.positive_time_period_milliseconds, } ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP8266, PLATFORM_ESP32]), + validate_provider_deprecation, ) -FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "dlms_meter", baud_rate=2400, require_rx=True -) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + dec_key_expr = cg.RawExpression("std::nullopt") + if dec_key := config.get(CONF_DECRYPTION_KEY): + key_bytes = [str(int(dec_key[i : i + 2], 16)) for i in range(0, 32, 2)] + dec_key_expr = cg.RawExpression( + f"std::array{{{', '.join(key_bytes)}}}" + ) + + auth_key_expr = cg.RawExpression("std::nullopt") + if auth_key := config.get(CONF_AUTH_KEY): + key_bytes = [str(int(auth_key[i : i + 2], 16)) for i in range(0, 32, 2)] + auth_key_expr = cg.RawExpression( + f"std::array{{{', '.join(key_bytes)}}}" + ) + + patterns = [] + if custom_patterns := config.get(CONF_CUSTOM_PATTERNS): + for p in custom_patterns: + name_expr = cg.RawExpression("std::nullopt") + if name_val := p.get(CONF_NAME): + name_expr = name_val + + if obis_vals := p.get(CONF_DEFAULT_OBIS): + obis_expr = cg.RawExpression( + f"std::array{{{obis_vals[0]}, {obis_vals[1]}, {obis_vals[2]}, {obis_vals[3]}, {obis_vals[4]}, {obis_vals[5]}}}" + ) + else: + obis_expr = cg.RawExpression("std::nullopt") + + patterns.append( + cg.ArrayInitializer( + p[CONF_PATTERN], + name_expr, + p.get(CONF_PRIORITY, 0), + obis_expr, + ) + ) + + patterns_expr = ( + cg.ArrayInitializer(*patterns) if patterns else cg.RawExpression("{}") + ) + + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_RECEIVE_TIMEOUT], + config[CONF_SKIP_CRC], + dec_key_expr, + auth_key_expr, + patterns_expr, + ) + + hub_id = config[CONF_ID].id + + sensor_count = 0 + for sens_conf in CORE.config.get("sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + if CONF_OBIS_CODE in sens_conf: + sensor_count += 1 + else: + from .sensor import NUMERIC_KEYS + + sensor_count += sum(1 for key in NUMERIC_KEYS if key in sens_conf) + + text_sensor_count = 0 + for sens_conf in CORE.config.get("text_sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + if CONF_OBIS_CODE in sens_conf: + text_sensor_count += 1 + else: + from .text_sensor import TEXT_KEYS + + text_sensor_count += sum(1 for key in TEXT_KEYS if key in sens_conf) + + binary_sensor_count = 0 + for sens_conf in CORE.config.get("binary_sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + binary_sensor_count += 1 + + cg.add_define("DLMS_MAX_SENSORS", sensor_count) + cg.add_define("DLMS_MAX_TEXT_SENSORS", text_sensor_count) + cg.add_define("DLMS_MAX_BINARY_SENSORS", binary_sensor_count) + await cg.register_component(var, config) await uart.register_uart_device(var, config) - key = ", ".join(str(b) for b in config[CONF_DECRYPTION_KEY]) - cg.add(var.set_decryption_key(cg.RawExpression(f"{{{key}}}"))) - cg.add(var.set_provider(PROVIDERS[config[CONF_PROVIDER]])) + + if CORE.is_esp32: + esp32.add_idf_component(name="esphome/dlms_parser", ref="1.1.0") + else: + cg.add_library("esphome/dlms_parser", "1.1.0") diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py new file mode 100644 index 0000000000..f9bc1d9df7 --- /dev/null +++ b/esphome/components/dlms_meter/binary_sensor/__init__.py @@ -0,0 +1,20 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv + +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code + +DEPENDENCIES = ["dlms_meter"] + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Required(CONF_OBIS_CODE): obis_code, + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var)) diff --git a/esphome/components/dlms_meter/dlms.h b/esphome/components/dlms_meter/dlms.h deleted file mode 100644 index a3d8f62ce6..0000000000 --- a/esphome/components/dlms_meter/dlms.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -/* -+-------------------------------+ -| Ciphering Service | -+-------------------------------+ -| System Title Length | -+-------------------------------+ -| | -| | -| | -| System | -| Title | -| | -| | -| | -+-------------------------------+ -| Length | (1 or 3 Bytes) -+-------------------------------+ -| Security Control Byte | -+-------------------------------+ -| | -| Frame | -| Counter | -| | -+-------------------------------+ -| | -~ ~ - Encrypted Payload -~ ~ -| | -+-------------------------------+ - -Ciphering Service: 0xDB (General-Glo-Ciphering) -System Title Length: 0x08 -System Title: Unique ID of meter -Length: 1 Byte=Length <= 127, 3 Bytes=Length > 127 (0x82 & 2 Bytes length) -Security Control Byte: -- Bit 3…0: Security_Suite_Id -- Bit 4: "A" subfield: indicates that authentication is applied -- Bit 5: "E" subfield: indicates that encryption is applied -- Bit 6: Key_Set subfield: 0 = Unicast, 1 = Broadcast -- Bit 7: Indicates the use of compression. - */ - -static constexpr uint8_t DLMS_HEADER_LENGTH = 16; -static constexpr uint8_t DLMS_HEADER_EXT_OFFSET = 2; // Extra offset for extended length header -static constexpr uint8_t DLMS_CIPHER_OFFSET = 0; -static constexpr uint8_t DLMS_SYST_OFFSET = 1; -static constexpr uint8_t DLMS_LENGTH_OFFSET = 10; -static constexpr uint8_t TWO_BYTE_LENGTH = 0x82; -static constexpr uint8_t DLMS_LENGTH_CORRECTION = 5; // Header bytes included in length field -static constexpr uint8_t DLMS_SECBYTE_OFFSET = 11; -static constexpr uint8_t DLMS_FRAMECOUNTER_OFFSET = 12; -static constexpr uint8_t DLMS_FRAMECOUNTER_LENGTH = 4; -static constexpr uint8_t DLMS_PAYLOAD_OFFSET = 16; -static constexpr uint8_t GLO_CIPHERING = 0xDB; -static constexpr uint8_t DATA_NOTIFICATION = 0x0F; -static constexpr uint8_t TIMESTAMP_DATETIME = 0x0C; -static constexpr uint16_t MAX_MESSAGE_LENGTH = 512; // Maximum size of message (when having 2 bytes length in header). - -// Provider specific quirks -static constexpr uint8_t NETZ_NOE_MAGIC_BYTE = 0x81; // Magic length byte used by Netz NOE -static constexpr uint8_t NETZ_NOE_EXPECTED_MESSAGE_LENGTH = 0xF8; -static constexpr uint8_t NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE = 0x20; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index b732e71d24..bdbf798df5 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -1,516 +1,236 @@ #include "dlms_meter.h" +#include "esphome/core/log.h" -#include - -#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) -#include -#elif defined(USE_ESP32) -#include -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) -#include -#else -#include "mbedtls/esp_config.h" -#include "mbedtls/gcm.h" -#endif -#endif +#include namespace esphome::dlms_meter { -static constexpr const char *TAG = "dlms_meter"; +static const char *const TAG = "dlms_meter"; +static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list args) { + std::array buf; + vsnprintf(buf.data(), buf.size(), fmt, args); + switch (level) { + case dlms_parser::LogLevel::ERROR: + ESP_LOGE(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::WARNING: + ESP_LOGW(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::INFO: + ESP_LOGI(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::VERBOSE: + ESP_LOGV(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::VERY_VERBOSE: + ESP_LOGVV(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::DEBUG: + ESP_LOGD(TAG, "%s", buf.data()); + break; + } +} + +DlmsMeterComponent::DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, + std::optional> decryption_key, + std::optional> authentication_key, + std::vector custom_patterns) + : receive_timeout_ms_(receive_timeout_ms), + skip_crc_check_(skip_crc_check), + custom_patterns_(std::move(custom_patterns)), + parser_(&decryptor_) { + dlms_parser::Logger::set_log_function(log_callback); + + if (decryption_key.has_value()) { +#ifdef DLMS_METER_NO_CRYPTO + ESP_LOGE(TAG, "Decryption is not supported on this platform (no compatible crypto library found)"); +#else + auto opt_key = dlms_parser::Aes128GcmDecryptionKey::from_bytes(decryption_key.value()); + if (opt_key) { + this->parser_.set_decryption_key(*opt_key); + } else { + ESP_LOGE(TAG, "Failed to set decryption key: invalid key format"); + } +#endif + } + + if (authentication_key.has_value()) { +#ifdef DLMS_METER_NO_CRYPTO + ESP_LOGE(TAG, "Authentication is not supported on this platform (no compatible crypto library found)"); +#else + auto opt_key = dlms_parser::Aes128GcmAuthenticationKey::from_bytes(authentication_key.value()); + if (opt_key) { + this->parser_.set_authentication_key(*opt_key); + } else { + ESP_LOGE(TAG, "Failed to set authentication key: invalid key format"); + } +#endif + } + + this->parser_.set_skip_crc_check(this->skip_crc_check_); + + this->parser_.load_default_patterns(); + for (const auto &pattern : this->custom_patterns_) { + if (pattern.default_obis.has_value() && pattern.name.has_value()) { + this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority, + pattern.default_obis.value()); + } else if (pattern.name.has_value()) { + this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority); + } else { + this->parser_.register_pattern(pattern.pattern.c_str()); + } + } +} + +void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); } void DlmsMeterComponent::dump_config() { - const char *provider_name = this->provider_ == PROVIDER_NETZNOE ? "Netz NOE" : "Generic"; - ESP_LOGCONFIG(TAG, - "DLMS Meter:\n" - " Provider: %s\n" - " Read Timeout: %" PRIu32 " ms", - provider_name, this->read_timeout_); -#define DLMS_METER_LOG_SENSOR(s) LOG_SENSOR(" ", #s, this->s##_sensor_); - DLMS_METER_SENSOR_LIST(DLMS_METER_LOG_SENSOR, ) -#define DLMS_METER_LOG_TEXT_SENSOR(s) LOG_TEXT_SENSOR(" ", #s, this->s##_text_sensor_); - DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_LOG_TEXT_SENSOR, ) + ESP_LOGCONFIG(TAG, "DLMS Meter:"); + ESP_LOGCONFIG(TAG, " Receive Timeout: %u ms", this->receive_timeout_ms_); + ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_)); + + for (const auto &pattern : this->custom_patterns_) { + if (pattern.default_obis.has_value() && pattern.name.has_value()) { + const auto &obis = pattern.default_obis.value(); + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %d.%d.%d.%d.%d.%d)", + pattern.pattern.c_str(), pattern.name->c_str(), pattern.priority, obis[0], obis[1], obis[2], + obis[3], obis[4], obis[5]); + } else if (pattern.name.has_value()) { + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d)", pattern.pattern.c_str(), + pattern.name->c_str(), pattern.priority); + } else { + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s'", pattern.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_code.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_code.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_code.c_str()); + } +#endif } void DlmsMeterComponent::loop() { - // Read while data is available, netznoe uses two frames so allow 2x max frame length - size_t avail = this->available(); - if (avail > 0) { - size_t remaining = MBUS_MAX_FRAME_LENGTH * 2 - this->receive_buffer_.size(); - if (remaining == 0) { - ESP_LOGW(TAG, "Receive buffer full, dropping remaining bytes"); - } else { - // Read all available bytes in batches to reduce UART call overhead. - // Cap reads to remaining buffer capacity. - if (avail > remaining) { - avail = remaining; - } - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; - } - avail -= to_read; - this->receive_buffer_.insert(this->receive_buffer_.end(), buf, buf + to_read); - this->last_read_ = millis(); - } - } - } - - if (!this->receive_buffer_.empty() && millis() - this->last_read_ > this->read_timeout_) { - this->mbus_payload_.clear(); - if (!this->parse_mbus_(this->mbus_payload_)) - return; - - uint16_t message_length; - uint8_t systitle_length; - uint16_t header_offset; - if (!this->parse_dlms_(this->mbus_payload_, message_length, systitle_length, header_offset)) - return; - - if (message_length < DECODER_START_OFFSET || message_length > MAX_MESSAGE_LENGTH) { - ESP_LOGE(TAG, "DLMS: Message length invalid: %u", message_length); - this->receive_buffer_.clear(); - return; - } - - // Decrypt in place and then decode the OBIS codes - if (!this->decrypt_(this->mbus_payload_, message_length, systitle_length, header_offset)) - return; - this->decode_obis_(&this->mbus_payload_[header_offset + DLMS_PAYLOAD_OFFSET], message_length); + this->read_rx_buffer_(); + if (this->bytes_accumulated_ > 0 && + App.get_loop_component_start_time() - this->last_rx_char_time_ > this->receive_timeout_ms_) { + this->process_frame_(); } } -bool DlmsMeterComponent::parse_mbus_(std::vector &mbus_payload) { - ESP_LOGV(TAG, "Parsing M-Bus frames"); - uint16_t frame_offset = 0; // Offset is used if the M-Bus message is split into multiple frames - - while (frame_offset < this->receive_buffer_.size()) { - // Ensure enough bytes remain for the minimal intro header before accessing indices - if (this->receive_buffer_.size() - frame_offset < MBUS_HEADER_INTRO_LENGTH) { - ESP_LOGE(TAG, "MBUS: Not enough data for frame header (need %d, have %d)", MBUS_HEADER_INTRO_LENGTH, - (this->receive_buffer_.size() - frame_offset)); - this->receive_buffer_.clear(); - return false; - } - - // Check start bytes - if (this->receive_buffer_[frame_offset + MBUS_START1_OFFSET] != START_BYTE_LONG_FRAME || - this->receive_buffer_[frame_offset + MBUS_START2_OFFSET] != START_BYTE_LONG_FRAME) { - ESP_LOGE(TAG, "MBUS: Start bytes do not match"); - this->receive_buffer_.clear(); - return false; - } - - // Both length bytes must be identical - if (this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET] != - this->receive_buffer_[frame_offset + MBUS_LENGTH2_OFFSET]) { - ESP_LOGE(TAG, "MBUS: Length bytes do not match"); - this->receive_buffer_.clear(); - return false; - } - - uint8_t frame_length = this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET]; // Get length of this frame - - // Check if received data is enough for the given frame length - if (this->receive_buffer_.size() - frame_offset < - frame_length + 3) { // length field inside packet does not account for second start- + checksum- + stop- byte - ESP_LOGE(TAG, "MBUS: Frame too big for received data"); - this->receive_buffer_.clear(); - return false; - } - - // Ensure we have full frame (header + payload + checksum + stop byte) before accessing stop byte - size_t required_total = - frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH; // payload + header + 2 footer bytes - if (this->receive_buffer_.size() - frame_offset < required_total) { - ESP_LOGE(TAG, "MBUS: Incomplete frame (need %d, have %d)", (unsigned int) required_total, - this->receive_buffer_.size() - frame_offset); - this->receive_buffer_.clear(); - return false; - } - - if (this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH - 1] != - STOP_BYTE) { - ESP_LOGE(TAG, "MBUS: Invalid stop byte"); - this->receive_buffer_.clear(); - return false; - } - - // Verify checksum: sum of all bytes starting at MBUS_HEADER_INTRO_LENGTH, take last byte - uint8_t checksum = 0; // use uint8_t so only the 8 least significant bits are stored - for (uint16_t i = 0; i < frame_length; i++) { - checksum += this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + i]; - } - if (checksum != this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]) { - ESP_LOGE(TAG, "MBUS: Invalid checksum: %x != %x", checksum, - this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]); - this->receive_buffer_.clear(); - return false; - } - - mbus_payload.insert(mbus_payload.end(), &this->receive_buffer_[frame_offset + MBUS_FULL_HEADER_LENGTH], - &this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + frame_length]); - - frame_offset += MBUS_HEADER_INTRO_LENGTH + frame_length + MBUS_FOOTER_LENGTH; +void DlmsMeterComponent::flush_rx_buffer_() { + while (this->available()) { + this->read(); } - return true; } -bool DlmsMeterComponent::parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, - uint8_t &systitle_length, uint16_t &header_offset) { - ESP_LOGV(TAG, "Parsing DLMS header"); - if (mbus_payload.size() < DLMS_HEADER_LENGTH + DLMS_HEADER_EXT_OFFSET) { - ESP_LOGE(TAG, "DLMS: Payload too short"); - this->receive_buffer_.clear(); - return false; +void DlmsMeterComponent::read_rx_buffer_() { + int available = this->available(); + if (available == 0) + return; + + if (this->bytes_accumulated_ + available > this->rx_buffer_.size()) { + ESP_LOGW(TAG, "RX Buffer overflow. Frame too large! Dropping frame."); + this->bytes_accumulated_ = 0; + + this->flush_rx_buffer_(); + return; } - if (mbus_payload[DLMS_CIPHER_OFFSET] != GLO_CIPHERING) { // Only general-glo-ciphering is supported (0xDB) - ESP_LOGE(TAG, "DLMS: Unsupported cipher"); - this->receive_buffer_.clear(); - return false; + bool success = this->read_array(this->rx_buffer_.data() + this->bytes_accumulated_, available); + if (!success) { + ESP_LOGW(TAG, "UART read failed. Dropping frame."); + this->bytes_accumulated_ = 0; + this->flush_rx_buffer_(); + return; } - systitle_length = mbus_payload[DLMS_SYST_OFFSET]; + this->bytes_accumulated_ += available; - if (systitle_length != 0x08) { // Only system titles with length of 8 are supported - ESP_LOGE(TAG, "DLMS: Unsupported system title length"); - this->receive_buffer_.clear(); - return false; - } - - message_length = mbus_payload[DLMS_LENGTH_OFFSET]; - header_offset = 0; - - if (this->provider_ == PROVIDER_NETZNOE) { - // for some reason EVN seems to set the standard "length" field to 0x81 and then the actual length is in the next - // byte. Check some bytes to see if received data still matches expectation - if (message_length == NETZ_NOE_MAGIC_BYTE && - mbus_payload[DLMS_LENGTH_OFFSET + 1] == NETZ_NOE_EXPECTED_MESSAGE_LENGTH && - mbus_payload[DLMS_LENGTH_OFFSET + 2] == NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE) { - message_length = mbus_payload[DLMS_LENGTH_OFFSET + 1]; - header_offset = 1; - } else { - ESP_LOGE(TAG, "Wrong Length - Security Control Byte sequence detected for provider EVN"); - } - } else { - if (message_length == TWO_BYTE_LENGTH) { - message_length = encode_uint16(mbus_payload[DLMS_LENGTH_OFFSET + 1], mbus_payload[DLMS_LENGTH_OFFSET + 2]); - header_offset = DLMS_HEADER_EXT_OFFSET; - } - } - if (message_length < DLMS_LENGTH_CORRECTION) { - ESP_LOGE(TAG, "DLMS: Message length too short: %u", message_length); - this->receive_buffer_.clear(); - return false; - } - message_length -= DLMS_LENGTH_CORRECTION; // Correct message length due to part of header being included in length - - if (mbus_payload.size() - DLMS_HEADER_LENGTH - header_offset != message_length) { - ESP_LOGV(TAG, "DLMS: Length mismatch - payload=%d, header=%d, offset=%d, message=%d", mbus_payload.size(), - DLMS_HEADER_LENGTH, header_offset, message_length); - ESP_LOGE(TAG, "DLMS: Message has invalid length"); - this->receive_buffer_.clear(); - return false; - } - - if (mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != 0x21 && - mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != - 0x20) { // Only certain security suite is supported (0x21 || 0x20) - ESP_LOGE(TAG, "DLMS: Unsupported security control byte"); - this->receive_buffer_.clear(); - return false; - } - - return true; + this->last_rx_char_time_ = App.get_loop_component_start_time(); } -bool DlmsMeterComponent::decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, - uint16_t header_offset) { - ESP_LOGV(TAG, "Decrypting payload"); - uint8_t iv[12]; // Reserve space for the IV, always 12 bytes - // Copy system title to IV (System title is before length; no header offset needed!) - // Add 1 to the offset in order to skip the system title length byte - memcpy(&iv[0], &mbus_payload[DLMS_SYST_OFFSET + 1], systitle_length); - memcpy(&iv[8], &mbus_payload[header_offset + DLMS_FRAMECOUNTER_OFFSET], - DLMS_FRAMECOUNTER_LENGTH); // Copy frame counter to IV +void DlmsMeterComponent::process_frame_() { + ESP_LOGV(TAG, "Processing frame of size: %zu bytes", this->bytes_accumulated_); - uint8_t *payload_ptr = &mbus_payload[header_offset + DLMS_PAYLOAD_OFFSET]; + auto callback = [this](const char *obis_code, float float_val, const char *str_val, bool is_numeric) { + this->on_data_(obis_code, float_val, str_val, is_numeric); + }; -#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) - br_gcm_context gcm_ctx; - br_aes_ct_ctr_keys bc; - br_aes_ct_ctr_init(&bc, this->decryption_key_.data(), this->decryption_key_.size()); - br_gcm_init(&gcm_ctx, &bc.vtable, br_ghash_ctmul32); - br_gcm_reset(&gcm_ctx, iv, sizeof(iv)); - br_gcm_flip(&gcm_ctx); - br_gcm_run(&gcm_ctx, 0, payload_ptr, message_length); -#elif defined(USE_ESP32) -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) - // PSA Crypto multipart AEAD (no tag verification, matching legacy behavior) - psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; - psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); - psa_set_key_bits(&attributes, this->decryption_key_.size() * 8); - psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); - psa_set_key_algorithm(&attributes, PSA_ALG_GCM); + this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}, callback); - mbedtls_svc_key_id_t key_id; - bool decrypt_failed = true; - if (psa_import_key(&attributes, this->decryption_key_.data(), this->decryption_key_.size(), &key_id) == PSA_SUCCESS) { - psa_aead_operation_t op = PSA_AEAD_OPERATION_INIT; - if (psa_aead_decrypt_setup(&op, key_id, PSA_ALG_GCM) == PSA_SUCCESS && - psa_aead_set_nonce(&op, iv, sizeof(iv)) == PSA_SUCCESS) { - size_t outlen = 0; - if (psa_aead_update(&op, payload_ptr, message_length, payload_ptr, message_length, &outlen) == PSA_SUCCESS && - outlen == message_length) { - decrypt_failed = false; + this->bytes_accumulated_ = 0; +} + +void DlmsMeterComponent::on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric) { + int updated_count = 0; + +#ifdef USE_SENSOR + if (is_numeric) { + for (auto &item : this->sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(float_val); + updated_count++; } } - psa_aead_abort(&op); - psa_destroy_key(key_id); - } - if (decrypt_failed) { - ESP_LOGE(TAG, "Decryption failed"); - this->receive_buffer_.clear(); - return false; - } -#else - size_t outlen = 0; - mbedtls_gcm_context gcm_ctx; - mbedtls_gcm_init(&gcm_ctx); - mbedtls_gcm_setkey(&gcm_ctx, MBEDTLS_CIPHER_ID_AES, this->decryption_key_.data(), this->decryption_key_.size() * 8); - mbedtls_gcm_starts(&gcm_ctx, MBEDTLS_GCM_DECRYPT, iv, sizeof(iv)); - auto ret = mbedtls_gcm_update(&gcm_ctx, payload_ptr, message_length, payload_ptr, message_length, &outlen); - mbedtls_gcm_free(&gcm_ctx); - if (ret != 0) { - ESP_LOGE(TAG, "Decryption failed with error: %d", ret); - this->receive_buffer_.clear(); - return false; } #endif -#else -#error "Invalid Platform" + +#ifdef USE_TEXT_SENSOR + if (!is_numeric && str_val != nullptr) { + for (auto &item : this->text_sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(str_val); + updated_count++; + } + } + } #endif - if (payload_ptr[0] != DATA_NOTIFICATION || payload_ptr[5] != TIMESTAMP_DATETIME) { - ESP_LOGE(TAG, "OBIS: Packet was decrypted but data is invalid"); - this->receive_buffer_.clear(); - return false; - } - ESP_LOGV(TAG, "Decrypted payload: %d bytes", message_length); - return true; -} - -void DlmsMeterComponent::decode_obis_(uint8_t *plaintext, uint16_t message_length) { - ESP_LOGV(TAG, "Decoding payload"); - MeterData data{}; - uint16_t current_position = DECODER_START_OFFSET; - bool power_factor_found = false; - - while (current_position + OBIS_CODE_OFFSET <= message_length) { - if (plaintext[current_position + OBIS_TYPE_OFFSET] != DataType::OCTET_STRING) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS header type: %x", plaintext[current_position + OBIS_TYPE_OFFSET]); - this->receive_buffer_.clear(); - return; - } - - uint8_t obis_code_length = plaintext[current_position + OBIS_LENGTH_OFFSET]; - if (obis_code_length != OBIS_CODE_LENGTH_STANDARD && obis_code_length != OBIS_CODE_LENGTH_EXTENDED) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS header length: %x", obis_code_length); - this->receive_buffer_.clear(); - return; - } - if (current_position + OBIS_CODE_OFFSET + obis_code_length > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for OBIS code"); - this->receive_buffer_.clear(); - return; - } - - uint8_t *obis_code = &plaintext[current_position + OBIS_CODE_OFFSET]; - uint8_t obis_medium = obis_code[OBIS_A]; - uint16_t obis_cd = encode_uint16(obis_code[OBIS_C], obis_code[OBIS_D]); - - bool timestamp_found = false; - bool meter_number_found = false; - if (this->provider_ == PROVIDER_NETZNOE) { - // Do not advance Position when reading the Timestamp at DECODER_START_OFFSET - if ((obis_code_length == OBIS_CODE_LENGTH_EXTENDED) && (current_position == DECODER_START_OFFSET)) { - timestamp_found = true; - } else if (power_factor_found) { - meter_number_found = true; - power_factor_found = false; - } else { - current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code and position - } - } else { - current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code, position and type - } - if (!timestamp_found && !meter_number_found && obis_medium != Medium::ELECTRICITY && - obis_medium != Medium::ABSTRACT) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS medium: %x", obis_medium); - this->receive_buffer_.clear(); - return; - } - - if (current_position >= message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for data type"); - this->receive_buffer_.clear(); - return; - } - - float value = 0.0f; - uint8_t value_size = 0; - uint8_t data_type = plaintext[current_position]; - current_position++; - - switch (data_type) { - case DataType::DOUBLE_LONG_UNSIGNED: { - value_size = 4; - if (current_position + value_size > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for DOUBLE_LONG_UNSIGNED"); - this->receive_buffer_.clear(); - return; - } - value = encode_uint32(plaintext[current_position + 0], plaintext[current_position + 1], - plaintext[current_position + 2], plaintext[current_position + 3]); - current_position += value_size; - break; - } - case DataType::LONG_UNSIGNED: { - value_size = 2; - if (current_position + value_size > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for LONG_UNSIGNED"); - this->receive_buffer_.clear(); - return; - } - value = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); - current_position += value_size; - break; - } - case DataType::OCTET_STRING: { - uint8_t data_length = plaintext[current_position]; - current_position++; // Advance past string length - if (current_position + data_length > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for OCTET_STRING"); - this->receive_buffer_.clear(); - return; - } - // Handle timestamp (normal OBIS code or NETZNOE special case) - if (obis_cd == OBIS_TIMESTAMP || timestamp_found) { - if (data_length < 8) { - ESP_LOGE(TAG, "OBIS: Timestamp data too short: %u", data_length); - this->receive_buffer_.clear(); - return; - } - uint16_t year = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); - uint8_t month = plaintext[current_position + 2]; - uint8_t day = plaintext[current_position + 3]; - uint8_t hour = plaintext[current_position + 5]; - uint8_t minute = plaintext[current_position + 6]; - uint8_t second = plaintext[current_position + 7]; - if (year > 9999 || month > 12 || day > 31 || hour > 23 || minute > 59 || second > 59) { - ESP_LOGE(TAG, "Invalid timestamp values: %04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, minute, - second); - this->receive_buffer_.clear(); - return; - } - snprintf(data.timestamp, sizeof(data.timestamp), "%04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, - minute, second); - } else if (meter_number_found) { - snprintf(data.meternumber, sizeof(data.meternumber), "%.*s", data_length, &plaintext[current_position]); - } - current_position += data_length; - break; - } - default: - ESP_LOGE(TAG, "OBIS: Unsupported OBIS data type: %x", data_type); - this->receive_buffer_.clear(); - return; - } - - // Skip break after data - if (this->provider_ == PROVIDER_NETZNOE) { - // Don't skip the break on the first timestamp, as there's none - if (!timestamp_found) { - current_position += 2; - } - } else { - current_position += 2; - } - - // Check for additional data (scaler-unit structure) - if (current_position < message_length && plaintext[current_position] == DataType::INTEGER) { - // Apply scaler: real_value = raw_value × 10^scaler - if (current_position + 1 < message_length) { - int8_t scaler = static_cast(plaintext[current_position + 1]); - if (scaler != 0) { - value *= pow10_int(scaler); - } - } - - // on EVN Meters there is no additional break - if (this->provider_ == PROVIDER_NETZNOE) { - current_position += 4; - } else { - current_position += 6; - } - } - - // Handle numeric values (LONG_UNSIGNED and DOUBLE_LONG_UNSIGNED) - if (value_size > 0) { - switch (obis_cd) { - case OBIS_VOLTAGE_L1: - data.voltage_l1 = value; - break; - case OBIS_VOLTAGE_L2: - data.voltage_l2 = value; - break; - case OBIS_VOLTAGE_L3: - data.voltage_l3 = value; - break; - case OBIS_CURRENT_L1: - data.current_l1 = value; - break; - case OBIS_CURRENT_L2: - data.current_l2 = value; - break; - case OBIS_CURRENT_L3: - data.current_l3 = value; - break; - case OBIS_ACTIVE_POWER_PLUS: - data.active_power_plus = value; - break; - case OBIS_ACTIVE_POWER_MINUS: - data.active_power_minus = value; - break; - case OBIS_ACTIVE_ENERGY_PLUS: - data.active_energy_plus = value; - break; - case OBIS_ACTIVE_ENERGY_MINUS: - data.active_energy_minus = value; - break; - case OBIS_REACTIVE_ENERGY_PLUS: - data.reactive_energy_plus = value; - break; - case OBIS_REACTIVE_ENERGY_MINUS: - data.reactive_energy_minus = value; - break; - case OBIS_POWER_FACTOR: - data.power_factor = value; - power_factor_found = true; - break; - default: - ESP_LOGW(TAG, "Unsupported OBIS code 0x%04X", obis_cd); +#ifdef USE_BINARY_SENSOR + if (is_numeric) { + bool state = float_val != 0.0f; + for (auto &item : this->binary_sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(state); + updated_count++; } } } +#endif - this->receive_buffer_.clear(); - - ESP_LOGI(TAG, "Received valid data"); - this->publish_sensors(data); - this->status_clear_warning(); + if (updated_count == 0) { + ESP_LOGV(TAG, "Received OBIS %s, but no sensors are registered for it.", obis_code); + } } +#ifdef USE_SENSOR +void DlmsMeterComponent::register_sensor(const std::string &obis_code, sensor::Sensor *sensor) { + this->sensors_.push_back({obis_code, sensor}); +} +#endif +#ifdef USE_TEXT_SENSOR +void DlmsMeterComponent::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 DlmsMeterComponent::register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor) { + this->binary_sensors_.push_back({obis_code, sensor}); +} +#endif + } // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index c50e6f6b4d..cdc53d5685 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -2,95 +2,150 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.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 -#include "esphome/components/uart/uart.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif -#include "mbus.h" -#include "dlms.h" -#include "obis.h" +#include -#include #include +#include +#include +#include +#include + +#if __has_include() +#include +#elif !defined(USE_ESP8266) && __has_include() +#if __has_include() +#include +#endif +#include +#elif __has_include() +#include +#else +#define DLMS_METER_NO_CRYPTO +#endif + +#ifndef DLMS_MAX_SENSORS +static constexpr uint8_t DLMS_MAX_SENSORS = 0; +#endif +#ifndef DLMS_MAX_TEXT_SENSORS +static constexpr uint8_t DLMS_MAX_TEXT_SENSORS = 0; +#endif +#ifndef DLMS_MAX_BINARY_SENSORS +static constexpr uint8_t DLMS_MAX_BINARY_SENSORS = 0; +#endif namespace esphome::dlms_meter { -#ifndef DLMS_METER_SENSOR_LIST -#define DLMS_METER_SENSOR_LIST(F, SEP) -#endif - -#ifndef DLMS_METER_TEXT_SENSOR_LIST -#define DLMS_METER_TEXT_SENSOR_LIST(F, SEP) -#endif - -struct MeterData { - float voltage_l1 = 0.0f; // Voltage L1 - float voltage_l2 = 0.0f; // Voltage L2 - float voltage_l3 = 0.0f; // Voltage L3 - float current_l1 = 0.0f; // Current L1 - float current_l2 = 0.0f; // Current L2 - float current_l3 = 0.0f; // Current L3 - float active_power_plus = 0.0f; // Active power taken from grid - float active_power_minus = 0.0f; // Active power put into grid - float active_energy_plus = 0.0f; // Active energy taken from grid - float active_energy_minus = 0.0f; // Active energy put into grid - float reactive_energy_plus = 0.0f; // Reactive energy taken from grid - float reactive_energy_minus = 0.0f; // Reactive energy put into grid - char timestamp[27]{}; // Text sensor for the timestamp value - - // Netz NOE - float power_factor = 0.0f; // Power Factor - char meternumber[13]{}; // Text sensor for the meterNumber value +#ifdef DLMS_METER_NO_CRYPTO +// Fallback dummy decryptor for platforms without supported crypto (e.g., Zephyr during clang-tidy) +class Aes128GcmDecryptorDummy : public dlms_parser::Aes128GcmDecryptor { + public: + void set_decryption_key(const dlms_parser::Aes128GcmDecryptionKey &key) override {} + bool decrypt_in_place(std::span iv, std::span ciphertext_and_plaintext, + std::span aad, std::span tag) override { + return false; + } }; +#endif -// Provider constants -enum Providers : uint32_t { PROVIDER_GENERIC = 0x00, PROVIDER_NETZNOE = 0x01 }; +#if __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa; +#elif !defined(USE_ESP8266) && __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls; +#elif __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl; +#else +using Aes128GcmDecryptorImpl = Aes128GcmDecryptorDummy; +#endif + +#ifdef USE_SENSOR +struct SensorItem { + std::string obis_code; + sensor::Sensor *sensor; +}; +#endif +#ifdef USE_TEXT_SENSOR +struct TextSensorItem { + std::string obis_code; + text_sensor::TextSensor *sensor; +}; +#endif +#ifdef USE_BINARY_SENSOR +struct BinarySensorItem { + std::string obis_code; + binary_sensor::BinarySensor *sensor; +}; +#endif + +struct CustomPattern { + std::string pattern; + std::optional name; + int priority{0}; + std::optional> default_obis; +}; class DlmsMeterComponent : public Component, public uart::UARTDevice { public: - DlmsMeterComponent() = default; + DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, + std::optional> decryption_key, + std::optional> authentication_key, + std::vector custom_patterns); + void setup() override; void dump_config() override; void loop() override; - void set_decryption_key(const std::array &key) { this->decryption_key_ = key; } - void set_provider(uint32_t provider) { this->provider_ = provider; } - - void publish_sensors(MeterData &data) { -#define DLMS_METER_PUBLISH_SENSOR(s) \ - if (this->s##_sensor_ != nullptr) \ - s##_sensor_->publish_state(data.s); - DLMS_METER_SENSOR_LIST(DLMS_METER_PUBLISH_SENSOR, ) - -#define DLMS_METER_PUBLISH_TEXT_SENSOR(s) \ - if (this->s##_text_sensor_ != nullptr) \ - s##_text_sensor_->publish_state(data.s); - DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_PUBLISH_TEXT_SENSOR, ) - } - - DLMS_METER_SENSOR_LIST(SUB_SENSOR, ) - DLMS_METER_TEXT_SENSOR_LIST(SUB_TEXT_SENSOR, ) +#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: - bool parse_mbus_(std::vector &mbus_payload); - bool parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, uint8_t &systitle_length, - uint16_t &header_offset); - bool decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, - uint16_t header_offset); - void decode_obis_(uint8_t *plaintext, uint16_t message_length); + void read_rx_buffer_(); + void flush_rx_buffer_(); + void process_frame_(); + void on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric); - std::vector receive_buffer_; // Stores the packet currently being received - std::vector mbus_payload_; // Parsed M-Bus payload, reused to avoid heap churn - uint32_t last_read_ = 0; // Timestamp when data was last read - uint32_t read_timeout_ = 1000; // Time to wait after last byte before considering data complete + std::array rx_buffer_; + size_t bytes_accumulated_{0}; + uint32_t last_rx_char_time_{0}; - uint32_t provider_ = PROVIDER_GENERIC; // Provider of the meter / your grid operator - std::array decryption_key_; + uint32_t receive_timeout_ms_{1000}; + bool skip_crc_check_{false}; + + std::vector custom_patterns_; + + Aes128GcmDecryptorImpl decryptor_; + dlms_parser::DlmsParser parser_; + +#ifdef USE_SENSOR + StaticVector sensors_; +#endif +#ifdef USE_TEXT_SENSOR + StaticVector text_sensors_; +#endif +#ifdef USE_BINARY_SENSOR + StaticVector binary_sensors_; +#endif }; } // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/mbus.h b/esphome/components/dlms_meter/mbus.h deleted file mode 100644 index 293d43a55b..0000000000 --- a/esphome/components/dlms_meter/mbus.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -/* -+----------------------------------------------------+ - -| Start Character [0x68] | \ -+----------------------------------------------------+ | -| Data Length (L) | | -+----------------------------------------------------+ | -| Data Length Repeat (L) | | -+----------------------------------------------------+ > M-Bus Data link layer -| Start Character Repeat [0x68] | | -+----------------------------------------------------+ | -| Control/Function Field (C) | | -+----------------------------------------------------+ | -| Address Field (A) | / -+----------------------------------------------------+ - -| Control Information Field (CI) | \ -+----------------------------------------------------+ | -| Source Transport Service Access Point (STSAP) | > DLMS/COSEM M-Bus transport layer -+----------------------------------------------------+ | -| Destination Transport Service Access Point (DTSAP) | / -+----------------------------------------------------+ - -| | \ -~ ~ | - Data > DLMS/COSEM Application Layer -~ ~ | -| | / -+----------------------------------------------------+ - -| Checksum | \ -+----------------------------------------------------+ > M-Bus Data link layer -| Stop Character [0x16] | / -+----------------------------------------------------+ - - -Data_Length = L - C - A - CI -Each line (except Data) is one Byte - -Possible Values found in publicly available docs: -- C: 0x53/0x73 (SND_UD) -- A: FF (Broadcast) -- CI: 0x00-0x1F/0x60/0x61/0x7C/0x7D -- STSAP: 0x01 (Management Logical Device ID 1 of the meter) -- DTSAP: 0x67 (Consumer Information Push Client ID 103) - */ - -// MBUS start bytes for different telegram formats: -// - Single Character: 0xE5 (length=1) -// - Short Frame: 0x10 (length=5) -// - Control Frame: 0x68 (length=9) -// - Long Frame: 0x68 (length=9+data_length) -// This component currently only uses Long Frame. -static constexpr uint8_t START_BYTE_SINGLE_CHARACTER = 0xE5; -static constexpr uint8_t START_BYTE_SHORT_FRAME = 0x10; -static constexpr uint8_t START_BYTE_CONTROL_FRAME = 0x68; -static constexpr uint8_t START_BYTE_LONG_FRAME = 0x68; -static constexpr uint8_t MBUS_HEADER_INTRO_LENGTH = 4; // Header length for the intro (0x68, length, length, 0x68) -static constexpr uint8_t MBUS_FULL_HEADER_LENGTH = 9; // Total header length -static constexpr uint8_t MBUS_FOOTER_LENGTH = 2; // Footer after frame -static constexpr uint8_t MBUS_MAX_FRAME_LENGTH = 250; // Maximum size of frame -static constexpr uint8_t MBUS_START1_OFFSET = 0; // Offset of first start byte -static constexpr uint8_t MBUS_LENGTH1_OFFSET = 1; // Offset of first length byte -static constexpr uint8_t MBUS_LENGTH2_OFFSET = 2; // Offset of (duplicated) second length byte -static constexpr uint8_t MBUS_START2_OFFSET = 3; // Offset of (duplicated) second start byte -static constexpr uint8_t STOP_BYTE = 0x16; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/obis.h b/esphome/components/dlms_meter/obis.h deleted file mode 100644 index 1bb960e61e..0000000000 --- a/esphome/components/dlms_meter/obis.h +++ /dev/null @@ -1,94 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -// Data types as per specification -enum DataType { - NULL_DATA = 0x00, - BOOLEAN = 0x03, - BIT_STRING = 0x04, - DOUBLE_LONG = 0x05, - DOUBLE_LONG_UNSIGNED = 0x06, - OCTET_STRING = 0x09, - VISIBLE_STRING = 0x0A, - UTF8_STRING = 0x0C, - BINARY_CODED_DECIMAL = 0x0D, - INTEGER = 0x0F, - LONG = 0x10, - UNSIGNED = 0x11, - LONG_UNSIGNED = 0x12, - LONG64 = 0x14, - LONG64_UNSIGNED = 0x15, - ENUM = 0x16, - FLOAT32 = 0x17, - FLOAT64 = 0x18, - DATE_TIME = 0x19, - DATE = 0x1A, - TIME = 0x1B, - - ARRAY = 0x01, - STRUCTURE = 0x02, - COMPACT_ARRAY = 0x13 -}; - -enum Medium { - ABSTRACT = 0x00, - ELECTRICITY = 0x01, - HEAT_COST_ALLOCATOR = 0x04, - COOLING = 0x05, - HEAT = 0x06, - GAS = 0x07, - COLD_WATER = 0x08, - HOT_WATER = 0x09, - OIL = 0x10, - COMPRESSED_AIR = 0x11, - NITROGEN = 0x12 -}; - -// Data structure -static constexpr uint8_t DECODER_START_OFFSET = 20; // Skip header, timestamp and break block -static constexpr uint8_t OBIS_TYPE_OFFSET = 0; -static constexpr uint8_t OBIS_LENGTH_OFFSET = 1; -static constexpr uint8_t OBIS_CODE_OFFSET = 2; -static constexpr uint8_t OBIS_CODE_LENGTH_STANDARD = 0x06; // 6-byte OBIS code (A.B.C.D.E.F) -static constexpr uint8_t OBIS_CODE_LENGTH_EXTENDED = 0x0C; // 12-byte extended OBIS code -static constexpr uint8_t OBIS_A = 0; -static constexpr uint8_t OBIS_B = 1; -static constexpr uint8_t OBIS_C = 2; -static constexpr uint8_t OBIS_D = 3; -static constexpr uint8_t OBIS_E = 4; -static constexpr uint8_t OBIS_F = 5; - -// Metadata -static constexpr uint16_t OBIS_TIMESTAMP = 0x0100; -static constexpr uint16_t OBIS_SERIAL_NUMBER = 0x6001; -static constexpr uint16_t OBIS_DEVICE_NAME = 0x2A00; - -// Voltage -static constexpr uint16_t OBIS_VOLTAGE_L1 = 0x2007; -static constexpr uint16_t OBIS_VOLTAGE_L2 = 0x3407; -static constexpr uint16_t OBIS_VOLTAGE_L3 = 0x4807; - -// Current -static constexpr uint16_t OBIS_CURRENT_L1 = 0x1F07; -static constexpr uint16_t OBIS_CURRENT_L2 = 0x3307; -static constexpr uint16_t OBIS_CURRENT_L3 = 0x4707; - -// Power -static constexpr uint16_t OBIS_ACTIVE_POWER_PLUS = 0x0107; -static constexpr uint16_t OBIS_ACTIVE_POWER_MINUS = 0x0207; - -// Active energy -static constexpr uint16_t OBIS_ACTIVE_ENERGY_PLUS = 0x0108; -static constexpr uint16_t OBIS_ACTIVE_ENERGY_MINUS = 0x0208; - -// Reactive energy -static constexpr uint16_t OBIS_REACTIVE_ENERGY_PLUS = 0x0308; -static constexpr uint16_t OBIS_REACTIVE_ENERGY_MINUS = 0x0408; - -// Netz NOE specific -static constexpr uint16_t OBIS_POWER_FACTOR = 0x0D07; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index 27fd44f008..ec4639351d 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -1,8 +1,9 @@ +import logging + import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -16,109 +17,142 @@ from esphome.const import ( UNIT_WATT_HOURS, ) -from .. import CONF_DLMS_METER_ID, DlmsMeterComponent +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -AUTO_LOAD = ["dlms_meter"] +_LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.Schema( +DEPENDENCIES = ["dlms_meter"] + +NUMERIC_KEYS = { + "voltage_l1": "1.0.32.7.0.255", + "voltage_l2": "1.0.52.7.0.255", + "voltage_l3": "1.0.72.7.0.255", + "current_l1": "1.0.31.7.0.255", + "current_l2": "1.0.51.7.0.255", + "current_l3": "1.0.71.7.0.255", + "active_power_plus": "1.0.1.7.0.255", + "active_power_minus": "1.0.2.7.0.255", + "active_energy_plus": "1.0.1.8.0.255", + "active_energy_minus": "1.0.2.8.0.255", + "reactive_energy_plus": "1.0.3.8.0.255", + "reactive_energy_minus": "1.0.4.8.0.255", + "power_factor": "1.0.13.7.0.255", +} + +DYNAMIC_SCHEMA = sensor.sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("voltage_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("active_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - # Netz NOE - cv.Optional("power_factor"): sensor.sensor_schema( - accuracy_decimals=3, - device_class=DEVICE_CLASS_POWER_FACTOR, - state_class=STATE_CLASS_MEASUREMENT, - ), + cv.Required(CONF_OBIS_CODE): obis_code, } -).extend(cv.COMPONENT_SCHEMA) +) + + +def deprecation_warning(config): + _LOGGER.warning( + "The dlms_meter sensor schema using predefined keys (e.g., 'voltage_l1') is deprecated and will be removed in 2026.11.0. " + "Please update your configuration to use the new schema with 'obis_code'." + ) + return config + + +OLD_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("voltage_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("active_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("power_factor"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ).extend(cv.COMPONENT_SCHEMA), + deprecation_warning, +) + + +CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) async def to_code(config): hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - sensors = [] - for key, conf in config.items(): - if not isinstance(conf, dict): - continue - id = conf[CONF_ID] - if id and id.type == sensor.Sensor: - sens = await sensor.new_sensor(conf) - cg.add(getattr(hub, f"set_{key}_sensor")(sens)) - sensors.append(f"F({key})") - - if sensors: - cg.add_define( - "DLMS_METER_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors)) - ) + if obis := config.get(CONF_OBIS_CODE): + var = await sensor.new_sensor(config) + cg.add(hub.register_sensor(obis, var)) + else: + for key, obis_val in NUMERIC_KEYS.items(): + if sensor_config := config.get(key): + sens = await sensor.new_sensor(sensor_config) + cg.add(hub.register_sensor(obis_val, sens)) diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index 4d2373f4f9..0bfb43a285 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -1,37 +1,59 @@ +import logging + import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv -from esphome.const import CONF_ID -from .. import CONF_DLMS_METER_ID, DlmsMeterComponent +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -AUTO_LOAD = ["dlms_meter"] +_LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.Schema( +DEPENDENCIES = ["dlms_meter"] + +TEXT_KEYS = { + "timestamp": "0.0.1.0.0.255", + "meternumber": "0.0.96.1.0.255", +} + +DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("timestamp"): text_sensor.text_sensor_schema(), - # Netz NOE - cv.Optional("meternumber"): text_sensor.text_sensor_schema(), + cv.Required(CONF_OBIS_CODE): obis_code, } -).extend(cv.COMPONENT_SCHEMA) +) + + +def deprecation_warning(config): + _LOGGER.warning( + "The dlms_meter text_sensor schema using predefined keys (e.g., 'timestamp') is deprecated and will be removed in 2026.11.0. " + "Please update your configuration to use the new schema with 'obis_code'." + ) + return config + + +OLD_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("timestamp"): text_sensor.text_sensor_schema(), + cv.Optional("meternumber"): text_sensor.text_sensor_schema(), + } + ).extend(cv.COMPONENT_SCHEMA), + deprecation_warning, +) + + +CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) async def to_code(config): hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - text_sensors = [] - for key, conf in config.items(): - if not isinstance(conf, dict): - continue - id = conf[CONF_ID] - if id and id.type == text_sensor.TextSensor: - sens = await text_sensor.new_text_sensor(conf) - cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) - text_sensors.append(f"F({key})") - - if text_sensors: - cg.add_define( - "DLMS_METER_TEXT_SENSOR_LIST(F, sep)", - cg.RawExpression(" sep ".join(text_sensors)), - ) + if obis := config.get(CONF_OBIS_CODE): + var = await text_sensor.new_text_sensor(config) + cg.add(hub.register_text_sensor(obis, var)) + else: + for key, obis_val in TEXT_KEYS.items(): + if text_sensor_config := config.get(key): + sens = await text_sensor.new_text_sensor(text_sensor_config) + cg.add(hub.register_text_sensor(obis_val, sens)) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 4a4bc18579..7cbc2ac4ae 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -1,6 +1,8 @@ dependencies: bblanchon/arduinojson: version: "7.4.2" + esphome/dlms_parser: + version: 1.1.0 esphome/esp-audio-libs: version: 3.2.1 esphome/esp-micro-speech-features: diff --git a/platformio.ini b/platformio.ini index b41e850bcd..d60a4fd68d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -107,6 +107,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + esphome/dlms_parser@1.1.0 ; dlms_meter fastled/FastLED@3.9.16 ; fastled_base bblanchon/ArduinoJson@7.4.2 ; json ESP8266WiFi ; wifi (Arduino built-in) @@ -193,6 +194,7 @@ platform_packages = framework = arduino lib_deps = ${common:arduino.lib_deps} + esphome/dlms_parser@1.1.0 ; dlms_meter fastled/FastLED@3.9.16 ; fastled_base ayushsharma82/RPAsyncTCP@1.3.2 ; async_tcp bblanchon/ArduinoJson@7.4.2 ; json @@ -212,6 +214,7 @@ platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1 framework = arduino lib_compat_mode = soft lib_deps = + esphome/dlms_parser@1.1.0 ; dlms_meter bblanchon/ArduinoJson@7.4.2 ; json ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard @@ -236,6 +239,7 @@ build_flags = -DUSE_NRF52 lib_deps = ${common.lib_deps_base} + esphome/dlms_parser@1.1.0 ; dlms_meter bblanchon/ArduinoJson@7.4.2 ; json lvgl/lvgl@9.5.0 ; lvgl diff --git a/tests/components/dlms_meter/common-generic.yaml b/tests/components/dlms_meter/common-generic.yaml deleted file mode 100644 index edb1c66f0f..0000000000 --- a/tests/components/dlms_meter/common-generic.yaml +++ /dev/null @@ -1,11 +0,0 @@ -dlms_meter: - decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key! - -sensor: - - platform: dlms_meter - reactive_energy_plus: - name: "Reactive energy taken from grid" - reactive_energy_minus: - name: "Reactive energy put into grid" - -<<: !include common.yaml diff --git a/tests/components/dlms_meter/common-netznoe.yaml b/tests/components/dlms_meter/common-netznoe.yaml deleted file mode 100644 index db064b64f9..0000000000 --- a/tests/components/dlms_meter/common-netznoe.yaml +++ /dev/null @@ -1,17 +0,0 @@ -dlms_meter: - decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" # change this to your decryption key! - provider: netznoe # (optional) key - only set if using evn - -sensor: - - platform: dlms_meter - # EVN - power_factor: - name: "Power Factor" - -text_sensor: - - platform: dlms_meter - # EVN - meternumber: - name: "meterNumber" - -<<: !include common.yaml diff --git a/tests/components/dlms_meter/common.yaml b/tests/components/dlms_meter/common.yaml index 6aa4e1b0ff..59d854a3ae 100644 --- a/tests/components/dlms_meter/common.yaml +++ b/tests/components/dlms_meter/common.yaml @@ -1,4 +1,16 @@ +dlms_meter: + id: dlms_meter_hub + receive_timeout: 50ms + decryption_key: "36C66639E48A8CA4D6BC8B282A793BBB" + auth_key: "11223344556677889900AABBCCDDEEFF" + skip_crc: true + provider: "netznoe" + custom_patterns: + - "custom_pattern_1" + - "custom_pattern_2" + sensor: + # Old Schema tests - platform: dlms_meter voltage_l1: name: "Voltage L1" @@ -20,8 +32,36 @@ sensor: name: "Active energy taken from grid" active_energy_minus: name: "Active energy put into grid" + reactive_energy_plus: + name: "Reactive energy taken from grid" + reactive_energy_minus: + name: "Reactive energy put into grid" + power_factor: + name: "Power factor" + + # Dynamic Schema tests + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "1-0:99.99.9" + name: "Custom Dynamic Sensor" text_sensor: + # Old Schema tests - platform: dlms_meter timestamp: name: "timestamp" + meternumber: + name: "Meter Number" + + # Dynamic Schema tests + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "0-0:99.99.9" + name: "Custom Dynamic Text Sensor" + +binary_sensor: + # Dynamic Schema tests (Binary sensors only use the dynamic schema) + - platform: dlms_meter + dlms_meter_id: dlms_meter_hub + obis_code: "0-1:2.3.4" + name: "Custom Binary Sensor" diff --git a/tests/components/dlms_meter/test.esp32-ard.yaml b/tests/components/dlms_meter/test.esp32-ard.yaml index c9910aa600..bd11a44373 100644 --- a/tests/components/dlms_meter/test.esp32-ard.yaml +++ b/tests/components/dlms_meter/test.esp32-ard.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp32-ard.yaml + uart: !include ../../test_build_components/common/uart/esp32-ard.yaml -<<: !include common-generic.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.esp32-idf.yaml b/tests/components/dlms_meter/test.esp32-idf.yaml index 1547532f1e..2d29656c94 100644 --- a/tests/components/dlms_meter/test.esp32-idf.yaml +++ b/tests/components/dlms_meter/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp32-idf.yaml + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml -<<: !include common-netznoe.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.esp8266-ard.yaml b/tests/components/dlms_meter/test.esp8266-ard.yaml index 119a1978de..5a05efa259 100644 --- a/tests/components/dlms_meter/test.esp8266-ard.yaml +++ b/tests/components/dlms_meter/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: - uart_2400: !include ../../test_build_components/common/uart_2400/esp8266-ard.yaml + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml -<<: !include common-generic.yaml +<<: !include common.yaml diff --git a/tests/components/dlms_meter/test.rp2040-ard.yaml b/tests/components/dlms_meter/test.rp2040-ard.yaml new file mode 100644 index 0000000000..f1df2daf83 --- /dev/null +++ b/tests/components/dlms_meter/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + +<<: !include common.yaml