[emontx] Lock the emonTx on reboot when its firmware supports it (#20621)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Frédéric Metrich
2026-10-13 12:37:09 -10:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5c23a827b6
commit 7d735f0677
13 changed files with 436 additions and 62 deletions
+15 -3
View File
@@ -23,11 +23,18 @@ DEPENDENCIES = ["uart"]
emontx_ns = cg.esphome_ns.namespace("emontx")
EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice)
LockOnReboot = emontx_ns.enum("LockOnReboot", is_class=True)
CONF_EMONTX_ID = "emontx_id"
CONF_TAG_NAME = "tag_name"
CONF_ON_JSON = "on_json"
CONF_LOCK_ON_REBOOT = "lock_on_reboot"
LOCK_ON_REBOOT_AUTO = "auto"
LOCK_ON_REBOOT_MODES = {
LOCK_ON_REBOOT_AUTO: LockOnReboot.LOCK_ON_REBOOT_AUTO,
True: LockOnReboot.LOCK_ON_REBOOT_ALWAYS,
False: LockOnReboot.LOCK_ON_REBOOT_NEVER,
}
DOMAIN = "emontx"
@@ -59,7 +66,9 @@ CONFIG_SCHEMA = (
cv.GenerateID(): cv.declare_id(EmonTx),
cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(),
cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(),
cv.Optional(CONF_LOCK_ON_REBOOT, default=False): cv.boolean,
cv.Optional(CONF_LOCK_ON_REBOOT, default=LOCK_ON_REBOOT_AUTO): cv.Any(
cv.boolean, cv.one_of(LOCK_ON_REBOOT_AUTO, lower=True)
),
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -135,8 +144,11 @@ async def to_code(config: ConfigType) -> None:
await uart.register_uart_device(var, config)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if config[CONF_LOCK_ON_REBOOT]:
cg.add(var.set_lock_on_reboot(True))
lock = config[CONF_LOCK_ON_REBOOT]
cg.add(var.set_lock_on_reboot(LOCK_ON_REBOOT_MODES[lock]))
if lock == LOCK_ON_REBOOT_AUTO:
# Locking depends on the firmware version the emonTx reports
cg.add_define("USE_EMONTX_VERSION")
# Action: emontx.send_command
+129 -27
View File
@@ -7,30 +7,85 @@ namespace esphome::emontx {
ESPHOME_LOG_TAG(TAG, "emontx");
static constexpr uint32_t UNLOCK_ID = 0;
// The emonTx joins bytes received while a command is pending into that command, so the next one waits
static constexpr uint32_t COMMAND_GAP_MS = 250;
#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;
static constexpr uint32_t VERSION_REQUEST_ID = 1;
// Without a reply, 'v' is sent again every VERSION_RETRY_FRAMES data lines, VERSION_MAX_ATTEMPTS times in all
static constexpr uint8_t VERSION_RETRY_FRAMES = 2;
static constexpr uint8_t VERSION_MAX_ATTEMPTS = 3;
// 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:";
// Every data line starts with the message counter, in JSON or text format
static const char MSG_JSON_PREFIX[] PROGMEM = "{\"MSG\":";
static const char MSG_TEXT_PREFIX[] PROGMEM = "MSG:";
// First firmware with 'emonlock' and 'emonunlock'; older firmware takes them for the bootloader command
static constexpr uint32_t LOCK_MIN_MAJOR = 1;
static constexpr uint32_t LOCK_MIN_MINOR = 2;
// True if line starts with the PROGMEM prefix and has more after it
static bool starts_with(const char *line, size_t len, const char *prefix, size_t prefix_len) {
return len > prefix_len && ESPHOME_strncmp_P(line, prefix, prefix_len) == 0;
}
// Reads the decimal number (up to 9 digits) at pos and moves pos past it; false if there is none
static bool read_number(const char *&pos, const char *end, uint32_t &value) {
const char *start = pos;
value = 0;
while (pos < end && pos - start < 9 && *pos >= '0' && *pos <= '9')
value = value * 10 + (*pos++ - '0');
return pos != start;
}
// True for a release such as "v1.2.0" or "v1.3.0-rc1" from LOCK_MIN_MAJOR.LOCK_MIN_MINOR on
static bool supports_lock(const char *version, const char *end) {
if (version < end && (*version == 'v' || *version == 'V'))
version++;
uint32_t major;
if (!read_number(version, end, major) || version == end || *version != '.')
return false;
version++;
uint32_t minor;
if (!read_number(version, end, minor))
return false;
return major > LOCK_MIN_MAJOR || (major == LOCK_MIN_MAJOR && minor >= LOCK_MIN_MINOR);
}
#endif
void EmonTx::setup() {
this->buffer_pos_ = 0;
if (this->lock_on_reboot_) {
// The line feed ends any boot noise first
ESP_LOGI(TAG, "Unlocking emonTx after boot");
if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) {
// The line feed ends any boot noise
this->write_byte('\n');
this->send_command("emonunlock");
this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() {
this->unlock_();
#ifdef USE_EMONTX_VERSION
if (this->wants_version_())
this->request_version_();
#endif
});
return;
}
#ifdef USE_EMONTX_VERSION
if (this->version_text_sensor_ != nullptr)
// With AUTO, the reply decides whether to unlock
if (this->wants_version_())
this->request_version_();
#endif
}
void EmonTx::unlock_() {
ESP_LOGI(TAG, "Unlocking emonTx");
this->send_command("emonunlock");
}
void EmonTx::on_shutdown() {
if (!this->lock_on_reboot_)
bool lock = this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS;
#ifdef USE_EMONTX_VERSION
lock |= this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && this->lock_supported_;
#endif
if (!lock)
return;
if (this->is_paused()) {
ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused");
@@ -63,7 +118,7 @@ void EmonTx::loop() {
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)
if (this->wants_version_())
this->request_version_();
#endif
}
@@ -96,8 +151,10 @@ void EmonTx::loop() {
this->parse_json_(this->buffer_.data(), len);
}
#ifdef USE_EMONTX_VERSION
if (this->version_text_sensor_ != nullptr)
if (this->wants_version_()) {
this->parse_msg_number_(this->buffer_.data(), len);
this->parse_version_line_(this->buffer_.data(), len);
}
#endif
}
} else if (received == '\r' || this->discarding_line_) {
@@ -124,16 +181,6 @@ 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<uint32_t>()) {
// 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;
@@ -145,17 +192,50 @@ 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_() {
// The firmware may have changed; until the reply, an unknown version gets no lock commands
this->lock_supported_ = false;
this->version_frames_left_ = VERSION_RETRY_FRAMES * VERSION_MAX_ATTEMPTS;
this->send_version_request_();
}
// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_()
void EmonTx::send_version_request_() {
if (this->is_paused())
return;
this->write_byte('\n');
this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); });
this->set_timeout(VERSION_REQUEST_ID, COMMAND_GAP_MS, [this]() { this->send_command("v"); });
}
void EmonTx::parse_msg_number_(const char *line, size_t len) {
const char *pos;
if (starts_with(line, len, MSG_JSON_PREFIX, sizeof(MSG_JSON_PREFIX) - 1)) {
pos = line + sizeof(MSG_JSON_PREFIX) - 1;
} else if (starts_with(line, len, MSG_TEXT_PREFIX, sizeof(MSG_TEXT_PREFIX) - 1)) {
pos = line + sizeof(MSG_TEXT_PREFIX) - 1;
} else {
return;
}
uint32_t msg_number;
if (!read_number(pos, line + len, msg_number))
return;
// MSG restarts at 1 when the emonTx reboots, as it does after a firmware update
if (msg_number <= this->last_msg_number_) {
this->request_version_();
} else if (this->version_frames_left_ > 0) {
if (--this->version_frames_left_ == 0) {
ESP_LOGW(TAG, "No firmware version from the emonTx, it may still be locked");
} else if (this->version_frames_left_ % VERSION_RETRY_FRAMES == 0) {
this->send_version_request_();
}
}
this->last_msg_number_ = msg_number;
}
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)
if (!starts_with(line, len, VERSION_PREFIX, prefix_len))
return;
const char *version = line + prefix_len;
const char *end = line + len;
@@ -164,7 +244,23 @@ void EmonTx::parse_version_line_(const char *line, size_t len) {
if (version == end)
return;
ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast<int>(end - version), version);
this->version_text_sensor_->publish_state(version, end - version);
this->version_frames_left_ = 0;
#ifdef USE_EMONTX_VERSION_TEXT_SENSOR
if (this->version_text_sensor_ != nullptr)
this->version_text_sensor_->publish_state(version, end - version);
#endif
bool supported = supports_lock(version, end);
if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && supported && !this->unlock_sent_) {
// Once the rest of the reply has gone out; a pause drops it, and the version request on resume retries
this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() {
if (this->is_paused())
return;
this->unlock_();
this->unlock_sent_ = true;
});
}
this->lock_supported_ = supported;
}
#endif
@@ -173,7 +269,13 @@ void EmonTx::parse_version_line_(const char *line, size_t len) {
*/
void EmonTx::dump_config() {
ESP_LOGCONFIG(TAG, "EmonTx:");
ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_));
const char *lock = LOG_STR_LITERAL("no");
if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) {
lock = LOG_STR_LITERAL("yes");
} else if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO) {
lock = LOG_STR_LITERAL("auto");
}
ESP_LOGCONFIG(TAG, " Lock on reboot: %s", lock);
#ifdef USE_SENSOR
ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size());
@@ -183,7 +285,7 @@ void EmonTx::dump_config() {
#else
ESP_LOGCONFIG(TAG, " Sensor support: DISABLED");
#endif
#ifdef USE_EMONTX_VERSION
#ifdef USE_EMONTX_VERSION_TEXT_SENSOR
LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_);
#endif
}
+24 -5
View File
@@ -13,7 +13,7 @@
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#endif
#ifdef USE_EMONTX_VERSION
#ifdef USE_EMONTX_VERSION_TEXT_SENSOR
#include "esphome/components/text_sensor/text_sensor.h"
#endif
@@ -22,6 +22,12 @@ namespace esphome::emontx {
/// Maximum line length in bytes (plus one byte reserved for null terminator)
static constexpr size_t MAX_LINE_LENGTH = 1024;
enum class LockOnReboot : uint8_t {
LOCK_ON_REBOOT_NEVER,
LOCK_ON_REBOOT_ALWAYS,
LOCK_ON_REBOOT_AUTO, // Only when the emonTx reports firmware v1.2.0 or later
};
/**
* @class EmonTx
* @brief Main class for the EmonTx component.
@@ -30,7 +36,7 @@ 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
#ifdef USE_EMONTX_VERSION_TEXT_SENSOR
SUB_TEXT_SENSOR(version)
#endif
public:
@@ -56,8 +62,8 @@ class EmonTx final : public Component, public uart::UARTDevice {
bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); }
/// Lock the emonTx ('emonlock') before a reboot, so serial garbage during the reboot is not taken
/// as a command, and unlock it in setup(). Needs emonTx firmware v1.2.0 or later.
void set_lock_on_reboot(bool lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; }
/// as a command, and unlock it after boot. Needs emonTx firmware v1.2.0 or later, which AUTO checks.
void set_lock_on_reboot(LockOnReboot lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; }
#ifdef USE_SENSOR
void init_sensors(size_t count) { this->sensors_.init(count); }
@@ -66,8 +72,18 @@ class EmonTx final : public Component, public uart::UARTDevice {
protected:
void parse_json_(const char *data, size_t len);
void unlock_();
#ifdef USE_EMONTX_VERSION
bool wants_version_() const {
#ifdef USE_EMONTX_VERSION_TEXT_SENSOR
if (this->version_text_sensor_ != nullptr)
return true;
#endif
return this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO;
}
void request_version_();
void send_version_request_();
void parse_msg_number_(const char *line, size_t len);
void parse_version_line_(const char *line, size_t len);
#endif
@@ -79,9 +95,12 @@ class EmonTx final : public Component, public uart::UARTDevice {
uint16_t buffer_pos_{0};
std::atomic<bool> paused_{false};
bool was_paused_{false};
bool lock_on_reboot_{false};
LockOnReboot lock_on_reboot_{LockOnReboot::LOCK_ON_REBOOT_NEVER};
bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line
#ifdef USE_EMONTX_VERSION
bool lock_supported_{false}; // The last version reported is v1.2.0 or later
bool unlock_sent_{false}; // AUTO has sent 'emonunlock' since boot
uint8_t version_frames_left_{0}; // Data lines still to wait for a version reply; 0 when none is owed
uint32_t last_msg_number_{0};
#endif
std::array<char, MAX_LINE_LENGTH + 1> buffer_{};
@@ -21,3 +21,4 @@ async def to_code(config: ConfigType) -> None:
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")
cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR")
+1
View File
@@ -69,6 +69,7 @@
#define USE_DISPLAY
#define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1
#define USE_EMONTX_VERSION
#define USE_EMONTX_VERSION_TEXT_SENSOR
#define USE_ENTITY_DEVICE_CLASS
#define USE_ENTITY_ICON
#define USE_ENTITY_UNIT_OF_MEASUREMENT
@@ -0,0 +1,38 @@
"""Tests for the emontx lock_on_reboot option: auto (the default), true or false."""
import pytest
from esphome import config_validation as cv
from esphome.components.emontx import (
CONF_LOCK_ON_REBOOT,
CONFIG_SCHEMA,
LOCK_ON_REBOOT_AUTO,
)
def _lock_key() -> cv.Optional:
return next(key for key in CONFIG_SCHEMA.schema if key == CONF_LOCK_ON_REBOOT)
def test_default_is_auto() -> None:
assert _lock_key().default() == LOCK_ON_REBOOT_AUTO
@pytest.mark.parametrize(
("value", "expected"),
[
("auto", LOCK_ON_REBOOT_AUTO),
("AUTO", LOCK_ON_REBOOT_AUTO),
(True, True),
(False, False),
("yes", True),
("off", False),
],
)
def test_accepted_values(value: object, expected: object) -> None:
assert CONFIG_SCHEMA.schema[_lock_key()](value) == expected
def test_other_values_rejected() -> None:
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA.schema[_lock_key()]("sometimes")
@@ -0,0 +1,9 @@
emontx:
id: test_emontx
lock_on_reboot: true
text_sensor:
- platform: emontx
emontx_id: test_emontx
version:
name: emonTx firmware version
+21
View File
@@ -8,6 +8,8 @@
#include <vector>
#include "esphome/components/uart/uart_component.h"
#include "esphome/components/emontx/emontx.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
namespace esphome::emontx::testing {
@@ -42,4 +44,23 @@ class MockUARTComponent : public uart::UARTComponent {
#endif // defined(USE_ESP8266) || defined(USE_ESP32)
};
// The test main does not construct App as generated code does
inline void construct_app() {
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
(void) APP_CONSTRUCTED;
}
inline void cancel_timers(EmonTx *emontx) {
for (uint32_t id = 0; id < 3; id++)
App.scheduler.cancel_timeout(emontx, id);
App.scheduler.call(millis());
}
// Lets timeouts due within duration_ms run; the scheduler reads the real clock
inline void run_timers(uint32_t duration_ms) {
App.scheduler.call(millis());
delay(duration_ms); // NOLINT - test only
App.scheduler.call(millis());
}
} // namespace esphome::emontx::testing
-7
View File
@@ -8,7 +8,6 @@ button:
emontx:
id: test_emontx
lock_on_reboot: true
on_json:
- then:
- logger.log: "Got JSON"
@@ -24,9 +23,3 @@ 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
+180 -5
View File
@@ -1,5 +1,6 @@
#include <gtest/gtest.h>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
@@ -13,11 +14,16 @@ namespace {
class EmonTxTest : public ::testing::Test {
protected:
void SetUp() override {
construct_app();
this->sut_.set_uart_parent(&this->uart_);
this->sut_.add_on_data_callback([this](StringRef line) { this->lines_.emplace_back(line.c_str(), line.size()); });
this->sut_.add_on_json_callback([this](JsonObject, StringRef) { this->json_count_++; });
}
void TearDown() override { cancel_timers(&this->sut_); }
std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); }
MockUARTComponent uart_;
EmonTx sut_;
std::vector<std::string> lines_;
@@ -160,7 +166,7 @@ TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) {
TEST_F(EmonTxTest, ShutdownLocksWhenEnabled) {
EXPECT_CALL(this->uart_, flush()).Times(1);
this->sut_.set_lock_on_reboot(true);
this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS);
this->sut_.on_shutdown();
EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "emonlock\n");
}
@@ -174,16 +180,20 @@ TEST_F(EmonTxTest, ShutdownSendsNothingByDefault) {
TEST_F(EmonTxTest, ShutdownSendsNothingWhilePaused) {
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.set_lock_on_reboot(true);
this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS);
this->sut_.set_paused(true);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty());
}
TEST_F(EmonTxTest, SetupUnlocksWhenEnabled) {
this->sut_.set_lock_on_reboot(true);
// The emonTx would join "emonunlock" to the pending empty line if both came in one burst
TEST_F(EmonTxTest, SetupUnlocksAfterLineFeedWhenEnabled) {
this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS);
this->sut_.setup();
EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "\nemonunlock\n");
EXPECT_EQ(this->tx_(), "\n");
run_timers(300);
EXPECT_EQ(this->tx_(), "\nemonunlock\n");
}
TEST_F(EmonTxTest, SetupSendsNothingByDefault) {
@@ -191,4 +201,169 @@ TEST_F(EmonTxTest, SetupSendsNothingByDefault) {
EXPECT_TRUE(this->uart_.tx.empty());
}
class EmonTxLockAutoTest : public EmonTxTest {
protected:
void SetUp() override {
EmonTxTest::SetUp();
this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_AUTO);
}
void report_version_(const char *version) {
this->uart_.push_rx(" - Version: ");
this->uart_.push_rx(version);
this->uart_.push_rx("\r\n");
this->sut_.loop();
}
};
// Only 'v' goes out until the reply shows the firmware knows 'emonunlock'
TEST_F(EmonTxLockAutoTest, SetupOnlyRequestsVersion) {
this->sut_.setup();
run_timers(300);
EXPECT_EQ(this->tx_(), "\nv\n");
}
TEST_F(EmonTxLockAutoTest, SupportedFirmwareUnlockedAndLockedOnShutdown) {
this->report_version_("v1.2.0");
EXPECT_TRUE(this->uart_.tx.empty());
run_timers(300);
EXPECT_EQ(this->tx_(), "emonunlock\n");
EXPECT_CALL(this->uart_, flush()).Times(1);
this->sut_.on_shutdown();
EXPECT_EQ(this->tx_(), "emonunlock\nemonlock\n");
}
// Firmware before v1.2.0 reads "emon..." as the bootloader command
TEST_F(EmonTxLockAutoTest, OlderFirmwareGetsNoLockCommands) {
this->report_version_("v1.1.1");
run_timers(300);
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty());
}
TEST_F(EmonTxLockAutoTest, NothingSentWithoutVersion) {
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty());
}
TEST_F(EmonTxLockAutoTest, VersionFormats) {
for (const char *version : {"V1.10.0", "v2.0.0", "1.2.0", "v1.3.0-rc1"}) {
this->report_version_("v0.99.0"); // So the next one is a change
this->report_version_(version);
EXPECT_CALL(this->uart_, flush()).Times(1);
this->sut_.on_shutdown();
EXPECT_NE(this->tx_().find("emonlock"), std::string::npos) << version;
::testing::Mock::VerifyAndClearExpectations(&this->uart_);
this->uart_.tx.clear();
}
for (const char *version : {"v1.1.1", "v0.99.9-test", "None", "768be1b", "v1", "v.2"}) {
this->report_version_(version);
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty()) << version;
::testing::Mock::VerifyAndClearExpectations(&this->uart_);
}
}
// The emonTx clears its lock when it reboots, so a repeated report needs no second unlock
TEST_F(EmonTxLockAutoTest, UnlockedOnlyOnce) {
this->report_version_("v1.2.0");
run_timers(300);
this->report_version_("v1.2.0");
run_timers(300);
EXPECT_EQ(this->tx_(), "emonunlock\n");
}
TEST_F(EmonTxLockAutoTest, RequestRetriedEverySecondDataLine) {
this->sut_.setup();
run_timers(300);
this->uart_.push_rx("{\"MSG\":10}\n");
this->sut_.loop();
EXPECT_EQ(this->tx_(), "\nv\n");
// Text format counts too
this->uart_.push_rx("MSG:11,V1:230.1\n");
this->sut_.loop();
run_timers(300);
EXPECT_EQ(this->tx_(), "\nv\n\nv\n");
this->report_version_("v1.1.1");
this->uart_.push_rx("{\"MSG\":12}\n{\"MSG\":13}\n{\"MSG\":14}\n");
this->sut_.loop();
run_timers(300);
EXPECT_EQ(this->tx_(), "\nv\n\nv\n");
}
// Each attempt sends its line feed at once; counting those needs no wait for the delayed 'v'
TEST_F(EmonTxLockAutoTest, RetriesStopAfterThreeAttempts) {
this->sut_.setup();
for (int msg = 10; msg < 20; msg++) {
char line[24];
snprintf(line, sizeof(line), "{\"MSG\":%d}\n", msg);
this->uart_.push_rx(line);
this->sut_.loop();
}
EXPECT_EQ(this->tx_(), "\n\n\n");
}
// E.g. a downgrade: after the reboot, the new firmware never answers 'v'
TEST_F(EmonTxLockAutoTest, NoLockAfterUnansweredRequest) {
this->report_version_("v1.2.0");
run_timers(300);
for (int msg : {50, 1, 2, 3, 4, 5, 6, 7}) {
char line[24];
snprintf(line, sizeof(line), "MSG:%d\n", msg);
this->uart_.push_rx(line);
this->sut_.loop();
}
this->uart_.tx.clear();
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty());
}
TEST_F(EmonTxLockAutoTest, NoLockWhileVersionPending) {
this->report_version_("v1.2.0");
run_timers(300);
this->uart_.push_rx("MSG:50\nMSG:1\n");
this->sut_.loop();
this->uart_.tx.clear();
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty());
}
TEST_F(EmonTxLockAutoTest, UnlockDroppedByPauseSentAfterResume) {
this->report_version_("v1.2.0");
this->sut_.set_paused(true);
this->sut_.loop();
run_timers(300);
EXPECT_TRUE(this->uart_.tx.empty());
// Resume asks for the version again, and its reply brings the unlock
this->sut_.set_paused(false);
this->sut_.loop();
run_timers(300);
this->uart_.push_rx("> Firmware:\r\n"); // Ends the line skipped after a resume
this->report_version_("v1.2.0");
run_timers(300);
EXPECT_EQ(this->tx_(), "\nv\nemonunlock\n");
}
// The lines of a 'v' reply before the version line are not data lines
TEST_F(EmonTxLockAutoTest, ReplyBannerNotCountedAsDataLines) {
this->sut_.setup();
run_timers(300);
this->uart_.push_rx(
"> Board:\r\n - emonPi3/emonTx6 (arch. rev. 1)\r\n> Firmware:\r\n - Bootloader: UART (.bin)\r\n");
this->sut_.loop();
run_timers(300);
EXPECT_EQ(this->tx_(), "\nv\n");
}
} // namespace esphome::emontx::testing
@@ -2,3 +2,4 @@ packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
emontx: !include common.yaml
actions: !include common-actions.yaml
version: !include common-version.yaml
@@ -7,5 +7,6 @@ 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")
cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR")
manifest.to_code = to_code_testing
@@ -4,8 +4,6 @@
#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 {
@@ -14,24 +12,15 @@ 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;
construct_app();
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());
}
void TearDown() override { cancel_timers(&this->sut_); }
// 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());
}
static void run_timers() { testing::run_timers(300); }
std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); }
@@ -51,8 +40,11 @@ TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) {
}
TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) {
this->sut_.set_lock_on_reboot(true);
this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS);
this->sut_.setup();
EXPECT_EQ(this->tx_(), "\n");
run_timers();
EXPECT_EQ(this->tx_(), "\nemonunlock\n\n");
run_timers();
@@ -88,6 +80,15 @@ TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) {
EXPECT_EQ(this->tx_(), "\nv\n");
}
TEST_F(EmonTxVersionTest, TextMsgCounterResetRequestsVersion) {
this->uart_.push_rx("MSG:41,V1:230.1\r\nMSG:1,V1:230.4\r\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);