diff --git a/CODEOWNERS b/CODEOWNERS index e1287ca275..13fae0664b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -350,6 +350,7 @@ esphome/components/mipi_spi/* @clydebarrow esphome/components/mitsubishi/* @RubyBailey esphome/components/mitsubishi_cn105/* @crnjan esphome/components/mixer/speaker/* @kahrendt +esphome/components/mk2pvrouter/* @FredM67 esphome/components/mlx90393/* @functionpointer esphome/components/mlx90614/* @jesserockz esphome/components/mmc5603/* @benhoff diff --git a/esphome/components/mk2pvrouter/__init__.py b/esphome/components/mk2pvrouter/__init__.py new file mode 100644 index 0000000000..d00b4ce8d0 --- /dev/null +++ b/esphome/components/mk2pvrouter/__init__.py @@ -0,0 +1,69 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TAG +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@FredM67"] +DEPENDENCIES = ["uart"] + +mk2pvrouter_ns = cg.esphome_ns.namespace("mk2pvrouter") +Mk2PVRouter = mk2pvrouter_ns.class_("Mk2PVRouter", cg.Component, uart.UARTDevice) + +CONF_MK2PVROUTER_ID = "mk2pvrouter_id" + +# Tags are copied into a fixed-size buffer (MAX_TAG_SIZE = 8 in mk2pvrouter.h), +# which needs room for a trailing null terminator. +MAX_TAG_LEN = 7 + +MK2PVROUTER_LISTENER_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MK2PVROUTER_ID): cv.use_id(Mk2PVRouter), + cv.Required(CONF_TAG): cv.All( + cv.string_strict, cv.Length(min=1, max=MAX_TAG_LEN), lambda x: x.upper() + ), + } +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Mk2PVRouter), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +def final_validate(config: ConfigType) -> None: + # Validate UART settings + schema = uart.final_validate_device_schema( + "mk2pvrouter", + baud_rate=9600, + parity="EVEN", + data_bits=7, + stop_bits=1, + require_rx=True, + require_tx=False, + ) + schema(config) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +_request_listener_slot = cg.slot_counter("MK2PVROUTER_LISTENER_COUNT") + + +async def register_mk2pvrouter_listener(mk2pvrouter: MockObj, var: MockObj) -> None: + """Register a listener with its hub and count it for the compile-time buffer size.""" + _request_listener_slot() + cg.add(mk2pvrouter.register_mk2pvrouter_listener(var)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp new file mode 100644 index 0000000000..a9c922602b --- /dev/null +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -0,0 +1,177 @@ +#include "mk2pvrouter.h" +#include "esphome/core/log.h" +#include + +namespace esphome::mk2pvrouter { + +static const char *const TAG = "mk2pvrouter"; + +constexpr uint8_t START_FRAME = 0x2; +constexpr uint8_t END_FRAME = 0x3; +constexpr uint8_t LINE_FEED = 0xa; +constexpr uint8_t CARRIAGE_RETURN = 0xd; +constexpr uint8_t TAB = 0x9; +constexpr uint8_t MAX_ITERATIONS = 128; +constexpr uint8_t CRC_MASK = 0x3F; +constexpr uint8_t CRC_OFFSET = 0x20; + +// Extracts a TAB-delimited field from [buf_start, buf_end) into dest. +// Returns the field length, or 0 if no TAB was found, or the (uncopied) field +// length if it's >= max_len. +static size_t get_field(char *dest, const char *buf_start, const char *buf_end, size_t max_len) { + const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); + if (!field_end) + return 0; + const size_t len = field_end - buf_start; + if (len >= max_len) { + ESP_LOGE(TAG, "Field too long: %zu bytes (max %zu)", len, max_len); + return len; + } + + memcpy(dest, buf_start, len); + dest[len] = '\0'; // Null-terminate + return len; +} + +// Calculates the CRC (checksum) for a given group of characters. +uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { + uint8_t crc_tmp{0}; + const auto effective_len = grp_len - CRC_SUFFIX_LEN; + for (size_t i = 0; i < effective_len; i++) { + crc_tmp += grp[i]; + } + crc_tmp &= CRC_MASK; + crc_tmp += CRC_OFFSET; + return crc_tmp; +} + +// Verifies the CRC of a group against its trailing CRC byte. +bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { + const auto grp_len = grp_end - grp; + if (grp_len < static_cast(CRC_SUFFIX_LEN)) { + ESP_LOGE(TAG, "Empty or too short group"); + return false; + } + const auto raw_crc = grp[grp_len - 1]; + + const auto calculated_crc = this->calculate_crc_(grp, grp_len); + + if (raw_crc != calculated_crc) { + ESP_LOGE(TAG, "CRC mismatch: expected %d, got %d", calculated_crc, raw_crc); + return false; + } + return true; +} + +// Validates, parses, and publishes a single tag/value group. +void Mk2PVRouter::process_group_(const char *grp, const char *grp_end) { + if (!this->check_crc_(grp, grp_end)) + return; + + size_t field_len = get_field(this->tag_, grp, grp_end, MAX_TAG_SIZE); + if (!field_len || field_len >= MAX_TAG_SIZE) { + ESP_LOGE(TAG, "Invalid tag"); + return; + } + const auto *val_start = grp + field_len + 1; // Skip tag + TAB. + + field_len = get_field(this->val_, val_start, grp_end, MAX_VAL_SIZE); + if (!field_len || field_len >= MAX_VAL_SIZE) { + ESP_LOGE(TAG, "Invalid value for tag %s", this->tag_); + return; + } + + this->publish_value_(this->tag_, this->val_); +} + +// Reads characters until `c` is found or the internal buffer is full. +bool Mk2PVRouter::read_chars_until_(bool drop, uint8_t c) { + size_t j{0}; + + while (this->available() > 0 && j++ < MAX_ITERATIONS) { + const auto received = this->read(); + if (received < 0) + continue; + if (received == c) + return true; + if (drop) + continue; + if (this->buf_index_ >= (sizeof(this->buf_) - 1)) { + ESP_LOGW(TAG, "Internal buffer full"); + this->buf_index_ = 0; + this->state_ = State::WAITING_FOR_START; + return false; + } + this->buf_[this->buf_index_++] = received; + } + + return false; +} + +void Mk2PVRouter::loop() { + switch (this->state_) { + case State::WAITING_FOR_START: + ESP_LOGVV(TAG, "State: WAITING_FOR_START"); + if (this->read_chars_until_(true, START_FRAME)) + this->state_ = State::START_FRAME_RECEIVED; + break; + case State::START_FRAME_RECEIVED: + ESP_LOGVV(TAG, "State: START_FRAME_RECEIVED"); + if (this->read_chars_until_(false, END_FRAME)) + this->state_ = State::END_FRAME_RECEIVED; + break; + case State::END_FRAME_RECEIVED: { + ESP_LOGVV(TAG, "State: END_FRAME_RECEIVED -> processing"); + + if (this->buf_index_ == 0) { + this->state_ = State::WAITING_FOR_START; + break; + } + + auto *buf_finger = this->buf_; + auto *buf_end = this->buf_ + this->buf_index_; + + // Each group: 0xa(LF) | Tag | 0x9(TAB) | Data | 0x9(TAB) | CRC | 0xd(CR) + // CRC is computed over "Tag | TAB | Data | TAB". + while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { + ++buf_finger; // Skip LF to the start of the group. + + auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); + if (!grp_end) { + ESP_LOGE(TAG, "No group found"); + break; + } + + this->process_group_(buf_finger, grp_end); + + buf_finger = grp_end; // grp_end is always < buf_end, so this stays in bounds. + } + this->buf_index_ = 0; + this->state_ = State::WAITING_FOR_START; + break; + } + } +} + +void Mk2PVRouter::publish_value_(const char *tag, const char *val) { +#ifdef MK2PVROUTER_LISTENER_COUNT + for (auto *element : this->mk2pvrouter_listeners_) { + if (strcmp(tag, element->get_tag()) != 0) + continue; + element->publish_val(val); + } +#endif +} + +void Mk2PVRouter::dump_config() { + ESP_LOGCONFIG(TAG, "Mk2PVRouter:"); + this->check_uart_settings(BAUD_RATE, 1, uart::UART_CONFIG_PARITY_EVEN, 7); +} + +#ifdef MK2PVROUTER_LISTENER_COUNT +void Mk2PVRouter::register_mk2pvrouter_listener(Mk2PVRouterListener *listener) { + this->mk2pvrouter_listeners_.push_back(listener); +} +#endif + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h new file mode 100644 index 0000000000..f542436f1d --- /dev/null +++ b/esphome/components/mk2pvrouter/mk2pvrouter.h @@ -0,0 +1,69 @@ +#pragma once + +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +namespace esphome::mk2pvrouter { +/* + * Buffer sizes based on the mk2pvrouter telemetry protocol, as implemented by the + * firmware's teleinfo.h (see github.com/FredM67/PVRouter-{1,3}-phase): + * - Tags: max 4 chars (S_MC is longest), most are 1-2 chars (P, V1, R2, etc.) + * - Values: max 6 digits signed (-10000), typical 1-5 digits. Energy (E) is a daily + * counter reset at midnight, so it stays well within 6 digits. + * - Frame: STX + multiple lines (LF+tag+TAB+value+TAB+crc+CR) + ETX + * - Line format: \n\t\t\r (8-15 bytes per line) + * - Multi-phase with all features: ~150-200 bytes + */ +static constexpr uint8_t MAX_TAG_SIZE = 8; // S_MC (4) + digit (1) + null (1) + margin (2) +static constexpr uint8_t MAX_VAL_SIZE = 8; // -10000 (6) + null (1) + margin (1) +static constexpr uint16_t MAX_BUF_SIZE = 256; // Full frame with all features enabled + +// Listener interface for entities that want updates for a specific tag. +class Mk2PVRouterListener { + public: + explicit Mk2PVRouterListener(const char *tag) : tag_(tag) {} + virtual ~Mk2PVRouterListener() = default; + const char *get_tag() const { return this->tag_; } + virtual void publish_val(const char *val) = 0; + + protected: + const char *tag_; +}; + +// Reads frames via UART, validates their CRC, and publishes tag/value pairs to listeners. +class Mk2PVRouter final : public Component, public uart::UARTDevice { + public: +#ifdef MK2PVROUTER_LISTENER_COUNT + void register_mk2pvrouter_listener(Mk2PVRouterListener *listener); +#endif + void loop() override; + void dump_config() override; + + protected: + static constexpr size_t CRC_SUFFIX_LEN = 1; + static constexpr uint32_t BAUD_RATE = 9600; + + enum class State : uint8_t { + WAITING_FOR_START, + START_FRAME_RECEIVED, + END_FRAME_RECEIVED, + }; + +#ifdef MK2PVROUTER_LISTENER_COUNT + StaticVector mk2pvrouter_listeners_; +#endif + uint16_t buf_index_{0}; + State state_{State::WAITING_FOR_START}; + char tag_[MAX_TAG_SIZE]; + char val_[MAX_VAL_SIZE]; + char buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding + + bool read_chars_until_(bool drop, uint8_t c); + uint8_t calculate_crc_(const char *grp, size_t grp_len); + bool check_crc_(const char *grp, const char *grp_end); + void process_group_(const char *grp, const char *grp_end); + void publish_value_(const char *tag, const char *val); +}; +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/sensor/__init__.py b/esphome/components/mk2pvrouter/sensor/__init__.py new file mode 100644 index 0000000000..14fc48a626 --- /dev/null +++ b/esphome/components/mk2pvrouter/sensor/__init__.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.const import CONF_ID, CONF_TAG +from esphome.types import ConfigType + +from .. import ( + CONF_MK2PVROUTER_ID, + MK2PVROUTER_LISTENER_SCHEMA, + mk2pvrouter_ns, + register_mk2pvrouter_listener, +) + +Mk2PVRouterSensor = mk2pvrouter_ns.class_( + "Mk2PVRouterSensor", sensor.Sensor, cg.Component +) + +CONFIG_SCHEMA = sensor.sensor_schema(Mk2PVRouterSensor).extend( + MK2PVROUTER_LISTENER_SCHEMA +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + mk2pvrouter = await cg.get_variable(config[CONF_MK2PVROUTER_ID]) + await register_mk2pvrouter_listener(mk2pvrouter, var) diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp new file mode 100644 index 0000000000..96f1ff5954 --- /dev/null +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp @@ -0,0 +1,24 @@ +#include "mk2pvrouter_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::mk2pvrouter { + +static const char *const TAG = "mk2pvrouter_sensor"; + +Mk2PVRouterSensor::Mk2PVRouterSensor(const char *tag) : Mk2PVRouterListener(tag) {} + +void Mk2PVRouterSensor::publish_val(const char *val) { + auto result = parse_number(val); + if (!result.has_value()) { + ESP_LOGW(TAG, "Failed to parse value '%s' for tag '%s'", val, this->get_tag()); + return; + } + this->publish_state(result.value()); +} + +void Mk2PVRouterSensor::dump_config() { + LOG_SENSOR(" ", "Mk2PVRouter Sensor", this); + ESP_LOGCONFIG(TAG, " Tag: %s", this->get_tag()); +} + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h new file mode 100644 index 0000000000..e4da41e384 --- /dev/null +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::mk2pvrouter { + +class Mk2PVRouterSensor final : public Mk2PVRouterListener, public sensor::Sensor, public Component { + public: + explicit Mk2PVRouterSensor(const char *tag); + void publish_val(const char *val) override; + void dump_config() override; +}; + +} // namespace esphome::mk2pvrouter diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 90ecfea72a..625d4879f5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -134,6 +134,7 @@ #define MDNS_DYNAMIC_TXT_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER +#define MK2PVROUTER_LISTENER_COUNT 1 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER diff --git a/tests/components/mk2pvrouter/common.yaml b/tests/components/mk2pvrouter/common.yaml new file mode 100644 index 0000000000..4421c09854 --- /dev/null +++ b/tests/components/mk2pvrouter/common.yaml @@ -0,0 +1,46 @@ +mk2pvrouter: + id: test_mk2pvrouter + uart_id: uart_bus + +sensor: + - platform: mk2pvrouter + name: Power + tag: P + mk2pvrouter_id: test_mk2pvrouter + unit_of_measurement: W + device_class: power + state_class: measurement + accuracy_decimals: 0 + + - platform: mk2pvrouter + name: Voltage + tag: V + mk2pvrouter_id: test_mk2pvrouter + unit_of_measurement: V + device_class: voltage + state_class: measurement + accuracy_decimals: 2 + filters: + # Device sends voltage * 100 + - multiply: 0.01 + + - platform: mk2pvrouter + name: Energy + tag: E + mk2pvrouter_id: test_mk2pvrouter + unit_of_measurement: Wh + device_class: energy + state_class: total_increasing + accuracy_decimals: 0 + + - platform: mk2pvrouter + name: Temperature + tag: T1 + mk2pvrouter_id: test_mk2pvrouter + unit_of_measurement: "°C" + device_class: temperature + state_class: measurement + accuracy_decimals: 2 + filters: + # Device sends temperature * 100 + - multiply: 0.01 diff --git a/tests/components/mk2pvrouter/test.esp32-idf.yaml b/tests/components/mk2pvrouter/test.esp32-idf.yaml new file mode 100644 index 0000000000..66539a4dd7 --- /dev/null +++ b/tests/components/mk2pvrouter/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml + mk2pvrouter: !include common.yaml diff --git a/tests/components/mk2pvrouter/test.esp8266-ard.yaml b/tests/components/mk2pvrouter/test.esp8266-ard.yaml new file mode 100644 index 0000000000..50a45a6ca5 --- /dev/null +++ b/tests/components/mk2pvrouter/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/esp8266-ard.yaml + mk2pvrouter: !include common.yaml diff --git a/tests/components/mk2pvrouter/test.rp2040-ard.yaml b/tests/components/mk2pvrouter/test.rp2040-ard.yaml new file mode 100644 index 0000000000..f8a5a620b3 --- /dev/null +++ b/tests/components/mk2pvrouter/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/rp2040-ard.yaml + mk2pvrouter: !include common.yaml diff --git a/tests/test_build_components/common/uart_9600_even_7bits/esp32-ard.yaml b/tests/test_build_components/common/uart_9600_even_7bits/esp32-ard.yaml new file mode 100644 index 0000000000..f0d24b9a18 --- /dev/null +++ b/tests/test_build_components/common/uart_9600_even_7bits/esp32-ard.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for ESP32 Arduino tests - 9600 baud, EVEN parity, 7 data bits + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml b/tests/test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml new file mode 100644 index 0000000000..e85fa7fc71 --- /dev/null +++ b/tests/test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for ESP32 IDF tests - 9600 baud, EVEN parity, 7 data bits + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_9600_even_7bits/esp8266-ard.yaml b/tests/test_build_components/common/uart_9600_even_7bits/esp8266-ard.yaml new file mode 100644 index 0000000000..488bfdbeab --- /dev/null +++ b/tests/test_build_components/common/uart_9600_even_7bits/esp8266-ard.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for ESP8266 Arduino tests - 9600 baud even parity, 7 data bits + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_9600_even_7bits/rp2040-ard.yaml b/tests/test_build_components/common/uart_9600_even_7bits/rp2040-ard.yaml new file mode 100644 index 0000000000..08bec00820 --- /dev/null +++ b/tests/test_build_components/common/uart_9600_even_7bits/rp2040-ard.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for RP2040 Arduino tests - 9600 baud even parity, 7 data bits + +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 + parity: EVEN + data_bits: 7 + stop_bits: 1