mirror of
https://github.com/esphome/esphome.git
synced 2026-01-31 08:52:07 -07:00
Compare commits
6 Commits
http_reque
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ff2f3b6a3 | ||
|
|
891382a32e | ||
|
|
0fd50b2381 | ||
|
|
9dcb469460 | ||
|
|
5e3561d60b | ||
|
|
ca9ed369f9 |
@@ -134,6 +134,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/dps310/* @kbx81
|
||||
esphome/components/ds1307/* @badbadc0ffee
|
||||
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)),
|
||||
)
|
||||
@@ -1329,6 +1329,10 @@ async def to_code(config):
|
||||
# Disable dynamic log level control to save memory
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False)
|
||||
|
||||
# Disable per-tag log level filtering since dynamic level control is disabled above
|
||||
# This saves ~250 bytes of RAM (tag cache) and associated code
|
||||
add_idf_sdkconfig_option("CONFIG_LOG_TAG_LEVEL_IMPL_NONE", True)
|
||||
|
||||
# Reduce PHY TX power in the event of a brownout
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True)
|
||||
|
||||
|
||||
@@ -196,14 +196,11 @@ bool Esp32HostedUpdate::fetch_manifest_() {
|
||||
int read_or_error = container->read(buf, sizeof(buf));
|
||||
App.feed_wdt();
|
||||
yield();
|
||||
auto result =
|
||||
http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete());
|
||||
auto result = http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout);
|
||||
if (result == http_request::HttpReadLoopResult::RETRY)
|
||||
continue;
|
||||
// Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length,
|
||||
// but this is defensive code in case chunked transfer encoding support is added in the future.
|
||||
if (result != http_request::HttpReadLoopResult::DATA)
|
||||
break; // COMPLETE, ERROR, or TIMEOUT
|
||||
break; // ERROR or TIMEOUT
|
||||
json_str.append(reinterpret_cast<char *>(buf), read_or_error);
|
||||
}
|
||||
container->end();
|
||||
@@ -324,14 +321,9 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
|
||||
App.feed_wdt();
|
||||
yield();
|
||||
|
||||
auto result =
|
||||
http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete());
|
||||
auto result = http_request::http_read_loop_result(read_or_error, last_data_time, read_timeout);
|
||||
if (result == http_request::HttpReadLoopResult::RETRY)
|
||||
continue;
|
||||
// Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length,
|
||||
// but this is defensive code in case chunked transfer encoding support is added in the future.
|
||||
if (result == http_request::HttpReadLoopResult::COMPLETE)
|
||||
break;
|
||||
if (result != http_request::HttpReadLoopResult::DATA) {
|
||||
if (result == http_request::HttpReadLoopResult::TIMEOUT) {
|
||||
ESP_LOGE(TAG, "Timeout reading firmware data");
|
||||
|
||||
@@ -26,7 +26,6 @@ struct Header {
|
||||
enum HttpStatus {
|
||||
HTTP_STATUS_OK = 200,
|
||||
HTTP_STATUS_NO_CONTENT = 204,
|
||||
HTTP_STATUS_RESET_CONTENT = 205,
|
||||
HTTP_STATUS_PARTIAL_CONTENT = 206,
|
||||
|
||||
/* 3xx - Redirection */
|
||||
@@ -127,21 +126,19 @@ struct HttpReadResult {
|
||||
|
||||
/// Result of processing a non-blocking read with timeout (for manual loops)
|
||||
enum class HttpReadLoopResult : uint8_t {
|
||||
DATA, ///< Data was read, process it
|
||||
COMPLETE, ///< All content has been read, caller should exit loop
|
||||
RETRY, ///< No data yet, already delayed, caller should continue loop
|
||||
ERROR, ///< Read error, caller should exit loop
|
||||
TIMEOUT, ///< Timeout waiting for data, caller should exit loop
|
||||
DATA, ///< Data was read, process it
|
||||
RETRY, ///< No data yet, already delayed, caller should continue loop
|
||||
ERROR, ///< Read error, caller should exit loop
|
||||
TIMEOUT, ///< Timeout waiting for data, caller should exit loop
|
||||
};
|
||||
|
||||
/// Process a read result with timeout tracking and delay handling
|
||||
/// @param bytes_read_or_error Return value from read() - positive for bytes read, negative for error
|
||||
/// @param last_data_time Time of last successful read, updated when data received
|
||||
/// @param timeout_ms Maximum time to wait for data
|
||||
/// @param is_read_complete Whether all expected content has been read (from HttpContainer::is_read_complete())
|
||||
/// @return How the caller should proceed - see HttpReadLoopResult enum
|
||||
inline HttpReadLoopResult http_read_loop_result(int bytes_read_or_error, uint32_t &last_data_time, uint32_t timeout_ms,
|
||||
bool is_read_complete) {
|
||||
/// @return DATA if data received, RETRY if should continue loop, ERROR/TIMEOUT if should exit
|
||||
inline HttpReadLoopResult http_read_loop_result(int bytes_read_or_error, uint32_t &last_data_time,
|
||||
uint32_t timeout_ms) {
|
||||
if (bytes_read_or_error > 0) {
|
||||
last_data_time = millis();
|
||||
return HttpReadLoopResult::DATA;
|
||||
@@ -149,10 +146,7 @@ inline HttpReadLoopResult http_read_loop_result(int bytes_read_or_error, uint32_
|
||||
if (bytes_read_or_error < 0) {
|
||||
return HttpReadLoopResult::ERROR;
|
||||
}
|
||||
// bytes_read_or_error == 0: either "no data yet" or "all content read"
|
||||
if (is_read_complete) {
|
||||
return HttpReadLoopResult::COMPLETE;
|
||||
}
|
||||
// bytes_read_or_error == 0: no data available yet
|
||||
if (millis() - last_data_time >= timeout_ms) {
|
||||
return HttpReadLoopResult::TIMEOUT;
|
||||
}
|
||||
@@ -165,9 +159,9 @@ class HttpRequestComponent;
|
||||
class HttpContainer : public Parented<HttpRequestComponent> {
|
||||
public:
|
||||
virtual ~HttpContainer() = default;
|
||||
size_t content_length{0};
|
||||
int status_code{-1}; ///< -1 indicates no response received yet
|
||||
uint32_t duration_ms{0};
|
||||
size_t content_length;
|
||||
int status_code;
|
||||
uint32_t duration_ms;
|
||||
|
||||
/**
|
||||
* @brief Read data from the HTTP response body.
|
||||
@@ -200,24 +194,9 @@ class HttpContainer : public Parented<HttpRequestComponent> {
|
||||
virtual void end() = 0;
|
||||
|
||||
void set_secure(bool secure) { this->secure_ = secure; }
|
||||
void set_chunked(bool chunked) { this->is_chunked_ = chunked; }
|
||||
|
||||
size_t get_bytes_read() const { return this->bytes_read_; }
|
||||
|
||||
/// Check if all expected content has been read
|
||||
/// For chunked responses, returns false (completion detected via read() returning error/EOF)
|
||||
bool is_read_complete() const {
|
||||
// Per RFC 9112, these responses have no body:
|
||||
// - 1xx (Informational), 204 No Content, 205 Reset Content, 304 Not Modified
|
||||
if ((this->status_code >= 100 && this->status_code < 200) || this->status_code == HTTP_STATUS_NO_CONTENT ||
|
||||
this->status_code == HTTP_STATUS_RESET_CONTENT || this->status_code == HTTP_STATUS_NOT_MODIFIED) {
|
||||
return true;
|
||||
}
|
||||
// For non-chunked responses, complete when bytes_read >= content_length
|
||||
// This handles both Content-Length: 0 and Content-Length: N cases
|
||||
return !this->is_chunked_ && this->bytes_read_ >= this->content_length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get response headers.
|
||||
*
|
||||
@@ -230,7 +209,6 @@ class HttpContainer : public Parented<HttpRequestComponent> {
|
||||
protected:
|
||||
size_t bytes_read_{0};
|
||||
bool secure_{false};
|
||||
bool is_chunked_{false}; ///< True if response uses chunked transfer encoding
|
||||
std::map<std::string, std::list<std::string>> response_headers_{};
|
||||
};
|
||||
|
||||
@@ -241,7 +219,7 @@ class HttpContainer : public Parented<HttpRequestComponent> {
|
||||
/// @param total_size Total bytes to read
|
||||
/// @param chunk_size Maximum bytes per read call
|
||||
/// @param timeout_ms Read timeout in milliseconds
|
||||
/// @return HttpReadResult with status and error_code on failure; use container->get_bytes_read() for total bytes read
|
||||
/// @return HttpReadResult with status and error_code on failure
|
||||
inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer, size_t total_size, size_t chunk_size,
|
||||
uint32_t timeout_ms) {
|
||||
size_t read_index = 0;
|
||||
@@ -253,11 +231,9 @@ inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer,
|
||||
App.feed_wdt();
|
||||
yield();
|
||||
|
||||
auto result = http_read_loop_result(read_bytes_or_error, last_data_time, timeout_ms, container->is_read_complete());
|
||||
auto result = http_read_loop_result(read_bytes_or_error, last_data_time, timeout_ms);
|
||||
if (result == HttpReadLoopResult::RETRY)
|
||||
continue;
|
||||
if (result == HttpReadLoopResult::COMPLETE)
|
||||
break; // Server sent less data than requested, but transfer is complete
|
||||
if (result == HttpReadLoopResult::ERROR)
|
||||
return {HttpReadStatus::ERROR, read_bytes_or_error};
|
||||
if (result == HttpReadLoopResult::TIMEOUT)
|
||||
@@ -417,12 +393,11 @@ template<typename... Ts> class HttpRequestSendAction : public Action<Ts...> {
|
||||
int read_or_error = container->read(buf + read_index, std::min<size_t>(max_length - read_index, 512));
|
||||
App.feed_wdt();
|
||||
yield();
|
||||
auto result =
|
||||
http_read_loop_result(read_or_error, last_data_time, read_timeout, container->is_read_complete());
|
||||
auto result = http_read_loop_result(read_or_error, last_data_time, read_timeout);
|
||||
if (result == HttpReadLoopResult::RETRY)
|
||||
continue;
|
||||
if (result != HttpReadLoopResult::DATA)
|
||||
break; // COMPLETE, ERROR, or TIMEOUT
|
||||
break; // ERROR or TIMEOUT
|
||||
read_index += read_or_error;
|
||||
}
|
||||
response_body.reserve(read_index);
|
||||
|
||||
@@ -135,23 +135,9 @@ std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &ur
|
||||
// When cast to size_t, -1 becomes SIZE_MAX (4294967295 on 32-bit).
|
||||
// The read() method handles this: bytes_read_ can never reach SIZE_MAX, so the
|
||||
// early return check (bytes_read_ >= content_length) will never trigger.
|
||||
//
|
||||
// TODO: Chunked transfer encoding is NOT properly supported on Arduino.
|
||||
// The implementation in #7884 was incomplete - it only works correctly on ESP-IDF where
|
||||
// esp_http_client_read() decodes chunks internally. On Arduino, using getStreamPtr()
|
||||
// returns raw TCP data with chunk framing (e.g., "12a\r\n{json}\r\n0\r\n\r\n") instead
|
||||
// of decoded content. This wasn't noticed because requests would complete and payloads
|
||||
// were only examined on IDF. The long transfer times were also masked by the misleading
|
||||
// "HTTP on Arduino version >= 3.1 is **very** slow" warning above. This causes two issues:
|
||||
// 1. Response body is corrupted - contains chunk size headers mixed with data
|
||||
// 2. Cannot detect end of transfer - connection stays open (keep-alive), causing timeout
|
||||
// The proper fix would be to use getString() for chunked responses, which decodes chunks
|
||||
// internally, but this buffers the entire response in memory.
|
||||
int content_length = container->client_.getSize();
|
||||
ESP_LOGD(TAG, "Content-Length: %d", content_length);
|
||||
container->content_length = (size_t) content_length;
|
||||
// -1 (SIZE_MAX when cast to size_t) means chunked transfer encoding
|
||||
container->set_chunked(content_length == -1);
|
||||
container->duration_ms = millis() - start;
|
||||
|
||||
return container;
|
||||
@@ -192,9 +178,9 @@ int HttpContainerArduino::read(uint8_t *buf, size_t max_len) {
|
||||
|
||||
if (bufsize == 0) {
|
||||
this->duration_ms += (millis() - start);
|
||||
// Check if we've read all expected content (non-chunked only)
|
||||
// For chunked encoding (content_length == SIZE_MAX), is_read_complete() returns false
|
||||
if (this->is_read_complete()) {
|
||||
// Check if we've read all expected content (only valid when content_length is known and not SIZE_MAX)
|
||||
// For chunked encoding (content_length == SIZE_MAX), we can't use this check
|
||||
if (this->content_length > 0 && this->bytes_read_ >= this->content_length) {
|
||||
return 0; // All content read successfully
|
||||
}
|
||||
// No data available - check if connection is still open
|
||||
|
||||
@@ -160,7 +160,6 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
|
||||
// esp_http_client_fetch_headers() returns 0 for chunked transfer encoding (no Content-Length header).
|
||||
// The read() method handles content_length == 0 specially to support chunked responses.
|
||||
container->content_length = esp_http_client_fetch_headers(client);
|
||||
container->set_chunked(esp_http_client_is_chunked_response(client));
|
||||
container->feed_wdt();
|
||||
container->status_code = esp_http_client_get_status_code(client);
|
||||
container->feed_wdt();
|
||||
@@ -196,7 +195,6 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
|
||||
|
||||
container->feed_wdt();
|
||||
container->content_length = esp_http_client_fetch_headers(client);
|
||||
container->set_chunked(esp_http_client_is_chunked_response(client));
|
||||
container->feed_wdt();
|
||||
container->status_code = esp_http_client_get_status_code(client);
|
||||
container->feed_wdt();
|
||||
@@ -241,9 +239,10 @@ int HttpContainerIDF::read(uint8_t *buf, size_t max_len) {
|
||||
const uint32_t start = millis();
|
||||
watchdog::WatchdogManager wdm(this->parent_->get_watchdog_timeout());
|
||||
|
||||
// Check if we've already read all expected content (non-chunked only)
|
||||
// For chunked responses (content_length == 0), esp_http_client_read() handles EOF
|
||||
if (this->is_read_complete()) {
|
||||
// Check if we've already read all expected content
|
||||
// Skip this check when content_length is 0 (chunked transfer encoding or unknown length)
|
||||
// For chunked responses, esp_http_client_read() will return 0 when all data is received
|
||||
if (this->content_length > 0 && this->bytes_read_ >= this->content_length) {
|
||||
return 0; // All content read successfully
|
||||
}
|
||||
|
||||
|
||||
@@ -130,13 +130,9 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
|
||||
App.feed_wdt();
|
||||
yield();
|
||||
|
||||
auto result = http_read_loop_result(bufsize_or_error, last_data_time, read_timeout, container->is_read_complete());
|
||||
auto result = http_read_loop_result(bufsize_or_error, last_data_time, read_timeout);
|
||||
if (result == HttpReadLoopResult::RETRY)
|
||||
continue;
|
||||
// Note: COMPLETE is currently unreachable since the loop condition checks bytes_read < content_length,
|
||||
// but this is defensive code in case chunked transfer encoding support is added for OTA in the future.
|
||||
if (result == HttpReadLoopResult::COMPLETE)
|
||||
break;
|
||||
if (result != HttpReadLoopResult::DATA) {
|
||||
if (result == HttpReadLoopResult::TIMEOUT) {
|
||||
ESP_LOGE(TAG, "Timeout reading data");
|
||||
|
||||
@@ -11,12 +11,6 @@ namespace i2c {
|
||||
static const char *const TAG = "i2c";
|
||||
|
||||
void I2CBus::i2c_scan_() {
|
||||
// suppress logs from the IDF I2C library during the scan
|
||||
#if defined(USE_ESP32) && defined(USE_LOGGER)
|
||||
auto previous = esp_log_level_get("*");
|
||||
esp_log_level_set("*", ESP_LOG_NONE);
|
||||
#endif
|
||||
|
||||
for (uint8_t address = 8; address != 120; address++) {
|
||||
auto err = write_readv(address, nullptr, 0, nullptr, 0);
|
||||
if (err == ERROR_OK) {
|
||||
@@ -27,9 +21,6 @@ void I2CBus::i2c_scan_() {
|
||||
// it takes 16sec to scan on nrf52. It prevents board reset.
|
||||
arch_feed_wdt();
|
||||
}
|
||||
#if defined(USE_ESP32) && defined(USE_LOGGER)
|
||||
esp_log_level_set("*", previous);
|
||||
#endif
|
||||
}
|
||||
|
||||
ErrorCode I2CDevice::read_register(uint8_t a_register, uint8_t *data, size_t len) {
|
||||
|
||||
@@ -114,9 +114,6 @@ void Logger::pre_setup() {
|
||||
|
||||
global_logger = this;
|
||||
esp_log_set_vprintf(esp_idf_log_vprintf_);
|
||||
if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE) {
|
||||
esp_log_level_set("*", ESP_LOG_VERBOSE);
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Log initialized");
|
||||
}
|
||||
|
||||
@@ -28,11 +28,10 @@ CONFIG_SCHEMA = (
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(config[CONF_ID], config[CONF_NUM_CHIPS])
|
||||
await spi.register_spi_device(var, config, write_only=True)
|
||||
await display.register_display(var, config)
|
||||
|
||||
cg.add(var.set_num_chips(config[CONF_NUM_CHIPS]))
|
||||
cg.add(var.set_intensity(config[CONF_INTENSITY]))
|
||||
cg.add(var.set_reverse(config[CONF_REVERSE_ENABLE]))
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace max7219 {
|
||||
namespace esphome::max7219 {
|
||||
|
||||
static const char *const TAG = "max7219";
|
||||
|
||||
@@ -115,12 +114,14 @@ const uint8_t MAX7219_ASCII_TO_RAW[95] PROGMEM = {
|
||||
};
|
||||
|
||||
float MAX7219Component::get_setup_priority() const { return setup_priority::PROCESSOR; }
|
||||
|
||||
MAX7219Component::MAX7219Component(uint8_t num_chips) : num_chips_(num_chips) {
|
||||
this->buffer_ = new uint8_t[this->num_chips_ * 8]; // NOLINT
|
||||
memset(this->buffer_, 0, this->num_chips_ * 8);
|
||||
}
|
||||
|
||||
void MAX7219Component::setup() {
|
||||
this->spi_setup();
|
||||
this->buffer_ = new uint8_t[this->num_chips_ * 8]; // NOLINT
|
||||
for (uint8_t i = 0; i < this->num_chips_ * 8; i++)
|
||||
this->buffer_[i] = 0;
|
||||
|
||||
// let's assume the user has all 8 digits connected, only important in daisy chained setups anyway
|
||||
this->send_to_all_(MAX7219_REGISTER_SCAN_LIMIT, 7);
|
||||
// let's use our own ASCII -> led pattern encoding
|
||||
@@ -229,7 +230,6 @@ void MAX7219Component::set_intensity(uint8_t intensity) {
|
||||
this->intensity_ = intensity;
|
||||
}
|
||||
}
|
||||
void MAX7219Component::set_num_chips(uint8_t num_chips) { this->num_chips_ = num_chips; }
|
||||
|
||||
uint8_t MAX7219Component::strftime(uint8_t pos, const char *format, ESPTime time) {
|
||||
char buffer[64];
|
||||
@@ -240,5 +240,4 @@ uint8_t MAX7219Component::strftime(uint8_t pos, const char *format, ESPTime time
|
||||
}
|
||||
uint8_t MAX7219Component::strftime(const char *format, ESPTime time) { return this->strftime(0, format, time); }
|
||||
|
||||
} // namespace max7219
|
||||
} // namespace esphome
|
||||
} // namespace esphome::max7219
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
#include "esphome/components/spi/spi.h"
|
||||
#include "esphome/components/display/display.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace max7219 {
|
||||
namespace esphome::max7219 {
|
||||
|
||||
class MAX7219Component;
|
||||
|
||||
@@ -17,6 +16,8 @@ class MAX7219Component : public PollingComponent,
|
||||
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW,
|
||||
spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_1MHZ> {
|
||||
public:
|
||||
explicit MAX7219Component(uint8_t num_chips);
|
||||
|
||||
void set_writer(max7219_writer_t &&writer);
|
||||
|
||||
void setup() override;
|
||||
@@ -30,7 +31,6 @@ class MAX7219Component : public PollingComponent,
|
||||
void display();
|
||||
|
||||
void set_intensity(uint8_t intensity);
|
||||
void set_num_chips(uint8_t num_chips);
|
||||
void set_reverse(bool reverse) { this->reverse_ = reverse; };
|
||||
|
||||
/// Evaluate the printf-format and print the result at the given position.
|
||||
@@ -56,10 +56,9 @@ class MAX7219Component : public PollingComponent,
|
||||
uint8_t intensity_{15}; // Intensity of the display from 0 to 15 (most)
|
||||
bool intensity_changed_{}; // True if we need to re-send the intensity
|
||||
uint8_t num_chips_{1};
|
||||
uint8_t *buffer_;
|
||||
uint8_t *buffer_{nullptr};
|
||||
bool reverse_{false};
|
||||
max7219_writer_t writer_{};
|
||||
};
|
||||
|
||||
} // namespace max7219
|
||||
} // namespace esphome
|
||||
} // namespace esphome::max7219
|
||||
|
||||
@@ -1,6 +1,39 @@
|
||||
#include "mipi_spi.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome {
|
||||
namespace mipi_spi {} // namespace mipi_spi
|
||||
} // namespace esphome
|
||||
namespace esphome::mipi_spi {
|
||||
|
||||
void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl,
|
||||
bool invert_colors, int display_bits, bool is_big_endian, const optional<uint8_t> &brightness,
|
||||
GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width) {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"MIPI_SPI Display\n"
|
||||
" Model: %s\n"
|
||||
" Width: %d\n"
|
||||
" Height: %d\n"
|
||||
" Swap X/Y: %s\n"
|
||||
" Mirror X: %s\n"
|
||||
" Mirror Y: %s\n"
|
||||
" Invert colors: %s\n"
|
||||
" Color order: %s\n"
|
||||
" Display pixels: %d bits\n"
|
||||
" Endianness: %s\n"
|
||||
" SPI Mode: %d\n"
|
||||
" SPI Data rate: %uMHz\n"
|
||||
" SPI Bus width: %d",
|
||||
model, width, height, YESNO(madctl & MADCTL_MV), YESNO(madctl & (MADCTL_MX | MADCTL_XFLIP)),
|
||||
YESNO(madctl & (MADCTL_MY | MADCTL_YFLIP)), YESNO(invert_colors), (madctl & MADCTL_BGR) ? "BGR" : "RGB",
|
||||
display_bits, is_big_endian ? "Big" : "Little", spi_mode, static_cast<unsigned>(data_rate / 1000000),
|
||||
bus_width);
|
||||
LOG_PIN(" CS Pin: ", cs);
|
||||
LOG_PIN(" Reset Pin: ", reset);
|
||||
LOG_PIN(" DC Pin: ", dc);
|
||||
if (offset_width != 0)
|
||||
ESP_LOGCONFIG(TAG, " Offset width: %d", offset_width);
|
||||
if (offset_height != 0)
|
||||
ESP_LOGCONFIG(TAG, " Offset height: %d", offset_height);
|
||||
if (brightness.has_value())
|
||||
ESP_LOGCONFIG(TAG, " Brightness: %u", brightness.value());
|
||||
}
|
||||
|
||||
} // namespace esphome::mipi_spi
|
||||
|
||||
@@ -63,6 +63,11 @@ enum BusType {
|
||||
BUS_TYPE_SINGLE_16 = 16, // Single bit bus, but 16 bits per transfer
|
||||
};
|
||||
|
||||
// Helper function for dump_config - defined in mipi_spi.cpp to allow use of LOG_PIN macro
|
||||
void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl,
|
||||
bool invert_colors, int display_bits, bool is_big_endian, const optional<uint8_t> &brightness,
|
||||
GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width);
|
||||
|
||||
/**
|
||||
* Base class for MIPI SPI displays.
|
||||
* All the methods are defined here in the header file, as it is not possible to define templated methods in a cpp file.
|
||||
@@ -201,37 +206,9 @@ class MipiSpi : public display::Display,
|
||||
}
|
||||
|
||||
void dump_config() override {
|
||||
esph_log_config(TAG,
|
||||
"MIPI_SPI Display\n"
|
||||
" Model: %s\n"
|
||||
" Width: %u\n"
|
||||
" Height: %u",
|
||||
this->model_, WIDTH, HEIGHT);
|
||||
if constexpr (OFFSET_WIDTH != 0)
|
||||
esph_log_config(TAG, " Offset width: %u", OFFSET_WIDTH);
|
||||
if constexpr (OFFSET_HEIGHT != 0)
|
||||
esph_log_config(TAG, " Offset height: %u", OFFSET_HEIGHT);
|
||||
esph_log_config(TAG,
|
||||
" Swap X/Y: %s\n"
|
||||
" Mirror X: %s\n"
|
||||
" Mirror Y: %s\n"
|
||||
" Invert colors: %s\n"
|
||||
" Color order: %s\n"
|
||||
" Display pixels: %d bits\n"
|
||||
" Endianness: %s\n",
|
||||
YESNO(this->madctl_ & MADCTL_MV), YESNO(this->madctl_ & (MADCTL_MX | MADCTL_XFLIP)),
|
||||
YESNO(this->madctl_ & (MADCTL_MY | MADCTL_YFLIP)), YESNO(this->invert_colors_),
|
||||
this->madctl_ & MADCTL_BGR ? "BGR" : "RGB", DISPLAYPIXEL * 8, IS_BIG_ENDIAN ? "Big" : "Little");
|
||||
if (this->brightness_.has_value())
|
||||
esph_log_config(TAG, " Brightness: %u", this->brightness_.value());
|
||||
log_pin(TAG, " CS Pin: ", this->cs_);
|
||||
log_pin(TAG, " Reset Pin: ", this->reset_pin_);
|
||||
log_pin(TAG, " DC Pin: ", this->dc_pin_);
|
||||
esph_log_config(TAG,
|
||||
" SPI Mode: %d\n"
|
||||
" SPI Data rate: %dMHz\n"
|
||||
" SPI Bus width: %d",
|
||||
this->mode_, static_cast<unsigned>(this->data_rate_ / 1000000), BUS_TYPE);
|
||||
internal_dump_config(this->model_, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, this->madctl_, this->invert_colors_,
|
||||
DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_,
|
||||
this->mode_, this->data_rate_, BUS_TYPE);
|
||||
}
|
||||
|
||||
protected:
|
||||
|
||||
@@ -181,17 +181,20 @@ optional<bool> PMSX003Component::check_byte_() {
|
||||
bool PMSX003Component::check_payload_length_(uint16_t payload_length) {
|
||||
// https://avaldebe.github.io/PyPMS/sensors/Plantower/
|
||||
switch (this->type_) {
|
||||
case Type::PMSX003:
|
||||
// The expected payload length is typically 28 bytes.
|
||||
// However, a 20-byte payload check was already present in the code.
|
||||
// No official documentation was found confirming this.
|
||||
// Retaining this check to avoid breaking existing behavior.
|
||||
case Type::PMS1003:
|
||||
return payload_length == 28; // 2*13+2
|
||||
case Type::PMS3003: // Data 7/8/9 not set/reserved
|
||||
return payload_length == 20; // 2*9+2
|
||||
case Type::PMSX003: // Data 13 not set/reserved
|
||||
// Deprecated: Length 20 is for PMS3003 backwards compatibility
|
||||
return payload_length == 28 || payload_length == 20; // 2*13+2
|
||||
case Type::PMS5003S:
|
||||
case Type::PMS5003T:
|
||||
return payload_length == 28; // 2*13+2 (Data 13 not set/reserved)
|
||||
case Type::PMS5003ST:
|
||||
return payload_length == 36; // 2*17+2 (Data 16 not set/reserved)
|
||||
case Type::PMS5003T: // Data 13 not set/reserved
|
||||
return payload_length == 28; // 2*13+2
|
||||
case Type::PMS5003ST: // Data 16 not set/reserved
|
||||
return payload_length == 36; // 2*17+2
|
||||
case Type::PMS9003M:
|
||||
return payload_length == 28; // 2*13+2
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -314,9 +317,10 @@ void PMSX003Component::parse_data_() {
|
||||
}
|
||||
|
||||
// Firmware Version and Error Code
|
||||
if (this->type_ == Type::PMS5003ST) {
|
||||
const uint8_t firmware_version = this->data_[36];
|
||||
const uint8_t error_code = this->data_[37];
|
||||
if (this->type_ == Type::PMS1003 || this->type_ == Type::PMS5003ST || this->type_ == Type::PMS9003M) {
|
||||
const uint8_t firmware_error_code_offset = (this->type_ == Type::PMS5003ST) ? 36 : 28;
|
||||
const uint8_t firmware_version = this->data_[firmware_error_code_offset];
|
||||
const uint8_t error_code = this->data_[firmware_error_code_offset + 1];
|
||||
|
||||
ESP_LOGD(TAG, "Got Firmware Version: 0x%02X, Error Code: 0x%02X", firmware_version, error_code);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
namespace esphome::pmsx003 {
|
||||
|
||||
enum class Type : uint8_t {
|
||||
PMSX003 = 0,
|
||||
PMS1003 = 0,
|
||||
PMS3003,
|
||||
PMSX003, // PMS5003, PMS6003, PMS7003, PMSA003 (NOT PMSA003I - see `pmsa003i` component)
|
||||
PMS5003S,
|
||||
PMS5003T,
|
||||
PMS5003ST,
|
||||
PMS9003M,
|
||||
};
|
||||
|
||||
enum class Command : uint8_t {
|
||||
|
||||
@@ -40,33 +40,127 @@ pmsx003_ns = cg.esphome_ns.namespace("pmsx003")
|
||||
PMSX003Component = pmsx003_ns.class_("PMSX003Component", uart.UARTDevice, cg.Component)
|
||||
PMSX003Sensor = pmsx003_ns.class_("PMSX003Sensor", sensor.Sensor)
|
||||
|
||||
TYPE_PMSX003 = "PMSX003"
|
||||
TYPE_PMS1003 = "PMS1003"
|
||||
TYPE_PMS3003 = "PMS3003"
|
||||
TYPE_PMSX003 = "PMSX003" # PMS5003, PMS6003, PMS7003, PMSA003 (NOT PMSA003I - see `pmsa003i` component)
|
||||
TYPE_PMS5003S = "PMS5003S"
|
||||
TYPE_PMS5003T = "PMS5003T"
|
||||
TYPE_PMS5003ST = "PMS5003ST"
|
||||
TYPE_PMS9003M = "PMS9003M"
|
||||
|
||||
Type = pmsx003_ns.enum("Type", is_class=True)
|
||||
|
||||
PMSX003_TYPES = {
|
||||
TYPE_PMS1003: Type.PMS1003,
|
||||
TYPE_PMS3003: Type.PMS3003,
|
||||
TYPE_PMSX003: Type.PMSX003,
|
||||
TYPE_PMS5003S: Type.PMS5003S,
|
||||
TYPE_PMS5003T: Type.PMS5003T,
|
||||
TYPE_PMS5003ST: Type.PMS5003ST,
|
||||
TYPE_PMS9003M: Type.PMS9003M,
|
||||
}
|
||||
|
||||
SENSORS_TO_TYPE = {
|
||||
CONF_PM_1_0_STD: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
CONF_PM_2_5_STD: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
CONF_PM_10_0_STD: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
CONF_PM_1_0: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
CONF_PM_2_5: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
CONF_PM_10_0: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
CONF_PM_0_3UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
CONF_PM_0_5UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
CONF_PM_1_0UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
CONF_PM_2_5UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
CONF_PM_5_0UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003ST],
|
||||
CONF_PM_10_0UM: [TYPE_PMSX003, TYPE_PMS5003S, TYPE_PMS5003ST],
|
||||
CONF_PM_1_0_STD: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMS3003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003T,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_PM_2_5_STD: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMS3003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003T,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_PM_10_0_STD: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMS3003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003T,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_PM_1_0: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMS3003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003T,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_PM_2_5: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMS3003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003T,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_PM_10_0: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMS3003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003T,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_PM_0_3UM: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003T,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_PM_0_5UM: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003T,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_PM_1_0UM: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003T,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_PM_2_5UM: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003T,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_PM_5_0UM: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_PM_10_0UM: [
|
||||
TYPE_PMS1003,
|
||||
TYPE_PMSX003,
|
||||
TYPE_PMS5003S,
|
||||
TYPE_PMS5003ST,
|
||||
TYPE_PMS9003M,
|
||||
],
|
||||
CONF_FORMALDEHYDE: [TYPE_PMS5003S, TYPE_PMS5003ST],
|
||||
CONF_TEMPERATURE: [TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
CONF_HUMIDITY: [TYPE_PMS5003T, TYPE_PMS5003ST],
|
||||
|
||||
@@ -12,8 +12,8 @@ from esphome.components.packet_transport import (
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID
|
||||
from esphome.core import ID, Lambda
|
||||
from esphome.cpp_generator import ExpressionStatement, MockObj
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import literal
|
||||
|
||||
CODEOWNERS = ["@clydebarrow"]
|
||||
DEPENDENCIES = ["network"]
|
||||
@@ -24,6 +24,8 @@ udp_ns = cg.esphome_ns.namespace("udp")
|
||||
UDPComponent = udp_ns.class_("UDPComponent", cg.Component)
|
||||
UDPWriteAction = udp_ns.class_("UDPWriteAction", automation.Action)
|
||||
trigger_args = cg.std_vector.template(cg.uint8)
|
||||
trigger_argname = "data"
|
||||
trigger_argtype = [(trigger_args, trigger_argname)]
|
||||
|
||||
CONF_ADDRESSES = "addresses"
|
||||
CONF_LISTEN_ADDRESS = "listen_address"
|
||||
@@ -111,13 +113,14 @@ async def to_code(config):
|
||||
cg.add(var.set_addresses([str(addr) for addr in config[CONF_ADDRESSES]]))
|
||||
if on_receive := config.get(CONF_ON_RECEIVE):
|
||||
on_receive = on_receive[0]
|
||||
trigger = cg.new_Pvariable(on_receive[CONF_TRIGGER_ID])
|
||||
trigger_id = cg.new_Pvariable(on_receive[CONF_TRIGGER_ID])
|
||||
trigger = await automation.build_automation(
|
||||
trigger, [(trigger_args, "data")], on_receive
|
||||
trigger_id, trigger_argtype, on_receive
|
||||
)
|
||||
trigger = Lambda(str(ExpressionStatement(trigger.trigger(MockObj("data")))))
|
||||
trigger = await cg.process_lambda(trigger, [(trigger_args, "data")])
|
||||
cg.add(var.add_listener(trigger))
|
||||
trigger_lambda = await cg.process_lambda(
|
||||
trigger.trigger(literal(trigger_argname)), trigger_argtype
|
||||
)
|
||||
cg.add(var.add_listener(trigger_lambda))
|
||||
cg.add(var.set_should_listen())
|
||||
|
||||
|
||||
|
||||
@@ -155,8 +155,9 @@ void USBCDCACMInstance::setup() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a larger stack size for (very) verbose logging
|
||||
const size_t stack_size = esp_log_level_get(TAG) > ESP_LOG_DEBUG ? USB_TX_TASK_STACK_SIZE_VV : USB_TX_TASK_STACK_SIZE;
|
||||
// Use a larger stack size for very verbose logging
|
||||
constexpr size_t stack_size =
|
||||
ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE ? USB_TX_TASK_STACK_SIZE_VV : USB_TX_TASK_STACK_SIZE;
|
||||
|
||||
// Create a simple, unique task name per interface
|
||||
char task_name[] = "usb_tx_0";
|
||||
|
||||
@@ -278,9 +278,13 @@ LAMBDA_PROG = re.compile(r"\bid\(\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\)(\.?)")
|
||||
|
||||
class Lambda:
|
||||
def __init__(self, value):
|
||||
from esphome.cpp_generator import Expression, statement
|
||||
|
||||
# pylint: disable=protected-access
|
||||
if isinstance(value, Lambda):
|
||||
self._value = value._value
|
||||
elif isinstance(value, Expression):
|
||||
self._value = str(statement(value))
|
||||
else:
|
||||
self._value = value
|
||||
self._parts = None
|
||||
|
||||
@@ -462,6 +462,16 @@ def statement(expression: Expression | Statement) -> Statement:
|
||||
return ExpressionStatement(expression)
|
||||
|
||||
|
||||
def literal(name: str) -> "MockObj":
|
||||
"""Create a literal name that will appear in the generated code
|
||||
not surrounded by quotes.
|
||||
|
||||
:param name: The name of the literal.
|
||||
:return: The literal as a MockObj.
|
||||
"""
|
||||
return MockObj(name, "")
|
||||
|
||||
|
||||
def variable(
|
||||
id_: ID, rhs: SafeExpType, type_: "MockObj" = None, register=True
|
||||
) -> "MockObj":
|
||||
@@ -665,7 +675,7 @@ async def get_variable_with_full_id(id_: ID) -> tuple[ID, "MockObj"]:
|
||||
|
||||
|
||||
async def process_lambda(
|
||||
value: Lambda,
|
||||
value: Lambda | Expression,
|
||||
parameters: TemplateArgsType,
|
||||
capture: str = "",
|
||||
return_type: SafeExpType = None,
|
||||
@@ -689,6 +699,14 @@ async def process_lambda(
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
# Inadvertently passing a malformed parameters value will lead to the build process mysteriously hanging at the
|
||||
# "Generating C++ source..." stage, so check here to save the developer's hair.
|
||||
assert isinstance(parameters, list) and all(
|
||||
isinstance(p, tuple) and len(p) == 2 for p in parameters
|
||||
)
|
||||
if isinstance(value, Expression):
|
||||
value = Lambda(value)
|
||||
|
||||
parts = value.parts[:]
|
||||
for i, id in enumerate(value.requires_ids):
|
||||
full_id, var = await get_variable_with_full_id(id)
|
||||
|
||||
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
|
||||
10
tests/components/mipi_spi/test.esp8266-ard.yaml
Normal file
10
tests/components/mipi_spi/test.esp8266-ard.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
substitutions:
|
||||
dc_pin: GPIO15
|
||||
cs_pin: GPIO5
|
||||
enable_pin: GPIO4
|
||||
reset_pin: GPIO16
|
||||
|
||||
packages:
|
||||
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
|
||||
|
||||
<<: !include common.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
|
||||
@@ -347,3 +347,280 @@ class TestMockObj:
|
||||
assert isinstance(actual, cg.MockObj)
|
||||
assert actual.base == "foo.eek"
|
||||
assert actual.op == "."
|
||||
|
||||
|
||||
class TestStatementFunction:
|
||||
"""Tests for the statement() function."""
|
||||
|
||||
def test_statement__expression_converted_to_statement(self):
|
||||
"""Test that expressions are converted to ExpressionStatement."""
|
||||
expr = cg.RawExpression("foo()")
|
||||
result = cg.statement(expr)
|
||||
|
||||
assert isinstance(result, cg.ExpressionStatement)
|
||||
assert str(result) == "foo();"
|
||||
|
||||
def test_statement__statement_unchanged(self):
|
||||
"""Test that statements are returned unchanged."""
|
||||
stmt = cg.RawStatement("foo()")
|
||||
result = cg.statement(stmt)
|
||||
|
||||
assert result is stmt
|
||||
assert str(result) == "foo()"
|
||||
|
||||
def test_statement__expression_statement_unchanged(self):
|
||||
"""Test that ExpressionStatement is returned unchanged."""
|
||||
stmt = cg.ExpressionStatement(42)
|
||||
result = cg.statement(stmt)
|
||||
|
||||
assert result is stmt
|
||||
assert str(result) == "42;"
|
||||
|
||||
def test_statement__line_comment_unchanged(self):
|
||||
"""Test that LineComment is returned unchanged."""
|
||||
stmt = cg.LineComment("This is a comment")
|
||||
result = cg.statement(stmt)
|
||||
|
||||
assert result is stmt
|
||||
assert str(result) == "// This is a comment"
|
||||
|
||||
|
||||
class TestLiteralFunction:
|
||||
"""Tests for the literal() function."""
|
||||
|
||||
def test_literal__creates_mockobj(self):
|
||||
"""Test that literal() creates a MockObj."""
|
||||
result = cg.literal("MY_CONSTANT")
|
||||
|
||||
assert isinstance(result, cg.MockObj)
|
||||
assert result.base == "MY_CONSTANT"
|
||||
assert result.op == ""
|
||||
|
||||
def test_literal__string_representation(self):
|
||||
"""Test that literal names appear unquoted in generated code."""
|
||||
result = cg.literal("nullptr")
|
||||
|
||||
assert str(result) == "nullptr"
|
||||
|
||||
def test_literal__can_be_used_in_expressions(self):
|
||||
"""Test that literals can be used as part of larger expressions."""
|
||||
null_lit = cg.literal("nullptr")
|
||||
expr = cg.CallExpression(cg.RawExpression("my_func"), null_lit)
|
||||
|
||||
assert str(expr) == "my_func(nullptr)"
|
||||
|
||||
def test_literal__common_cpp_literals(self):
|
||||
"""Test common C++ literal values."""
|
||||
test_cases = [
|
||||
("nullptr", "nullptr"),
|
||||
("true", "true"),
|
||||
("false", "false"),
|
||||
("NULL", "NULL"),
|
||||
("NAN", "NAN"),
|
||||
]
|
||||
|
||||
for name, expected in test_cases:
|
||||
result = cg.literal(name)
|
||||
assert str(result) == expected
|
||||
|
||||
|
||||
class TestLambdaConstructor:
|
||||
"""Tests for the Lambda class constructor in core/__init__.py."""
|
||||
|
||||
def test_lambda__from_string(self):
|
||||
"""Test Lambda constructor with string argument."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
lambda_obj = Lambda("return x + 1;")
|
||||
|
||||
assert lambda_obj.value == "return x + 1;"
|
||||
assert str(lambda_obj) == "return x + 1;"
|
||||
|
||||
def test_lambda__from_expression(self):
|
||||
"""Test Lambda constructor with Expression argument."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
expr = cg.RawExpression("x + 1")
|
||||
lambda_obj = Lambda(expr)
|
||||
|
||||
# Expression should be converted to statement (with semicolon)
|
||||
assert lambda_obj.value == "x + 1;"
|
||||
|
||||
def test_lambda__from_lambda(self):
|
||||
"""Test Lambda constructor with another Lambda argument."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
original = Lambda("return x + 1;")
|
||||
copy = Lambda(original)
|
||||
|
||||
assert copy.value == original.value
|
||||
assert copy.value == "return x + 1;"
|
||||
|
||||
def test_lambda__parts_parsing(self):
|
||||
"""Test that Lambda correctly parses parts with id() references."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
lambda_obj = Lambda("return id(my_sensor).state;")
|
||||
parts = lambda_obj.parts
|
||||
|
||||
# Parts should be split by LAMBDA_PROG regex: text, id, op, text
|
||||
assert len(parts) == 4
|
||||
assert parts[0] == "return "
|
||||
assert parts[1] == "my_sensor"
|
||||
assert parts[2] == "."
|
||||
assert parts[3] == "state;"
|
||||
|
||||
def test_lambda__requires_ids(self):
|
||||
"""Test that Lambda correctly extracts required IDs."""
|
||||
from esphome.core import ID, Lambda
|
||||
|
||||
lambda_obj = Lambda("return id(sensor1).state + id(sensor2).value;")
|
||||
ids = lambda_obj.requires_ids
|
||||
|
||||
assert len(ids) == 2
|
||||
assert all(isinstance(id_obj, ID) for id_obj in ids)
|
||||
assert ids[0].id == "sensor1"
|
||||
assert ids[1].id == "sensor2"
|
||||
|
||||
def test_lambda__no_ids(self):
|
||||
"""Test Lambda with no id() references."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
lambda_obj = Lambda("return 42;")
|
||||
ids = lambda_obj.requires_ids
|
||||
|
||||
assert len(ids) == 0
|
||||
|
||||
def test_lambda__comment_removal(self):
|
||||
"""Test that comments are removed when parsing parts."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
lambda_obj = Lambda("return id(sensor).state; // Get sensor state")
|
||||
parts = lambda_obj.parts
|
||||
|
||||
# Comment should be replaced with space, not affect parsing
|
||||
assert "my_sensor" not in str(parts)
|
||||
|
||||
def test_lambda__multiline_string(self):
|
||||
"""Test Lambda with multiline string."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
code = """if (id(sensor).state > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;"""
|
||||
lambda_obj = Lambda(code)
|
||||
|
||||
assert lambda_obj.value == code
|
||||
assert "sensor" in [id_obj.id for id_obj in lambda_obj.requires_ids]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestProcessLambda:
|
||||
"""Tests for the process_lambda() async function."""
|
||||
|
||||
async def test_process_lambda__none_value(self):
|
||||
"""Test that None returns None."""
|
||||
result = await cg.process_lambda(None, [])
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_process_lambda__with_expression(self):
|
||||
"""Test process_lambda with Expression argument."""
|
||||
|
||||
expr = cg.RawExpression("return x + 1")
|
||||
result = await cg.process_lambda(expr, [(int, "x")])
|
||||
|
||||
assert isinstance(result, cg.LambdaExpression)
|
||||
assert "x + 1" in str(result)
|
||||
|
||||
async def test_process_lambda__simple_lambda_no_ids(self):
|
||||
"""Test process_lambda with simple Lambda without id() references."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
lambda_obj = Lambda("return x + 1;")
|
||||
result = await cg.process_lambda(lambda_obj, [(int, "x")])
|
||||
|
||||
assert isinstance(result, cg.LambdaExpression)
|
||||
# Should have parameter
|
||||
lambda_str = str(result)
|
||||
assert "int32_t x" in lambda_str
|
||||
assert "return x + 1;" in lambda_str
|
||||
|
||||
async def test_process_lambda__with_return_type(self):
|
||||
"""Test process_lambda with return type specified."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
lambda_obj = Lambda("return x > 0;")
|
||||
result = await cg.process_lambda(lambda_obj, [(int, "x")], return_type=bool)
|
||||
|
||||
assert isinstance(result, cg.LambdaExpression)
|
||||
lambda_str = str(result)
|
||||
assert "-> bool" in lambda_str
|
||||
|
||||
async def test_process_lambda__with_capture(self):
|
||||
"""Test process_lambda with capture specified."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
lambda_obj = Lambda("return captured + x;")
|
||||
result = await cg.process_lambda(lambda_obj, [(int, "x")], capture="captured")
|
||||
|
||||
assert isinstance(result, cg.LambdaExpression)
|
||||
lambda_str = str(result)
|
||||
assert "[captured]" in lambda_str
|
||||
|
||||
async def test_process_lambda__empty_capture(self):
|
||||
"""Test process_lambda with empty capture (stateless lambda)."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
lambda_obj = Lambda("return x + 1;")
|
||||
result = await cg.process_lambda(lambda_obj, [(int, "x")], capture="")
|
||||
|
||||
assert isinstance(result, cg.LambdaExpression)
|
||||
lambda_str = str(result)
|
||||
assert "[]" in lambda_str
|
||||
|
||||
async def test_process_lambda__no_parameters(self):
|
||||
"""Test process_lambda with no parameters."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
lambda_obj = Lambda("return 42;")
|
||||
result = await cg.process_lambda(lambda_obj, [])
|
||||
|
||||
assert isinstance(result, cg.LambdaExpression)
|
||||
lambda_str = str(result)
|
||||
# Should have empty parameter list
|
||||
assert "()" in lambda_str
|
||||
|
||||
async def test_process_lambda__multiple_parameters(self):
|
||||
"""Test process_lambda with multiple parameters."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
lambda_obj = Lambda("return x + y + z;")
|
||||
result = await cg.process_lambda(
|
||||
lambda_obj, [(int, "x"), (float, "y"), (bool, "z")]
|
||||
)
|
||||
|
||||
assert isinstance(result, cg.LambdaExpression)
|
||||
lambda_str = str(result)
|
||||
assert "int32_t x" in lambda_str
|
||||
assert "float y" in lambda_str
|
||||
assert "bool z" in lambda_str
|
||||
|
||||
async def test_process_lambda__parameter_validation(self):
|
||||
"""Test that malformed parameters raise assertion error."""
|
||||
from esphome.core import Lambda
|
||||
|
||||
lambda_obj = Lambda("return x;")
|
||||
|
||||
# Test invalid parameter format (not list of tuples)
|
||||
with pytest.raises(AssertionError):
|
||||
await cg.process_lambda(lambda_obj, "invalid")
|
||||
|
||||
# Test invalid tuple format (not 2-element tuples)
|
||||
with pytest.raises(AssertionError):
|
||||
await cg.process_lambda(lambda_obj, [(int, "x", "extra")])
|
||||
|
||||
# Test invalid tuple format (single element)
|
||||
with pytest.raises(AssertionError):
|
||||
await cg.process_lambda(lambda_obj, [(int,)])
|
||||
|
||||
Reference in New Issue
Block a user