[mk2pvrouter] Add Mk2PVRouter component with sensor support (#8487)

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
This commit is contained in:
Frédéric Metrich
2026-08-28 21:02:32 +00:00
committed by GitHub
co-authored by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Claude Opus 4.5 pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Copilot J. Nick Koston
parent ed3429d372
commit 0dc0cf83de
16 changed files with 494 additions and 0 deletions
+1
View File
@@ -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
@@ -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)
@@ -0,0 +1,177 @@
#include "mk2pvrouter.h"
#include "esphome/core/log.h"
#include <cstring>
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<const char *>(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<decltype(grp_len)>(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<char *>(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<char *>(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
@@ -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<tag>\t<value>\t<crc>\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<Mk2PVRouterListener *, MK2PVROUTER_LISTENER_COUNT> 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
@@ -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)
@@ -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<float>(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
@@ -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
+1
View File
@@ -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
+46
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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