mirror of
https://github.com/esphome/esphome.git
synced 2026-02-08 00:31:58 +00:00
[dlms_meter] Add dlms smart meter component (#8009)
Co-authored-by: Thomas Rupprecht <rupprecht.thomas@gmail.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,7 @@ esphome/components/dfplayer/* @glmnet
|
|||||||
esphome/components/dfrobot_sen0395/* @niklasweber
|
esphome/components/dfrobot_sen0395/* @niklasweber
|
||||||
esphome/components/dht/* @OttoWinter
|
esphome/components/dht/* @OttoWinter
|
||||||
esphome/components/display_menu_base/* @numo68
|
esphome/components/display_menu_base/* @numo68
|
||||||
|
esphome/components/dlms_meter/* @SimonFischer04
|
||||||
esphome/components/dps310/* @kbx81
|
esphome/components/dps310/* @kbx81
|
||||||
esphome/components/ds1307/* @badbadc0ffee
|
esphome/components/ds1307/* @badbadc0ffee
|
||||||
esphome/components/ds2484/* @mrk-its
|
esphome/components/ds2484/* @mrk-its
|
||||||
|
|||||||
57
esphome/components/dlms_meter/__init__.py
Normal file
57
esphome/components/dlms_meter/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
CODEOWNERS = ["@SimonFischer04"]
|
||||||
|
DEPENDENCIES = ["uart"]
|
||||||
|
|
||||||
|
CONF_DLMS_METER_ID = "dlms_meter_id"
|
||||||
|
CONF_DECRYPTION_KEY = "decryption_key"
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.extend(uart.UART_DEVICE_SCHEMA)
|
||||||
|
.extend(cv.COMPONENT_SCHEMA),
|
||||||
|
cv.only_on([PLATFORM_ESP8266, PLATFORM_ESP32]),
|
||||||
|
)
|
||||||
|
|
||||||
|
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||||
|
"dlms_meter", baud_rate=2400, require_rx=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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]]))
|
||||||
71
esphome/components/dlms_meter/dlms.h
Normal file
71
esphome/components/dlms_meter/dlms.h
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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
|
||||||
468
esphome/components/dlms_meter/dlms_meter.cpp
Normal file
468
esphome/components/dlms_meter/dlms_meter.cpp
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
#include "dlms_meter.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#if defined(USE_ESP8266_FRAMEWORK_ARDUINO)
|
||||||
|
#include <bearssl/bearssl.h>
|
||||||
|
#elif defined(USE_ESP32)
|
||||||
|
#include "mbedtls/esp_config.h"
|
||||||
|
#include "mbedtls/gcm.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace esphome::dlms_meter {
|
||||||
|
|
||||||
|
static constexpr const char *TAG = "dlms_meter";
|
||||||
|
|
||||||
|
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: %u 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, )
|
||||||
|
}
|
||||||
|
|
||||||
|
void DlmsMeterComponent::loop() {
|
||||||
|
// Read while data is available, netznoe uses two frames so allow 2x max frame length
|
||||||
|
while (this->available()) {
|
||||||
|
if (this->receive_buffer_.size() >= MBUS_MAX_FRAME_LENGTH * 2) {
|
||||||
|
ESP_LOGW(TAG, "Receive buffer full, dropping remaining bytes");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
uint8_t c;
|
||||||
|
this->read_byte(&c);
|
||||||
|
this->receive_buffer_.push_back(c);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DlmsMeterComponent::parse_mbus_(std::vector<uint8_t> &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;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DlmsMeterComponent::parse_dlms_(const std::vector<uint8_t> &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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
systitle_length = mbus_payload[DLMS_SYST_OFFSET];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DlmsMeterComponent::decrypt_(std::vector<uint8_t> &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
|
||||||
|
|
||||||
|
uint8_t *payload_ptr = &mbus_payload[header_offset + DLMS_PAYLOAD_OFFSET];
|
||||||
|
|
||||||
|
#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)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
#error "Invalid Platform"
|
||||||
|
#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<int8_t>(plaintext[current_position + 1]);
|
||||||
|
if (scaler != 0) {
|
||||||
|
value *= powf(10.0f, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this->receive_buffer_.clear();
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Received valid data");
|
||||||
|
this->publish_sensors(data);
|
||||||
|
this->status_clear_warning();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace esphome::dlms_meter
|
||||||
96
esphome/components/dlms_meter/dlms_meter.h
Normal file
96
esphome/components/dlms_meter/dlms_meter.h
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esphome/core/component.h"
|
||||||
|
#include "esphome/core/defines.h"
|
||||||
|
#include "esphome/core/log.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"
|
||||||
|
|
||||||
|
#include "mbus.h"
|
||||||
|
#include "dlms.h"
|
||||||
|
#include "obis.h"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provider constants
|
||||||
|
enum Providers : uint32_t { PROVIDER_GENERIC = 0x00, PROVIDER_NETZNOE = 0x01 };
|
||||||
|
|
||||||
|
class DlmsMeterComponent : public Component, public uart::UARTDevice {
|
||||||
|
public:
|
||||||
|
DlmsMeterComponent() = default;
|
||||||
|
|
||||||
|
void dump_config() override;
|
||||||
|
void loop() override;
|
||||||
|
|
||||||
|
void set_decryption_key(const std::array<uint8_t, 16> &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, )
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool parse_mbus_(std::vector<uint8_t> &mbus_payload);
|
||||||
|
bool parse_dlms_(const std::vector<uint8_t> &mbus_payload, uint16_t &message_length, uint8_t &systitle_length,
|
||||||
|
uint16_t &header_offset);
|
||||||
|
bool decrypt_(std::vector<uint8_t> &mbus_payload, uint16_t message_length, uint8_t systitle_length,
|
||||||
|
uint16_t header_offset);
|
||||||
|
void decode_obis_(uint8_t *plaintext, uint16_t message_length);
|
||||||
|
|
||||||
|
std::vector<uint8_t> receive_buffer_; // Stores the packet currently being received
|
||||||
|
std::vector<uint8_t> 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
|
||||||
|
|
||||||
|
uint32_t provider_ = PROVIDER_GENERIC; // Provider of the meter / your grid operator
|
||||||
|
std::array<uint8_t, 16> decryption_key_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace esphome::dlms_meter
|
||||||
69
esphome/components/dlms_meter/mbus.h
Normal file
69
esphome/components/dlms_meter/mbus.h
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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
|
||||||
94
esphome/components/dlms_meter/obis.h
Normal file
94
esphome/components/dlms_meter/obis.h
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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
|
||||||
124
esphome/components/dlms_meter/sensor/__init__.py
Normal file
124
esphome/components/dlms_meter/sensor/__init__.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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,
|
||||||
|
DEVICE_CLASS_POWER_FACTOR,
|
||||||
|
DEVICE_CLASS_VOLTAGE,
|
||||||
|
STATE_CLASS_MEASUREMENT,
|
||||||
|
STATE_CLASS_TOTAL_INCREASING,
|
||||||
|
UNIT_AMPERE,
|
||||||
|
UNIT_VOLT,
|
||||||
|
UNIT_WATT,
|
||||||
|
UNIT_WATT_HOURS,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .. import CONF_DLMS_METER_ID, DlmsMeterComponent
|
||||||
|
|
||||||
|
AUTO_LOAD = ["dlms_meter"]
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = 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,
|
||||||
|
),
|
||||||
|
# Netz NOE
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
|
)
|
||||||
37
esphome/components/dlms_meter/text_sensor/__init__.py
Normal file
37
esphome/components/dlms_meter/text_sensor/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
AUTO_LOAD = ["dlms_meter"]
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.Schema(
|
||||||
|
{
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
).extend(cv.COMPONENT_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)),
|
||||||
|
)
|
||||||
11
tests/components/dlms_meter/common-generic.yaml
Normal file
11
tests/components/dlms_meter/common-generic.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
17
tests/components/dlms_meter/common-netznoe.yaml
Normal file
17
tests/components/dlms_meter/common-netznoe.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
27
tests/components/dlms_meter/common.yaml
Normal file
27
tests/components/dlms_meter/common.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
sensor:
|
||||||
|
- platform: dlms_meter
|
||||||
|
voltage_l1:
|
||||||
|
name: "Voltage L1"
|
||||||
|
voltage_l2:
|
||||||
|
name: "Voltage L2"
|
||||||
|
voltage_l3:
|
||||||
|
name: "Voltage L3"
|
||||||
|
current_l1:
|
||||||
|
name: "Current L1"
|
||||||
|
current_l2:
|
||||||
|
name: "Current L2"
|
||||||
|
current_l3:
|
||||||
|
name: "Current L3"
|
||||||
|
active_power_plus:
|
||||||
|
name: "Active power taken from grid"
|
||||||
|
active_power_minus:
|
||||||
|
name: "Active power put into grid"
|
||||||
|
active_energy_plus:
|
||||||
|
name: "Active energy taken from grid"
|
||||||
|
active_energy_minus:
|
||||||
|
name: "Active energy put into grid"
|
||||||
|
|
||||||
|
text_sensor:
|
||||||
|
- platform: dlms_meter
|
||||||
|
timestamp:
|
||||||
|
name: "timestamp"
|
||||||
4
tests/components/dlms_meter/test.esp32-ard.yaml
Normal file
4
tests/components/dlms_meter/test.esp32-ard.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
packages:
|
||||||
|
uart: !include ../../test_build_components/common/uart_2400/esp32-ard.yaml
|
||||||
|
|
||||||
|
<<: !include common-generic.yaml
|
||||||
4
tests/components/dlms_meter/test.esp32-idf.yaml
Normal file
4
tests/components/dlms_meter/test.esp32-idf.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
packages:
|
||||||
|
uart: !include ../../test_build_components/common/uart_2400/esp32-idf.yaml
|
||||||
|
|
||||||
|
<<: !include common-netznoe.yaml
|
||||||
4
tests/components/dlms_meter/test.esp8266-ard.yaml
Normal file
4
tests/components/dlms_meter/test.esp8266-ard.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
packages:
|
||||||
|
uart: !include ../../test_build_components/common/uart_2400/esp8266-ard.yaml
|
||||||
|
|
||||||
|
<<: !include common-generic.yaml
|
||||||
11
tests/test_build_components/common/uart_2400/esp32-ard.yaml
Normal file
11
tests/test_build_components/common/uart_2400/esp32-ard.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Common UART configuration for ESP32 Arduino tests - 2400 baud
|
||||||
|
|
||||||
|
substitutions:
|
||||||
|
tx_pin: GPIO17
|
||||||
|
rx_pin: GPIO16
|
||||||
|
|
||||||
|
uart:
|
||||||
|
- id: uart_bus
|
||||||
|
tx_pin: ${tx_pin}
|
||||||
|
rx_pin: ${rx_pin}
|
||||||
|
baud_rate: 2400
|
||||||
11
tests/test_build_components/common/uart_2400/esp32-idf.yaml
Normal file
11
tests/test_build_components/common/uart_2400/esp32-idf.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Common UART configuration for ESP32 IDF tests - 2400 baud
|
||||||
|
|
||||||
|
substitutions:
|
||||||
|
tx_pin: GPIO17
|
||||||
|
rx_pin: GPIO16
|
||||||
|
|
||||||
|
uart:
|
||||||
|
- id: uart_bus
|
||||||
|
tx_pin: ${tx_pin}
|
||||||
|
rx_pin: ${rx_pin}
|
||||||
|
baud_rate: 2400
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Common UART configuration for ESP8266 Arduino tests - 2400 baud
|
||||||
|
|
||||||
|
substitutions:
|
||||||
|
tx_pin: GPIO4
|
||||||
|
rx_pin: GPIO5
|
||||||
|
|
||||||
|
uart:
|
||||||
|
- id: uart_bus
|
||||||
|
tx_pin: ${tx_pin}
|
||||||
|
rx_pin: ${rx_pin}
|
||||||
|
baud_rate: 2400
|
||||||
Reference in New Issue
Block a user