From 77c9fa2fa4557c332ea2aebd9097482a1a9fa2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:43:02 +0200 Subject: [PATCH] [emontx] Read the emonTx firmware version (#20619) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/emontx.cpp | 71 ++++++++++- esphome/components/emontx/emontx.h | 13 ++ .../components/emontx/text_sensor/__init__.py | 23 ++++ esphome/core/defines.h | 1 + tests/components/emontx/common.yaml | 6 + .../components/emontx/text_sensor/__init__.py | 11 ++ .../text_sensor/emontx_version_test.cpp | 116 ++++++++++++++++++ 7 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 esphome/components/emontx/text_sensor/__init__.py create mode 100644 tests/components/emontx/text_sensor/__init__.py create mode 100644 tests/components/emontx/text_sensor/emontx_version_test.cpp diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 14dc89e56a..9c8436c007 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -1,19 +1,32 @@ #include "emontx.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/components/json/json_util.h" namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); +#ifdef USE_EMONTX_VERSION +static constexpr uint32_t VERSION_REQUEST_ID = 0; +// The emonTx joins bytes received while a command is pending into that command, so 'v' waits +static constexpr uint32_t VERSION_REQUEST_DELAY_MS = 250; +// Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1" +static const char VERSION_PREFIX[] PROGMEM = " - Version:"; +#endif + void EmonTx::setup() { this->buffer_pos_ = 0; - if (!this->lock_on_reboot_) - return; - // The line feed ends any boot noise first - ESP_LOGI(TAG, "Unlocking emonTx after boot"); - this->write_byte('\n'); - this->send_command("emonunlock"); + if (this->lock_on_reboot_) { + // The line feed ends any boot noise first + ESP_LOGI(TAG, "Unlocking emonTx after boot"); + this->write_byte('\n'); + this->send_command("emonunlock"); + } +#ifdef USE_EMONTX_VERSION + if (this->version_text_sensor_ != nullptr) + this->request_version_(); +#endif } void EmonTx::on_shutdown() { @@ -48,6 +61,11 @@ void EmonTx::loop() { uint8_t dropped; while (this->available() > 0 && this->read_byte(&dropped)) this->discarding_line_ = dropped != '\n'; +#ifdef USE_EMONTX_VERSION + // The emonTx may have been flashed, or a request dropped, during the pause + if (this->version_text_sensor_ != nullptr) + this->request_version_(); +#endif } // Read all available data to prevent UART buffer overflow @@ -77,6 +95,10 @@ void EmonTx::loop() { ESP_LOGV(TAG, "Line is JSON, parsing..."); this->parse_json_(this->buffer_.data(), len); } +#ifdef USE_EMONTX_VERSION + if (this->version_text_sensor_ != nullptr) + this->parse_version_line_(this->buffer_.data(), len); +#endif } } else if (received == '\r' || this->discarding_line_) { // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause @@ -102,6 +124,16 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } #endif +#ifdef USE_EMONTX_VERSION + auto msg = root["MSG"]; + if (this->version_text_sensor_ != nullptr && msg.is()) { + // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update + uint32_t msg_number = msg; + if (msg_number <= this->last_msg_number_) + this->request_version_(); + this->last_msg_number_ = msg_number; + } +#endif this->json_callbacks_.call(root, StringRef(data, len)); return true; @@ -112,6 +144,30 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } +#ifdef USE_EMONTX_VERSION +// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() +void EmonTx::request_version_() { + if (this->is_paused()) + return; + this->write_byte('\n'); + this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); }); +} + +void EmonTx::parse_version_line_(const char *line, size_t len) { + constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1; + if (len <= prefix_len || ESPHOME_strncmp_P(line, VERSION_PREFIX, prefix_len) != 0) + return; + const char *version = line + prefix_len; + const char *end = line + len; + while (version < end && *version == ' ') + version++; + if (version == end) + return; + ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast(end - version), version); + this->version_text_sensor_->publish_state(version, end - version); +} +#endif + /** * @brief Logs the EmonTx component configuration details. */ @@ -127,6 +183,9 @@ void EmonTx::dump_config() { #else ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); #endif +#ifdef USE_EMONTX_VERSION + LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_); +#endif } /** diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index bcec7ce171..8f6a2424c9 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -13,6 +13,9 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#ifdef USE_EMONTX_VERSION +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::emontx { @@ -27,6 +30,9 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * extracts tags and values, and publishes them to registered sensors. */ class EmonTx final : public Component, public uart::UARTDevice { +#ifdef USE_EMONTX_VERSION + SUB_TEXT_SENSOR(version) +#endif public: EmonTx() = default; @@ -60,6 +66,10 @@ class EmonTx final : public Component, public uart::UARTDevice { protected: void parse_json_(const char *data, size_t len); +#ifdef USE_EMONTX_VERSION + void request_version_(); + void parse_version_line_(const char *line, size_t len); +#endif #ifdef USE_SENSOR FixedVector> sensors_{}; @@ -71,6 +81,9 @@ class EmonTx final : public Component, public uart::UARTDevice { bool was_paused_{false}; bool lock_on_reboot_{false}; bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line +#ifdef USE_EMONTX_VERSION + uint32_t last_msg_number_{0}; +#endif std::array buffer_{}; }; diff --git a/esphome/components/emontx/text_sensor/__init__.py b/esphome/components/emontx/text_sensor/__init__.py new file mode 100644 index 0000000000..9b3065f0f8 --- /dev/null +++ b/esphome/components/emontx/text_sensor/__init__.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from .. import CONF_EMONTX_ID, EmonTx + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_EMONTX_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None: + cg.add_define("USE_EMONTX_VERSION") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a7d2084a54..d9b12fdddb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -68,6 +68,7 @@ #define USE_DEVICES #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 +#define USE_EMONTX_VERSION #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index e609a217e0..4a5e347053 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -24,3 +24,9 @@ sensor: tag_name: P1 emontx_id: test_emontx unit_of_measurement: W + +text_sensor: + - platform: emontx + emontx_id: test_emontx + version: + name: emonTx firmware version diff --git a/tests/components/emontx/text_sensor/__init__.py b/tests/components/emontx/text_sensor/__init__.py new file mode 100644 index 0000000000..60a67b5a0b --- /dev/null +++ b/tests/components/emontx/text_sensor/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The platform's own to_code needs a configured hub; only its define is wanted here. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_EMONTX_VERSION") + + manifest.to_code = to_code_testing diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp new file mode 100644 index 0000000000..fe905a0e95 --- /dev/null +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -0,0 +1,116 @@ +#include + +#include + +#include "esphome/components/emontx/emontx.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "../common.h" + +namespace esphome::emontx::testing { + +namespace { +class EmonTxVersionTest : public ::testing::Test { + protected: + void SetUp() override { + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_version_text_sensor(&this->version_); + } + + void TearDown() override { + App.scheduler.cancel_timeout(&this->sut_, uint32_t{0}); + App.scheduler.call(millis()); + } + + // Lets the delayed 'v' go out + static void run_timers() { + App.scheduler.call(millis()); + delay(300); // NOLINT - test only, the scheduler reads the real clock + App.scheduler.call(millis()); + } + + std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } + + MockUARTComponent uart_; + EmonTx sut_; + text_sensor::TextSensor version_; +}; +} // namespace + +// 'v' must not reach the emonTx in the same burst as the line feed, or it joins the pending command +TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) { + this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) { + this->sut_.set_lock_on_reboot(true); + this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\nemonunlock\n\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nemonunlock\n\nv\n"); +} + +TEST_F(EmonTxVersionTest, VersionLinePublished) { + this->uart_.push_rx("> Firmware:\r\n - Bootloader: UART (.bin)\r\n - Version: v1.1.1\r\n"); + this->sut_.loop(); + + ASSERT_TRUE(this->version_.has_state()); + EXPECT_EQ(this->version_.get_state(), "v1.1.1"); +} + +TEST_F(EmonTxVersionTest, OtherLinesNotPublished) { + this->uart_.push_rx(" - Build: 768be1b (Default)\r\n{\"P1\":100}\r\n - Version:\r\n - Version: \r\n"); + this->sut_.loop(); + + EXPECT_FALSE(this->version_.has_state()); +} + +// MSG restarts at 1 after a reboot, e.g. a firmware update +TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) { + this->uart_.push_rx("{\"MSG\":41}\n{\"MSG\":42}\n"); + this->sut_.loop(); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->uart_.push_rx("{\"MSG\":1}\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +// The emonTx may have been flashed during the pause, with MSG already past its old value +TEST_F(EmonTxVersionTest, ResumeRequestsVersion) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->sut_.set_paused(false); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxVersionTest, RequestDroppedByPauseSentAgainOnResume) { + this->sut_.setup(); + this->sut_.set_paused(true); + this->sut_.loop(); + run_timers(); + EXPECT_EQ(this->tx_(), "\n"); + + this->sut_.set_paused(false); + this->sut_.loop(); + run_timers(); + EXPECT_EQ(this->tx_(), "\n\nv\n"); +} + +} // namespace esphome::emontx::testing