From 62f8cc343674136751941c5dbcf62b5690788830 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:41:51 -1000 Subject: [PATCH 01/50] [uart] Add read_available helpers to drain available bytes in chunks (#20947) --- esphome/components/uart/uart.h | 54 +++++++ tests/components/uart/uart_read_available.cpp | 144 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 tests/components/uart/uart_read_available.cpp diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index 7a34a20583e..a0840aa26a7 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -1,5 +1,8 @@ #pragma once +#include +#include +#include #include #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -48,6 +51,45 @@ class UARTDevice { size_t available() { return this->parent_->available(); } + /// Pass the bytes available now to on_chunk(data, len) in N byte chunks; returning false stops. + /// Returns false only on a read error. + template bool read_available_chunks(F &&on_chunk) { + return this->read_available_chunks(on_chunk, this->available()); + } + /// Same, reading exactly count bytes; count must not exceed available(). + template bool read_available_chunks(F &&on_chunk, size_t count) { + static_assert(N > 0, "read buffer size must be non-zero"); + uint8_t buf[N]; + while (count > 0) { + const size_t len = std::min(count, N); + if (!this->read_array(buf, len)) { + return false; + } + count -= len; + if (!continue_after(on_chunk, static_cast(buf), len)) { + break; + } + } + return true; + } + + /// Same as read_available_chunks(), calling on_byte(uint8_t) once per byte. + template bool read_available(F &&on_byte) { + return this->read_available(on_byte, this->available()); + } + template bool read_available(F &&on_byte, size_t count) { + return this->read_available_chunks( + [&on_byte](const uint8_t *data, size_t len) { + for (const uint8_t *p = data; p != data + len; p++) { + if (!continue_after(on_byte, *p)) { + return false; + } + } + return true; + }, + count); + } + UARTFlushResult flush() { return this->parent_->flush(); } // Compat APIs @@ -74,6 +116,18 @@ class UARTDevice { UARTParityOptions parity = UART_CONFIG_PARITY_NONE, uint8_t data_bits = 8); protected: + /// Call f(args...) and return its result; a void callback always continues. + template static bool continue_after(F &f, A... args) { + using R = std::invoke_result_t; + if constexpr (std::is_void_v) { + f(args...); + return true; + } else { + static_assert(std::is_same_v, bool>, "callback must return void or bool"); + return f(args...); + } + } + UARTComponent *parent_{nullptr}; }; diff --git a/tests/components/uart/uart_read_available.cpp b/tests/components/uart/uart_read_available.cpp new file mode 100644 index 00000000000..10c3e7fa0ef --- /dev/null +++ b/tests/components/uart/uart_read_available.cpp @@ -0,0 +1,144 @@ +#include +#include "common.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::uart::testing { + +// Feeds bytes from a fixed vector so available() and read_array() agree like a real UART. +class FakeRxUART : public MockUARTComponent { + public: + explicit FakeRxUART(std::vector rx) : rx_(std::move(rx)) { + ON_CALL(*this, available()).WillByDefault(Invoke([this]() { return this->rx_.size() - this->pos_; })); + ON_CALL(*this, read_array(_, _)).WillByDefault(Invoke([this](uint8_t *data, size_t len) { + this->read_sizes.push_back(len); + if (this->fail_after_reads >= 0 && static_cast(this->read_sizes.size()) > this->fail_after_reads) + return false; + if (len > this->rx_.size() - this->pos_) + return false; + std::memcpy(data, this->rx_.data() + this->pos_, len); + this->pos_ += len; + return true; + })); + } + void append(std::initializer_list more) { this->rx_.insert(this->rx_.end(), more); } + size_t remaining() const { return this->rx_.size() - this->pos_; } + + std::vector read_sizes; + int fail_after_reads{-1}; + + protected: + std::vector rx_; + size_t pos_{0}; +}; + +static std::vector make_bytes(size_t n) { + std::vector v(n); + for (size_t i = 0; i < n; i++) + v[i] = static_cast(i); + return v; +} + +TEST(UARTReadAvailableTest, NothingAvailableDoesNotRead) { + ::testing::NiceMock uart(std::vector{}); + UARTDevice dev(&uart); + int calls = 0; + EXPECT_TRUE(dev.read_available([&calls](uint8_t) { calls++; })); + EXPECT_EQ(calls, 0); + EXPECT_TRUE(uart.read_sizes.empty()); +} + +TEST(UARTReadAvailableTest, ReadsEveryByteInChunks) { + ::testing::NiceMock uart(make_bytes(10)); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<4>([&got](uint8_t b) { got.push_back(b); })); + EXPECT_EQ(got, make_bytes(10)); + EXPECT_EQ(uart.read_sizes, (std::vector{4, 4, 2})); +} + +TEST(UARTReadAvailableTest, OnlyBytesAvailableOnEntry) { + ::testing::NiceMock uart(std::vector{1, 2, 3}); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<2>([&](uint8_t b) { + got.push_back(b); + if (b == 1) + uart.append({9, 9}); // arrives while draining; left for the next call + })); + EXPECT_EQ(got, (std::vector{1, 2, 3})); + EXPECT_EQ(uart.remaining(), 2u); +} + +TEST(UARTReadAvailableTest, CountReadsExactlyThatMany) { + ::testing::NiceMock uart(make_bytes(100)); + UARTDevice dev(&uart); + size_t count = 0; + EXPECT_TRUE(dev.read_available<16>([&count](uint8_t) { count++; }, 40)); + EXPECT_EQ(count, 40u); + EXPECT_EQ(uart.read_sizes, (std::vector{16, 16, 8})); + EXPECT_EQ(uart.remaining(), 60u); +} + +TEST(UARTReadAvailableTest, ReadFailureStopsAndReturnsFalse) { + ::testing::NiceMock uart(make_bytes(10)); + uart.fail_after_reads = 1; + UARTDevice dev(&uart); + std::vector got; + EXPECT_FALSE(dev.read_available<4>([&got](uint8_t b) { got.push_back(b); })); + EXPECT_EQ(got, (std::vector{0, 1, 2, 3})); + EXPECT_EQ(uart.read_sizes.size(), 2u); +} + +TEST(UARTReadAvailableTest, ByteCallbackReturningFalseStops) { + ::testing::NiceMock uart(make_bytes(10)); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<4>([&got](uint8_t b) { + got.push_back(b); + return b != 5; + })); + // Stops mid chunk: bytes 6 and 7 were read from the UART but not handed over, 8 and 9 were never read. + EXPECT_EQ(got, (std::vector{0, 1, 2, 3, 4, 5})); + EXPECT_EQ(uart.remaining(), 2u); +} + +TEST(UARTReadAvailableTest, ChunkCallbackGetsWholeChunks) { + ::testing::NiceMock uart(make_bytes(70)); + UARTDevice dev(&uart); + std::vector lens; + std::vector got; + EXPECT_TRUE(dev.read_available_chunks([&](const uint8_t *data, size_t len) { + lens.push_back(len); + got.insert(got.end(), data, data + len); + })); + EXPECT_EQ(lens, (std::vector{64, 6})); + EXPECT_EQ(got, make_bytes(70)); +} + +TEST(UARTReadAvailableTest, ChunkCallbackReturningFalseStops) { + ::testing::NiceMock uart(make_bytes(20)); + UARTDevice dev(&uart); + int chunks = 0; + EXPECT_TRUE(dev.read_available_chunks<8>([&chunks](const uint8_t *, size_t) { + chunks++; + return false; + })); + EXPECT_EQ(chunks, 1); + EXPECT_EQ(uart.remaining(), 12u); +} + +TEST(UARTReadAvailableTest, ChunksWithCountStopEarly) { + ::testing::NiceMock uart(make_bytes(30)); + UARTDevice dev(&uart); + std::vector lens; + EXPECT_TRUE(dev.read_available_chunks<8>( + [&lens](const uint8_t *, size_t len) { + lens.push_back(len); + return lens.size() < 2; + }, + 20)); + EXPECT_EQ(lens, (std::vector{8, 8})); + EXPECT_EQ(uart.remaining(), 14u); +} + +} // namespace esphome::uart::testing From 25b684f7628326deb3b7b86acbd89ef69c359896 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:44:41 -1000 Subject: [PATCH 02/50] [pylontech] Parse each line as it arrives with one fixed buffer (#20959) --- esphome/components/pylontech/__init__.py | 31 +++ esphome/components/pylontech/pylontech.cpp | 192 +++++++------ esphome/components/pylontech/pylontech.h | 66 +++-- .../components/pylontech/sensor/__init__.py | 5 +- .../pylontech/sensor/pylontech_sensor.cpp | 7 +- .../pylontech/sensor/pylontech_sensor.h | 5 +- .../pylontech/text_sensor/__init__.py | 5 +- .../text_sensor/pylontech_text_sensor.cpp | 7 +- .../text_sensor/pylontech_text_sensor.h | 5 +- esphome/core/defines.h | 2 + tests/components/pylontech/__init__.py | 15 ++ tests/components/pylontech/pylontech_test.cpp | 252 ++++++++++++++++++ .../fixtures/uart_mock_pylontech.yaml | 105 ++++++++ tests/integration/test_uart_mock_pylontech.py | 136 ++++++++++ 14 files changed, 716 insertions(+), 117 deletions(-) create mode 100644 tests/components/pylontech/__init__.py create mode 100644 tests/components/pylontech/pylontech_test.cpp create mode 100644 tests/integration/fixtures/uart_mock_pylontech.yaml create mode 100644 tests/integration/test_uart_mock_pylontech.py diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index f9eef4c8096..8bd57f252c1 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -1,9 +1,12 @@ +from dataclasses import dataclass, field import logging import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -24,6 +27,33 @@ PylontechBattery = pylontech_ns.class_("PylontechBattery") CV_NUM_BATTERIES = cv.int_range(1, 16) +_request_listener_slot = cg.slot_counter("PYLONTECH_LISTENER_COUNT") +_request_battery_slot = cg.slot_counter("PYLONTECH_BATTERY_COUNT") + + +@dataclass +class PylontechData: + batteries: set[tuple[str, int]] = field(default_factory=set) + + +def _get_data() -> PylontechData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = PylontechData() + return CORE.data[DOMAIN] + + +async def register_listener(config: ConfigType, listener: MockObj) -> None: + """Register a battery listener; the hub keeps one line slot per distinct battery.""" + hub = await cg.get_variable(config[CONF_PYLONTECH_ID]) + hub_key = str(hub) + _request_listener_slot(hub_key) + data = _get_data() + if (key := (hub_key, config[CONF_BATTERY])) not in data.batteries: + data.batteries.add(key) + _request_battery_slot(hub_key) + cg.add(hub.register_listener(listener)) + + PYLONTECH_COMPONENT_SCHEMA = cv.Schema( { cv.GenerateID(CONF_PYLONTECH_ID): cv.use_id(PylontechComponent), @@ -53,5 +83,6 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 50d8c814fa4..5e48e8b726c 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -1,25 +1,35 @@ #include "pylontech.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#include +#include // Helper macros +#define PARSE_TOKEN_INT(field, field_name) \ + { \ + auto val = parse_number(token_buf); \ + if (val.has_value() && in_range(*val)) { \ + (field) = *val; \ + } else { \ + ESP_LOGV(TAG, "invalid " field_name " in line %s", buffer); \ + this->reject_line_(); \ + return; \ + } \ + } + #define PARSE_INT(field, field_name) \ { \ get_token(token_buf); \ - auto val = parse_number(token_buf); \ - if (val.has_value()) { \ - (field) = val.value(); \ - } else { \ - ESP_LOGD(TAG, "invalid " field_name " in line %s", buffer.substr(0, buffer.size() - 2).c_str()); \ - return; \ - } \ + PARSE_TOKEN_INT(field, field_name) \ } #define PARSE_STR(field, field_name) \ { \ get_token(field); \ if (strlen(field) < 2) { \ - ESP_LOGD(TAG, "too short " field_name " in line %s", buffer.substr(0, buffer.size() - 2).c_str()); \ + ESP_LOGV(TAG, "too short " field_name " in line %s", buffer); \ + this->reject_line_(); \ return; \ } \ } @@ -27,17 +37,26 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech"); -static const int MAX_DATA_LENGTH_BYTES = 256; -static const uint8_t ASCII_LF = 0x0A; + +// Rejects values that would wrap in a narrowed field +template static bool in_range(int value) { + if constexpr (sizeof(T) >= sizeof(int)) { + return true; + } else { + return value >= std::numeric_limits::min() && value <= std::numeric_limits::max(); + } +} PylontechComponent::PylontechComponent() {} void PylontechComponent::dump_config() { ESP_LOGCONFIG(TAG, "pylontech:"); +#ifdef PYLONTECH_LISTENER_COUNT for (PylontechListener *listener : this->listeners_) { listener->dump_config(); } +#endif LOG_UPDATE_INTERVAL(this); } @@ -51,41 +70,70 @@ void PylontechComponent::setup() { void PylontechComponent::update() { this->write_str("pwr\n"); } void PylontechComponent::loop() { + // Drain the UART quickly; pylontech sends large bursts. Logging and publishing wait until it is idle. size_t avail = this->available(); - if (avail > 0) { - // pylontech sends a lot of data very suddenly - // we need to quickly put it all into our own buffer, otherwise the uart's buffer will overflow - int recv = 0; - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; - } - avail -= to_read; - recv += to_read; - - for (size_t i = 0; i < to_read; i++) { - buffer_[buffer_index_write_] += (char) buf[i]; - if (buf[i] == ASCII_LF || buffer_[buffer_index_write_].length() >= MAX_DATA_LENGTH_BYTES) { - // complete line received - buffer_index_write_ = (buffer_index_write_ + 1) % NUM_BUFFERS; +#ifdef PYLONTECH_LISTENER_COUNT + if (avail == 0) { + this->dispatch_one_(); + return; + } +#endif + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(avail, sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; +#ifdef PYLONTECH_LISTENER_COUNT + for (size_t i = 0; i < to_read; i++) { + const char *line = this->line_reader_.feed(buf[i]); + if (line != nullptr) { + this->process_line_(line); + } else if (this->line_reader_.overflowed()) { + this->line_reader_.log_if_overflowed(TAG); + // Tlow.Id sits near the start, so a too long header still sets the column layout + const char *partial = this->line_reader_.partial(); + if (strncmp(partial, "Power", 5) == 0) { + this->parse_header_(partial); } } } - ESP_LOGV(TAG, "received %d bytes", recv); - } else { - // only process one line per call of loop() to not block esphome for too long - if (buffer_index_read_ != buffer_index_write_) { - this->process_line_(buffer_[buffer_index_read_]); - buffer_[buffer_index_read_].clear(); - buffer_index_read_ = (buffer_index_read_ + 1) % NUM_BUFFERS; - } +#endif } } -void PylontechComponent::process_line_(std::string &buffer) { - ESP_LOGV(TAG, "Read from serial: %s", buffer.substr(0, buffer.size() - 2).c_str()); +#ifdef PYLONTECH_LISTENER_COUNT +void PylontechComponent::dispatch_one_() { + for (uint8_t i = 0; this->pending_ != 0 && i < PYLONTECH_BATTERY_COUNT; i++) { + const battery_mask_t bit = 1u << i; + if ((this->pending_ & bit) == 0) { + continue; + } + this->pending_ &= ~bit; + PylontechListener::LineContents *l = &this->slots_[i]; + ESP_LOGV(TAG, "Publishing battery %u", l->bat_num); + for (PylontechListener *listener : this->listeners_) { + if (listener->get_battery() == l->bat_num) { + listener->on_line_read(l); + } + } + return; + } + if (this->rejected_ != 0) { + ESP_LOGW(TAG, "Rejected %u malformed battery lines", this->rejected_); + this->rejected_ = 0; + } +} + +void PylontechComponent::parse_header_(const char *buffer) { + this->has_tlow_id_ = strstr(buffer, "Tlow.Id") != nullptr; + ESP_LOGV(TAG, "header line %s Tlow.Id: %s", this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), + buffer); +} + +// Runs while the UART drains: no logging above VERBOSE and no publishing here +void PylontechComponent::process_line_(const char *buffer) { // clang-format off // example lines to parse: // Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St @@ -96,9 +144,7 @@ void PylontechComponent::process_line_(std::string &buffer) { // 1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal 60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal // clang-format on - PylontechListener::LineContents l{}; - - const char *cursor = buffer.c_str(); + const char *cursor = buffer; char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; // Helper Lambda to extract tokens @@ -116,7 +162,7 @@ void PylontechComponent::process_line_(std::string &buffer) { const char *start = cursor; // Find end of field - while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t' && *cursor != '\r') { + while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { cursor++; } @@ -125,23 +171,33 @@ void PylontechComponent::process_line_(std::string &buffer) { token_buf[token_len] = 0; }; - { - get_token(token_buf); - auto val = parse_number(token_buf); - if (val.has_value() && val.value() > 0) { - l.bat_num = val.value(); - } else if (strcmp(token_buf, "Power") == 0) { + get_token(token_buf); + auto bat = parse_number(token_buf); + if (!bat.has_value()) { + if (strcmp(token_buf, "Power") == 0) { // header line i.e. "Power Volt Curr" and so on - this->has_tlow_id_ = buffer.find("Tlow.Id") != std::string::npos; - ESP_LOGD(TAG, "header line %s Tlow.Id: %s", - this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), - buffer.substr(0, buffer.size() - 2).c_str()); - return; + this->parse_header_(buffer); } else { - ESP_LOGD(TAG, "unknown line %s", buffer.substr(0, buffer.size() - 2).c_str()); - return; + ESP_LOGV(TAG, "unknown line %s", buffer); } + return; } + const unsigned bat_index = static_cast(*bat) - 1u; + if (bat_index >= MAX_BATTERIES || (this->batteries_ & (1u << bat_index)) == 0) { + return; // no listener for this battery + } + const battery_mask_t bat_bit = 1u << bat_index; + // The slot index is the number of listened batteries below this one + uint8_t slot = 0; + for (battery_mask_t below = this->batteries_ & (bat_bit - 1); below != 0; below &= below - 1) { + slot++; + } + const battery_mask_t slot_bit = 1u << slot; + // Cleared until the whole line parses, so a bad line never publishes a half written slot + this->pending_ &= ~slot_bit; + PylontechListener::LineContents &l = this->slots_[slot]; + l.bat_num = *bat; + PARSE_INT(l.volt, "Volt"); PARSE_INT(l.curr, "Curr"); PARSE_INT(l.tempr, "Tempr"); @@ -165,34 +221,20 @@ void PylontechComponent::process_line_(std::string &buffer) { PARSE_STR(l.volt_st, "Volt.St"); PARSE_STR(l.curr_st, "Curr.St"); PARSE_STR(l.temp_st, "Temp.St"); - { - get_token(token_buf); - for (char &i : token_buf) { - if (i == '%') { - i = 0; - break; - } - } - auto coul_val = parse_number(token_buf); - if (coul_val.has_value()) { - l.coulomb = coul_val.value(); - } else { - ESP_LOGD(TAG, "invalid Coulomb in line %s", buffer.substr(0, buffer.size() - 2).c_str()); - return; - } + get_token(token_buf); + if (char *pct = strchr(token_buf, '%')) { + *pct = '\0'; } + PARSE_TOKEN_INT(l.coulomb, "Coulomb"); get_token(token_buf); // Skip Date get_token(token_buf); // Skip Time get_token(token_buf); // Skip B.V.St get_token(token_buf); // Skip B.T.St PARSE_INT(l.mostempr, "Mostempr"); - ESP_LOGD(TAG, "successful line %s", buffer.substr(0, buffer.size() - 2).c_str()); - - for (PylontechListener *listener : this->listeners_) { - listener->on_line_read(&l); - } + this->pending_ |= slot_bit; } +#endif } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index eae5e6e7bca..aa652056dca 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -1,24 +1,42 @@ #pragma once +#include +#include + #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::pylontech { -static const uint8_t NUM_BUFFERS = 20; -static const uint8_t TEXT_SENSOR_MAX_LEN = 14; +static constexpr uint8_t TEXT_SENSOR_MAX_LEN = 14; +// Battery numbers run 1 to MAX_BATTERIES; bit n - 1 of a battery_mask_t stands for battery n +static constexpr uint8_t MAX_BATTERIES = 16; +using battery_mask_t = uint16_t; +static_assert(MAX_BATTERIES <= sizeof(battery_mask_t) * 8, "battery mask too narrow"); class PylontechListener { public: + // No default initializers: the hub's slots stay plain zeroed storage; a parse writes every field struct LineContents { - int bat_num = 0, volt, curr, tempr, tlow, thigh, vlow, vhigh, coulomb, mostempr; - char base_st[TEXT_SENSOR_MAX_LEN] = {0}, volt_st[TEXT_SENSOR_MAX_LEN] = {0}, curr_st[TEXT_SENSOR_MAX_LEN] = {0}, - temp_st[TEXT_SENSOR_MAX_LEN] = {0}; + int32_t curr, tempr, tlow, thigh, mostempr; + uint16_t volt, vlow, vhigh; // mV + uint8_t bat_num, coulomb; + char base_st[TEXT_SENSOR_MAX_LEN], volt_st[TEXT_SENSOR_MAX_LEN], curr_st[TEXT_SENSOR_MAX_LEN], + temp_st[TEXT_SENSOR_MAX_LEN]; }; - virtual void on_line_read(LineContents *line); - virtual void dump_config(); + explicit PylontechListener(uint8_t bat_num) : bat_num_(bat_num) {} + uint8_t get_battery() const { return this->bat_num_; } + + /// Called only with records for this listener's battery. + virtual void on_line_read(LineContents *line) = 0; + virtual void dump_config() = 0; + + protected: + uint8_t bat_num_; }; class PylontechComponent final : public PollingComponent, public uart::UARTDevice { @@ -33,18 +51,34 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic void setup() override; void dump_config() override; - void register_listener(PylontechListener *listener) { this->listeners_.push_back(listener); } +#ifdef PYLONTECH_LISTENER_COUNT + void register_listener(PylontechListener *listener) { + this->listeners_.push_back(listener); + this->batteries_ |= 1u << (listener->get_battery() - 1); + } protected: - void process_line_(std::string &buffer); + void process_line_(const char *buffer); + void parse_header_(const char *buffer); + void dispatch_one_(); + void reject_line_() { + if (this->rejected_ < UINT8_MAX) { + this->rejected_++; + } + } - // ring buffer - std::string buffer_[NUM_BUFFERS]; - int buffer_index_write_ = 0; - int buffer_index_read_ = 0; - bool has_tlow_id_ = false; - - std::vector listeners_{}; + // The longest line, the newer firmware header, is 182 characters + LineReader<200> line_reader_; + StaticVector listeners_; + // One slot per listened battery, in battery order; parsed while the UART drains, published one per + // loop once it is idle. A newer line for a battery replaces the unpublished one. + static_assert(PYLONTECH_BATTERY_COUNT <= MAX_BATTERIES, "more slots than batteries"); + std::array slots_; + battery_mask_t batteries_{0}; // batteries with a listener + battery_mask_t pending_{0}; // bit i set: slots_[i] is waiting to be published + bool has_tlow_id_{false}; + uint8_t rejected_{0}; // malformed battery lines since the last idle pass +#endif }; } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 40391206fbc..51aceace3e7 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -18,7 +18,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns +from .. import CONF_BATTERY, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns, register_listener PylontechSensor = pylontech_ns.class_("PylontechSensor") @@ -92,7 +92,6 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) for marker in TYPES: @@ -100,4 +99,4 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_sensor")(sens)) - cg.add(paren.register_listener(bat)) + await register_listener(config, bat) diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index dc236f47a55..bead0387bef 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -6,12 +6,10 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech.sensor"); -PylontechSensor::PylontechSensor(int8_t bat_num) { this->bat_num_ = bat_num; } - void PylontechSensor::dump_config() { ESP_LOGCONFIG(TAG, "Pylontech Sensor:\n" - " Battery %d", + " Battery %u", this->bat_num_); LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); LOG_SENSOR(" ", "Current", this->current_sensor_); @@ -25,9 +23,6 @@ void PylontechSensor::dump_config() { } void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { - if (this->bat_num_ != line->bat_num) { - return; - } if (this->voltage_sensor_ != nullptr) { this->voltage_sensor_->publish_state(((float) line->volt) / 1000.0f); } diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 1403d3445dd..6b466b74664 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -7,7 +7,7 @@ namespace esphome::pylontech { class PylontechSensor final : public PylontechListener { public: - PylontechSensor(int8_t bat_num); + explicit PylontechSensor(uint8_t bat_num) : PylontechListener(bat_num) {} void dump_config() override; SUB_SENSOR(voltage) @@ -22,9 +22,6 @@ class PylontechSensor final : public PylontechListener { SUB_SENSOR(mos_temperature) void on_line_read(LineContents *line) override; - - protected: - int8_t bat_num_; }; } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/text_sensor/__init__.py b/esphome/components/pylontech/text_sensor/__init__.py index 511eb7d542e..cb3e04a91d8 100644 --- a/esphome/components/pylontech/text_sensor/__init__.py +++ b/esphome/components/pylontech/text_sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType -from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns +from .. import CONF_BATTERY, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns, register_listener PylontechTextSensor = pylontech_ns.class_("PylontechTextSensor") @@ -26,7 +26,6 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) for marker in MARKERS: @@ -34,4 +33,4 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_text_sensor")(var)) - cg.add(paren.register_listener(bat)) + await register_listener(config, bat) diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp index 1beb04064ec..07b8e36df03 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp @@ -6,12 +6,10 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech.textsensor"); -PylontechTextSensor::PylontechTextSensor(int8_t bat_num) { this->bat_num_ = bat_num; } - void PylontechTextSensor::dump_config() { ESP_LOGCONFIG(TAG, "Pylontech Text Sensor:\n" - " Battery %d", + " Battery %u", this->bat_num_); LOG_TEXT_SENSOR(" ", "Base state", this->base_state_text_sensor_); LOG_TEXT_SENSOR(" ", "Voltage state", this->voltage_state_text_sensor_); @@ -20,9 +18,6 @@ void PylontechTextSensor::dump_config() { } void PylontechTextSensor::on_line_read(PylontechListener::LineContents *line) { - if (this->bat_num_ != line->bat_num) { - return; - } if (this->base_state_text_sensor_ != nullptr) { this->base_state_text_sensor_->publish_state(line->base_st); } diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h index 3ba4f1fd4e7..6f229893c0c 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h @@ -7,7 +7,7 @@ namespace esphome::pylontech { class PylontechTextSensor final : public PylontechListener { public: - PylontechTextSensor(int8_t bat_num); + explicit PylontechTextSensor(uint8_t bat_num) : PylontechListener(bat_num) {} void dump_config() override; SUB_TEXT_SENSOR(base_state) @@ -16,9 +16,6 @@ class PylontechTextSensor final : public PylontechListener { SUB_TEXT_SENSOR(temperature_state) void on_line_read(LineContents *line) override; - - protected: - int8_t bat_num_; }; } // namespace esphome::pylontech diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3555b3cd1c2..8d17dc3f514 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -178,6 +178,8 @@ #define PN532_ON_TAG_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_TRIGGER_COUNT 1 +#define PYLONTECH_BATTERY_COUNT 1 +#define PYLONTECH_LISTENER_COUNT 2 #define REMOTE_BASE_DUMPER_COUNT 1 #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME diff --git a/tests/components/pylontech/__init__.py b/tests/components/pylontech/__init__.py new file mode 100644 index 00000000000..c16210128f6 --- /dev/null +++ b/tests/components/pylontech/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # Storage is sized by code generation; the gtests register three batteries. + cg.add_define("PYLONTECH_LISTENER_COUNT", 3) + cg.add_define("PYLONTECH_BATTERY_COUNT", 3) + cg.add_define("USE_LINE_READER") + + # A MULTI_CONF component gets no entry in the host build, so its to_code would never run. + manifest.multi_conf = False + manifest.to_code = to_code_testing diff --git a/tests/components/pylontech/pylontech_test.cpp b/tests/components/pylontech/pylontech_test.cpp new file mode 100644 index 00000000000..479530571c0 --- /dev/null +++ b/tests/components/pylontech/pylontech_test.cpp @@ -0,0 +1,252 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/pylontech/pylontech.h" +#include "esphome/components/uart/uart_component.h" + +namespace esphome::pylontech::testing { + +namespace { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void push_rx(std::initializer_list lines) { + for (const std::string &line : lines) { + this->rx.insert(this->rx.end(), line.begin(), line.end()); + } + } + + void write_array(const uint8_t *data, size_t len) override {} + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +// Every record any listener gets, in publish order +std::vector published; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +class TestListener final : public PylontechListener { + public: + explicit TestListener(uint8_t bat_num) : PylontechListener(bat_num) {} + void on_line_read(LineContents *line) override { + EXPECT_EQ(line->bat_num, this->bat_num_); + published.push_back(*line); + } + void dump_config() override {} +}; + +// clang-format off +constexpr const char *HEADER = + "Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time" + " B.V.St B.T.St MosTempr M.T.St\r\n"; +constexpr const char *BAT1 = + "1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% " + "2021-06-30 20:49:45 Normal Normal 22700 Normal\r\n"; +constexpr const char *BAT2 = + "2 46012 -1255 9100 5300 5500 3047 3091 SysError Low Normal Normal 4% " + "2025-11-28 17:56:33 Low Normal 7800 Normal\r\n"; +constexpr const char *NEW_HEADER = + "Power Volt Curr Tempr Tlow Tlow.Id Thigh Thigh.Id Vlow Vlow.Id Vhigh Vhigh.Id Base.St Volt.St Curr.St Temp.St " + "Coulomb Time B.V.St B.T.St MosTempr M.T.St SysAlarm.St\r\n"; +constexpr const char *NEW_BAT1 = + "1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal " + "60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n"; +// clang-format on + +// An older firmware line for battery bat with pack voltage volt (mV) +std::string bat_line(int bat, int volt) { + char buf[192]; + snprintf(buf, sizeof(buf), + "%d %d 0 17000 16000 18000 3200 3300 Idle Normal Normal Normal 50%% " + "2025-11-28 17:56:33 Normal Normal 16000 Normal\r\n", + bat, volt); + return buf; +} + +class PylontechTest : public ::testing::Test { + protected: + void SetUp() override { + published.clear(); + this->hub_.set_uart_parent(&this->uart_); + for (TestListener &listener : this->listeners_) { + this->hub_.register_listener(&listener); + } + } + + std::vector &published_() { return published; } + + MockUARTComponent uart_; + PylontechComponent hub_; + TestListener listeners_[3]{TestListener(1), TestListener(2), TestListener(3)}; +}; + +} // namespace + +TEST_F(PylontechTest, BurstIsPublishedAfterTheDrainOnePerLoop) { + this->uart_.push_rx({HEADER, BAT1, BAT2}); + this->hub_.loop(); + EXPECT_TRUE(this->uart_.rx.empty()); + EXPECT_TRUE(this->published_().empty()); + + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 2u); + this->hub_.loop(); + EXPECT_EQ(this->published_().size(), 2u); + + const auto &b1 = this->published_()[0]; + EXPECT_EQ(b1.bat_num, 1); + EXPECT_EQ(b1.volt, 50548); + EXPECT_EQ(b1.curr, 8910); + EXPECT_EQ(b1.tempr, 25000); + EXPECT_EQ(b1.tlow, 24200); + EXPECT_EQ(b1.thigh, 25000); + EXPECT_EQ(b1.vlow, 3368); + EXPECT_EQ(b1.vhigh, 3371); + EXPECT_STREQ(b1.base_st, "Charge"); + EXPECT_STREQ(b1.volt_st, "Normal"); + EXPECT_EQ(b1.coulomb, 97); + EXPECT_EQ(b1.mostempr, 22700); + + const auto &b2 = this->published_()[1]; + EXPECT_EQ(b2.bat_num, 2); + EXPECT_EQ(b2.curr, -1255); + EXPECT_STREQ(b2.base_st, "SysError"); + EXPECT_STREQ(b2.volt_st, "Low"); + EXPECT_EQ(b2.coulomb, 4); + EXPECT_EQ(b2.mostempr, 7800); +} + +TEST_F(PylontechTest, NothingIsPublishedWhileBytesKeepArriving) { + this->uart_.push_rx({HEADER, BAT1}); + this->hub_.loop(); + this->uart_.push_rx({BAT2}); + this->hub_.loop(); + EXPECT_TRUE(this->published_().empty()); + this->hub_.loop(); + this->hub_.loop(); + EXPECT_EQ(this->published_().size(), 2u); +} + +TEST_F(PylontechTest, NewerFirmwareSkipsIdColumns) { + this->uart_.push_rx({NEW_HEADER, NEW_BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + const auto &b1 = this->published_()[0]; + EXPECT_EQ(b1.volt, 49405); + EXPECT_EQ(b1.tlow, 13700); + EXPECT_EQ(b1.thigh, 14500); + EXPECT_EQ(b1.vlow, 3293); + EXPECT_EQ(b1.vhigh, 3294); + EXPECT_STREQ(b1.base_st, "Idle"); + EXPECT_EQ(b1.coulomb, 60); + EXPECT_EQ(b1.mostempr, 16600); +} + +TEST_F(PylontechTest, OverlongHeaderStillSetsTheColumnLayout) { + // Past the 199 character line limit: the line is dropped but its start still selects the layout + const std::string pad(40, ' '); + std::string new_header(NEW_HEADER); + new_header.insert(new_header.size() - 2, pad + "Extra.St"); + ASSERT_GT(new_header.size(), 201u); + this->uart_.push_rx({new_header, NEW_BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].thigh, 14500); + EXPECT_EQ(this->published_()[0].mostempr, 16600); + + // An overlong older header switches back + std::string old_header(HEADER); + old_header.insert(old_header.size() - 2, pad + pad + "Extra.St"); + ASSERT_GT(old_header.size(), 201u); + this->uart_.push_rx({old_header, BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[1].thigh, 25000); + EXPECT_EQ(this->published_()[1].mostempr, 22700); +} + +TEST_F(PylontechTest, BatteryWithoutListenerIsIgnored) { + this->uart_.push_rx( + {HEADER, bat_line(5, 49000), bat_line(17, 49000), bat_line(0, 49000), bat_line(INT32_MIN, 49000), BAT1, BAT2}); + for (int i = 0; i < 4; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + EXPECT_EQ(this->published_()[1].bat_num, 2); +} + +TEST_F(PylontechTest, OutOfRangeValueRejectsTheLine) { + this->uart_.push_rx({HEADER, bat_line(1, -1), bat_line(1, 70000), BAT2}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +TEST_F(PylontechTest, InvalidAndUnknownLinesAreSkipped) { + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, "1 abc\r\n", BAT2, "$$\r\n"}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +TEST_F(PylontechTest, RepeatedBatteryLineOverwrites) { + this->uart_.push_rx({HEADER, bat_line(3, 49000), BAT1, bat_line(3, 49500)}); + for (int i = 0; i < 4; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + EXPECT_EQ(this->published_()[1].bat_num, 3); + EXPECT_EQ(this->published_()[1].volt, 49500); + + // The next reply fills the slot again + this->uart_.push_rx({HEADER, bat_line(3, 48000)}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 3u); + EXPECT_EQ(this->published_()[2].volt, 48000); +} + +TEST_F(PylontechTest, BadLineClearsTheBatteryPendingRecord) { + this->uart_.push_rx({HEADER, BAT1, "1 abc\r\n", BAT2}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +} // namespace esphome::pylontech::testing diff --git a/tests/integration/fixtures/uart_mock_pylontech.yaml b/tests/integration/fixtures/uart_mock_pylontech.yaml new file mode 100644 index 00000000000..fcf87cc8246 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_pylontech.yaml @@ -0,0 +1,105 @@ +esphome: + name: uart-mock-pylontech-test + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy pylontech's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + rx_buffer_size: 1024 + auto_start: false + +pylontech: + id: pylontech0 + uart_id: mock_uart + +sensor: + - platform: pylontech + pylontech_id: pylontech0 + battery: 1 + voltage: + name: "Bat1 Voltage" + current: + name: "Bat1 Current" + temperature: + name: "Bat1 Temperature" + temperature_low: + name: "Bat1 Temperature Low" + temperature_high: + name: "Bat1 Temperature High" + voltage_low: + name: "Bat1 Voltage Low" + voltage_high: + name: "Bat1 Voltage High" + coulomb: + name: "Bat1 Coulomb" + mos_temperature: + name: "Bat1 MOS Temperature" + - platform: pylontech + pylontech_id: pylontech0 + battery: 2 + voltage: + name: "Bat2 Voltage" + current: + name: "Bat2 Current" + temperature_high: + name: "Bat2 Temperature High" + coulomb: + name: "Bat2 Coulomb" + +text_sensor: + - platform: pylontech + pylontech_id: pylontech0 + battery: 1 + base_state: + name: "Bat1 Base State" + - platform: pylontech + pylontech_id: pylontech0 + battery: 2 + base_state: + name: "Bat2 Base State" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + # Phase 1: older firmware header and two battery lines + - uart_mock.inject_rx: + id: mock_uart + data: "pwr\r\n@\r\nPower Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St\r\n1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% 2021-06-30 20:49:45 Normal Normal 22700 Normal\r\n2 46012 -1255 9100 5300 5500 3047 3091 SysError Low Normal Normal 4% 2025-11-28 17:56:33 Low Normal 7800 Normal\r\n$$\r\n" + - delay: 300ms + # Phase 2: garbage bytes, ending a line + - uart_mock.inject_rx: + id: mock_uart + data: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF, 0x0D, 0x0A] + - delay: 100ms + # Phase 3: truncated battery line; must not publish + - uart_mock.inject_rx: + id: mock_uart + data: "1 49000 0 17000\r\n" + - delay: 100ms + # Phase 4: line over the 199 character limit; dropped with a warning + - uart_mock.inject_rx: + id: mock_uart + data: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\r\n" + - delay: 100ms + # Phase 5 (recovery): newer firmware header, also over the limit, then its data lines + - uart_mock.inject_rx: + id: mock_uart + data: "Power Volt Curr Tempr Tlow Tlow.Id Thigh Thigh.Id Vlow Vlow.Id Vhigh Vhigh.Id Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St SysAlarm.St Extra.St\r\n1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal 60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n2 48100 -2500 16900 13200 3 14100 1 3201 4 3215 9 Dischg Normal Normal Normal 42% 2025-12-05 00:53:41 Normal Normal 15800 Normal Normal\r\n$$\r\n" diff --git a/tests/integration/test_uart_mock_pylontech.py b/tests/integration/test_uart_mock_pylontech.py new file mode 100644 index 00000000000..fffaa902072 --- /dev/null +++ b/tests/integration/test_uart_mock_pylontech.py @@ -0,0 +1,136 @@ +"""Integration test for the pylontech component with mock UART. + +Phases, all injected after the Start Scenario button press: + 1. Older firmware header and two battery lines publish exact values + 2. Garbage bytes are skipped + 3. A truncated battery line publishes nothing and is counted in a warning + 4. A line over the 199 character limit is dropped with a warning + 5. Recovery: a newer firmware header, itself over the limit, still selects the + layout with the extra Id columns, so its battery lines parse correctly +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, find_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +SENSORS = [ + "bat1_voltage", + "bat1_current", + "bat1_temperature", + "bat1_temperature_low", + "bat1_temperature_high", + "bat1_voltage_low", + "bat1_voltage_high", + "bat1_coulomb", + "bat1_mos_temperature", + "bat2_voltage", + "bat2_current", + "bat2_temperature_high", + "bat2_coulomb", +] +TEXT_SENSORS = ["bat1_base_state", "bat2_base_state"] + +# Values per entity: phase 1 reply, then the phase 5 recovery reply +EXPECTED = { + "bat1_voltage": [50.548, 49.405], + "bat1_current": [8.91, 0.0], + "bat1_temperature": [25.0, 17.6], + "bat1_temperature_low": [24.2, 13.7], + "bat1_temperature_high": [25.0, 14.5], + "bat1_voltage_low": [3.368, 3.293], + "bat1_voltage_high": [3.371, 3.294], + "bat1_coulomb": [97.0, 60.0], + "bat1_mos_temperature": [22.7, 16.6], + "bat2_voltage": [46.012, 48.1], + "bat2_current": [-1.255, -2.5], + "bat2_temperature_high": [5.5, 14.1], + "bat2_coulomb": [4.0, 42.0], +} +EXPECTED_TEXT = { + "bat1_base_state": ["Charge", "Idle"], + "bat2_base_state": ["SysError", "Dischg"], +} + + +@pytest.mark.asyncio +async def test_uart_mock_pylontech( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test pylontech parsing with happy path, garbage, overflow and recovery.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + overflow_lines: list[str] = [] + rejected_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "Line over 199 chars dropped" in line: + overflow_lines.append(line) + if "malformed battery lines" in line: + rejected_lines.append(line) + + collector = SensorStateCollector( + sensor_names=SENSORS, text_sensor_names=TEXT_SENSORS + ) + recovery_received = collector.add_waiter( + lambda: ( + all(len(collector.sensor_states[name]) >= 2 for name in SENSORS) + and all( + len(collector.text_sensor_states[name]) >= 2 for name in TEXT_SENSORS + ) + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(collector.on_state) + ) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + + try: + await asyncio.wait_for(recovery_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the recovery reply. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" text_states: {collector.text_sensor_states}" + ) + # Let any stray publish from the bad phases arrive before the exact checks + await asyncio.sleep(0.2) + + for name, values in EXPECTED.items(): + assert collector.sensor_states[name] == pytest.approx(values), name + for name, values in EXPECTED_TEXT.items(): + assert collector.text_sensor_states[name] == values, name + + # Phase 4 and the phase 5 header both overflow + assert len(overflow_lines) == 2, overflow_lines + # The phase 3 cut line is counted and reported once the UART is idle + assert rejected_lines, "no rejected-line summary logged" From 052a6096e65cb520a299f8d6de253342e30bb7d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:46:16 -1000 Subject: [PATCH 03/50] [components] Log hex dumps with the ESP_LOG*_HEX macros (#20932) --- .../haier_water_heater/hon_water_heater.cpp | 12 +++------ esphome/components/hdmi_cec/hdmi_cec.cpp | 5 +--- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 7 ++---- esphome/components/i2c/i2c_bus_arduino.cpp | 5 +--- esphome/components/i2c/i2c_bus_esp_idf.cpp | 5 +--- .../improv_ble/improv_ble_component.cpp | 7 ++---- esphome/components/ld2410/ld2410.cpp | 10 ++------ esphome/components/ld2412/ld2412.cpp | 10 ++------ esphome/components/ld2450/ld2450.cpp | 10 ++------ esphome/components/ld2460/ld2460.cpp | 5 +--- .../midea_portasplit/midea_portasplit.cpp | 10 ++------ esphome/components/mitsubishi/mitsubishi.cpp | 5 +--- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 5 +--- esphome/components/modbus/modbus.cpp | 6 +---- .../output/modbus_output.cpp | 6 +---- .../switch/modbus_switch.cpp | 6 +---- .../mopeka_std_check/mopeka_std_check.cpp | 7 ++---- esphome/components/pn532_spi/pn532_spi.cpp | 10 ++------ .../seeed_mr60bha2/seeed_mr60bha2.cpp | 10 ++------ .../seeed_mr60fda2/seeed_mr60fda2.cpp | 25 ++++--------------- esphome/components/sonoff_d1/sonoff_d1.cpp | 15 +++-------- .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 10 ++------ esphome/components/xiaomi_ble/xiaomi_ble.cpp | 5 +--- .../components/zwave_proxy/zwave_proxy.cpp | 10 ++------ 24 files changed, 43 insertions(+), 163 deletions(-) diff --git a/esphome/components/haier_water_heater/hon_water_heater.cpp b/esphome/components/haier_water_heater/hon_water_heater.cpp index 3f190de5b83..b2c35c4cca4 100644 --- a/esphome/components/haier_water_heater/hon_water_heater.cpp +++ b/esphome/components/haier_water_heater/hon_water_heater.cpp @@ -46,10 +46,7 @@ void HonWaterHeater::send_frame_(FrameType type, const uint8_t *payload, size_t const size_t length = encode_frame(type, payload, size, wire); if (length == 0) return; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex[format_hex_pretty_size(sizeof(wire))]; - ESP_LOGV(TAG, "TX type=%02X: %s", static_cast(type), format_hex_pretty_to(hex, wire, length)); -#endif + ESP_LOGV_HEX(TAG, wire, length, sizeof(wire), "TX type=%02X: %s", static_cast(type)); this->write_array(wire, length); } @@ -221,11 +218,8 @@ void HonWaterHeater::confirm_control_(const Status &status) { void HonWaterHeater::process_frame_() { const uint8_t *frame = this->parser_.data(); const auto type = static_cast(frame[FRAME_TYPE_OFFSET]); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex[format_hex_pretty_size(MAX_STORED_FRAME)]; - ESP_LOGV(TAG, "RX decoded type=%02X flags=%02X: %s", static_cast(type), frame[FRAME_FLAGS_OFFSET], - format_hex_pretty_to(hex, frame, this->parser_.stored_size())); -#endif + ESP_LOGV_HEX(TAG, frame, this->parser_.stored_size(), MAX_STORED_FRAME, "RX decoded type=%02X flags=%02X: %s", + static_cast(type), frame[FRAME_FLAGS_OFFSET]); this->handle_frame_type_(type); Status status{}; diff --git a/esphome/components/hdmi_cec/hdmi_cec.cpp b/esphome/components/hdmi_cec/hdmi_cec.cpp index 82e354dbae7..e0e9abde7b1 100644 --- a/esphome/components/hdmi_cec/hdmi_cec.cpp +++ b/esphome/components/hdmi_cec/hdmi_cec.cpp @@ -75,10 +75,7 @@ void HDMICEC::loop() { } // One inbound frame per pass keeps the time spent in the automations bounded if (const Frame *frame = this->frames_queue_.front()) { -#ifdef ESPHOME_LOG_HAS_DEBUG - char line[format_hex_pretty_size(Frame::MAX_LENGTH)]; - ESP_LOGD(TAG, "Received: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size())); -#endif + ESP_LOGD_HEX(TAG, frame->bytes().data(), frame->size(), Frame::MAX_LENGTH, "Received: %s"); #ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT this->message_callbacks_.call(frame->initiator_addr(), frame->destination_addr(), frame->payload()); #endif diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index e4845daa57b..8674a1de97c 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -161,11 +161,8 @@ void HlkFm22xComponent::recv_command_() { } } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(HLK_FM22X_MAX_RESPONSE_SIZE)]; - ESP_LOGV(TAG, "Recv type: 0x%.2X, data: %s", response_type, - format_hex_pretty_to(hex_buf, this->recv_buf_.data(), to_store)); -#endif + ESP_LOGV_HEX(TAG, this->recv_buf_.data(), to_store, HLK_FM22X_MAX_RESPONSE_SIZE, "Recv type: 0x%.2X, data: %s", + response_type); byte = this->read(); if (byte != checksum) { diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 215cc1364f8..799004b1c0b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -111,10 +111,7 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe return ERROR_NOT_INITIALIZED; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(I2C_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty_to(hex_buf, write_buffer, write_count)); -#endif + ESP_LOGV_HEX(TAG, write_buffer, write_count, I2C_MAX_LOG_BYTES, "0x%02X TX %s", address); uint8_t status = 0; if (write_count != 0 || read_count == 0) { diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 931bcfa0889..2b1b83ef6f5 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -167,10 +167,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s jobs[num_jobs++].write.total_bytes = 1; } else { if (write_count != 0) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(I2C_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty_to(hex_buf, write_buffer, write_count)); -#endif + ESP_LOGV_HEX(TAG, write_buffer, write_count, I2C_MAX_LOG_BYTES, "0x%02X TX %s", address); jobs[num_jobs++].command = I2C_MASTER_CMD_START; jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; jobs[num_jobs].write.ack_check = true; diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 6e11975b6c3..0d441ee62dc 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -354,11 +354,8 @@ void ImprovBLEComponent::process_incoming_data_() { return; uint8_t length = this->incoming_data_[1]; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(IMPROV_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "Processing bytes - %s", - format_hex_pretty_to(hex_buf, this->incoming_data_.data(), this->incoming_data_.size())); -#endif + ESP_LOGV_HEX(TAG, this->incoming_data_.data(), this->incoming_data_.size(), IMPROV_MAX_LOG_BYTES, + "Processing bytes - %s"); if (this->incoming_data_.size() - 3 == length) { this->set_error_(improv::ERROR_NONE); improv::ImprovCommand command = improv::parse_improv_data(this->incoming_data_); diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 22bd20d1e5a..7461a2ade40 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -607,17 +607,11 @@ void LD2410Component::readline_(int readch) { return; // Not enough data to process yet } if (ld2410::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next message } else if (ld2410::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index c6137f29f7c..704e3845e73 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -702,17 +702,11 @@ void LD2412Component::readline_(int readch) { return; // Not enough data to process yet } if (ld2412::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next message } else if (ld2412::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index cdf542ee8b8..52327d309e3 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -829,17 +829,11 @@ void LD2450Component::readline_(int readch) { } if (this->buffer_data_[this->buffer_pos_ - 2] == DATA_FRAME_FOOTER[0] && this->buffer_data_[this->buffer_pos_ - 1] == DATA_FRAME_FOOTER[1]) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next frame } else if (ld2450::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp index 2efd34a1d8c..abeb6779cf4 100644 --- a/esphome/components/ld2460/ld2460.cpp +++ b/esphome/components/ld2460/ld2460.cpp @@ -168,10 +168,7 @@ void LD2460Component::readline_(uint8_t readch) { return; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Frame: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Frame: %s"); if (is_data) { this->handle_periodic_data_(); diff --git a/esphome/components/midea_portasplit/midea_portasplit.cpp b/esphome/components/midea_portasplit/midea_portasplit.cpp index a1f22226d9c..309e1f880f4 100644 --- a/esphome/components/midea_portasplit/midea_portasplit.cpp +++ b/esphome/components/midea_portasplit/midea_portasplit.cpp @@ -202,10 +202,7 @@ void PortaSplitClimate::send_frame_(uint8_t proto, uint8_t type, const uint8_t * f[10 + len] = crc8(&f[10], (uint8_t) len); f[11 + len] = checksum(f, 11 + len); this->write_array(f, total); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(sizeof(f))]; - ESP_LOGV(TAG, "TX: %s", format_hex_pretty_to(hex_buf, f, total)); -#endif + ESP_LOGV_HEX(TAG, f, total, sizeof(f), "TX: %s"); } void PortaSplitClimate::send_set_command_() { @@ -276,10 +273,7 @@ void PortaSplitClimate::rx_feed_(uint8_t b) { ESP_LOGW(TAG, "RX checksum mismatch, dropping frame"); return; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_FRAME)]; - ESP_LOGV(TAG, "RX: %s", format_hex_pretty_to(hex_buf, this->rx_.data(), total)); -#endif + ESP_LOGV_HEX(TAG, this->rx_.data(), total, MAX_FRAME, "RX: %s"); this->handle_frame_(this->rx_.data(), total); } diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 9fba222d635..19b8bb3fad3 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -387,10 +387,7 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { break; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MITSUBISHI_FRAME_SIZE)]; -#endif - ESP_LOGV(TAG, "Receiving: %s", format_hex_pretty_to(hex_buf, state_frame, MITSUBISHI_FRAME_SIZE)); + ESP_LOGV_HEX(TAG, state_frame, MITSUBISHI_FRAME_SIZE, MITSUBISHI_FRAME_SIZE, "Receiving: %s"); this->publish_state(); return true; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 3261d9d5694..75e8d547c05 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -458,10 +458,7 @@ void MitsubishiCN105::FrameParser::reset_and_dump_buffer_(const char *prefix) { } void MitsubishiCN105::FrameParser::dump_buffer_vv(const char *prefix, const uint8_t *data, size_t len) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char buf[format_hex_pretty_size(READ_BUFFER_SIZE)]; - ESP_LOGVV(TAG, "%s (%zu): %s", prefix, len, format_hex_pretty_to(buf, data, len)); -#endif + ESP_LOGVV_HEX(TAG, data, len, READ_BUFFER_SIZE, "%s (%zu): %s", prefix, len); } } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index a79a7c3bd8c..d135ae58e63 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -1161,11 +1161,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); return false; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, - format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); + ESP_LOGV_HEX(TAG, pdu.data(), pdu.size(), MODBUS_MAX_LOG_BYTES, "Adding frame to tx queue: %" PRIu8 ":%s", address); this->tx_buffer_.emplace_back(device, address, pdu, options, this->next_seq_++); return true; } diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index 9fb40f90053..f49429b1ece 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -100,11 +100,7 @@ void ModbusBinaryOutput::write_state(bool state) { } if (!data.empty()) { this->warn_write_buffer_deprecated_(LOG_STR("binary output"), this->start_address); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Modbus binary output write raw: %s", - format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), MODBUS_OUTPUT_MAX_LOG_BYTES, "Modbus binary output write raw: %s"); // The lambda filled a legacy raw frame (device address + function code + data). if (!this->send_raw_frame_deprecated_(data)) { ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address()); diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index fe2b2762313..c29378c5436 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -70,11 +70,7 @@ void ModbusSwitch::write_state(bool state) { } if (!data.empty()) { this->warn_write_buffer_deprecated_(LOG_STR("switch"), this->start_address); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Modbus Switch write raw: %s", - format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), MODBUS_SWITCH_MAX_LOG_BYTES, "Modbus Switch write raw: %s"); // The lambda filled a legacy raw frame (device address + function code + data). if (!this->send_raw_frame_deprecated_(data)) { ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index f63f2bc190b..831c264d24e 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -64,11 +64,8 @@ bool MopekaStdCheck::parse_device(const ble_device_base::ESPBTDevice &device) { const auto &manu_data = manu_datas[0]; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MOPEKA_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "[%s] Manufacturer data: %s", addr_str, - format_hex_pretty_to(hex_buf, manu_data.data.data(), manu_data.data.size())); + ESP_LOGVV_HEX(TAG, manu_data.data.data(), manu_data.data.size(), MOPEKA_MAX_LOG_BYTES, "[%s] Manufacturer data: %s", + addr_str); if (manu_data.data.size() != MANUFACTURER_DATA_LENGTH) { ESP_LOGE(TAG, "[%s] Unexpected manu_data size (%d)", addr_str, manu_data.data.size()); diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 34c6a3e44f7..eb0ce241f39 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -38,10 +38,7 @@ bool PN532Spi::write_data(const std::span data) { delay(2); // First byte, communication mode: Write data this->write_byte(0x01); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Writing data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), PN532_MAX_LOG_BYTES, "Writing data: %s"); this->write_array(data.data(), data.size()); this->disable(); @@ -65,10 +62,7 @@ bool PN532Spi::read_data(pn532::PN532Frame &data, size_t len) { data[0] = 0x01; this->read_array(data.data() + 1, len); this->disable(); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Read data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), PN532_MAX_LOG_BYTES, "Read data: %s"); return true; } diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index e86909d0491..eec4e7afa6b 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -119,10 +119,7 @@ bool MR60BHA2Component::validate_message_() { if (at == 7) { if (!validate_checksum(data, 7, header_checksum)) { ESP_LOGE(TAG, "HEAD_CKSUM_FRAME ERROR: 0x%02x", header_checksum); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MR60BHA2_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "GET FRAME: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data, 8)); + ESP_LOGV_HEX(TAG, data, 8, MR60BHA2_MAX_LOG_BYTES, "GET FRAME: %s"); return false; } return true; @@ -137,10 +134,7 @@ bool MR60BHA2Component::validate_message_() { if (at == 8 + length) { if (!validate_checksum(data + 8, length, data_checksum)) { ESP_LOGE(TAG, "DATA_CKSUM_FRAME ERROR: 0x%02x", data_checksum); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MR60BHA2_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "GET FRAME: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data, 8 + length)); + ESP_LOGV_HEX(TAG, data, 8 + length, MR60BHA2_MAX_LOG_BYTES, "GET FRAME: %s"); return false; } } diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index d0e4c635eb2..5a0143071c6 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -329,10 +329,7 @@ void MR60FDA2Component::set_install_height(uint8_t index) { float_to_bytes(INSTALL_HEIGHT[index], &send_data[8]); send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND INSTALL HEIGHT FRAME: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND INSTALL HEIGHT FRAME: %s"); } void MR60FDA2Component::set_height_threshold(uint8_t index) { @@ -342,10 +339,7 @@ void MR60FDA2Component::set_height_threshold(uint8_t index) { float_to_bytes(HEIGHT_THRESHOLD[index], &send_data[8]); send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND HEIGHT THRESHOLD: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND HEIGHT THRESHOLD: %s"); } void MR60FDA2Component::set_sensitivity(uint8_t index) { @@ -357,28 +351,19 @@ void MR60FDA2Component::set_sensitivity(uint8_t index) { send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND SET SENSITIVITY: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND SET SENSITIVITY: %s"); } void MR60FDA2Component::get_radar_parameters() { uint8_t send_data[8] = {0x01, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x06, 0xF6}; this->write_array(send_data, 8); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(8)]; -#endif - ESP_LOGV(TAG, "SEND GET PARAMETERS: %s", format_hex_pretty_to(hex_buf, send_data, 8)); + ESP_LOGV_HEX(TAG, send_data, 8, 8, "SEND GET PARAMETERS: %s"); } void MR60FDA2Component::factory_reset() { uint8_t send_data[8] = {0x01, 0x00, 0x00, 0x00, 0x00, 0x21, 0x10, 0xCF}; this->write_array(send_data, 8); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(8)]; -#endif - ESP_LOGV(TAG, "SEND RESET: %s", format_hex_pretty_to(hex_buf, send_data, 8)); + ESP_LOGV_HEX(TAG, send_data, 8, 8, "SEND RESET: %s"); this->get_radar_parameters(); } diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index c72210e932f..ca3b4267e84 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -84,10 +84,7 @@ bool SonoffD1Output::read_command_(uint8_t *cmd, size_t &len) { // Read a minimal packet if (this->read_array(cmd, 6)) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(6)]; - ESP_LOGV(TAG, "[%04d] Reading from dimmer: %s", this->write_count_, format_hex_pretty_to(hex_buf, cmd, 6)); -#endif + ESP_LOGV_HEX(TAG, cmd, 6, 6, "[%04d] Reading from dimmer: %s", this->write_count_); if (cmd[0] != 0xAA || cmd[1] != 0x55) { ESP_LOGW(TAG, "[%04d] RX: wrong header (%x%x, must be AA55)", this->write_count_, cmd[0], cmd[1]); @@ -101,10 +98,7 @@ bool SonoffD1Output::read_command_(uint8_t *cmd, size_t &len) { return false; } if (this->read_array(&cmd[6], cmd[5] + 1 /*checksum suffix*/)) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf2[format_hex_pretty_size(SONOFF_D1_MAX_CMD_SIZE)]; - ESP_LOGV(TAG, "[%04d] %s", this->write_count_, format_hex_pretty_to(hex_buf2, &cmd[6], cmd[5] + 1)); -#endif + ESP_LOGV_HEX(TAG, &cmd[6], cmd[5] + 1, SONOFF_D1_MAX_CMD_SIZE, "[%04d] %s", this->write_count_); // Check the checksum uint8_t valid_checksum = this->calc_checksum_(cmd, cmd[5] + 7); @@ -178,10 +172,7 @@ bool SonoffD1Output::write_command_(uint8_t *cmd, const size_t len, bool needs_a // 2. UART command initiated by this component can clash with a command initiated by RF uint32_t retries = 10; do { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(SONOFF_D1_MAX_CMD_SIZE)]; - ESP_LOGV(TAG, "[%04d] Writing to the dimmer: %s", this->write_count_, format_hex_pretty_to(hex_buf, cmd, len)); -#endif + ESP_LOGV_HEX(TAG, cmd, len, SONOFF_D1_MAX_CMD_SIZE, "[%04d] Writing to the dimmer: %s", this->write_count_); this->write_array(cmd, len); this->write_count_++; if (!needs_ack) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 0f02ddf3c7a..ebf44e41438 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -50,10 +50,7 @@ static void tinyusb_cdc_rx_callback(int itf, cdcacm_event_t *event) { esp_err_t ret = tinyusb_cdcacm_read(static_cast(itf), rx_buf, CONFIG_TINYUSB_CDC_RX_BUFSIZE, &rx_size); ESP_LOGV(TAG, "tinyusb_cdc_rx_callback itf=%d (size: %u)", itf, rx_size); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char rx_hex_buf[format_hex_pretty_size(USB_CDC_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "rx_buf = %s", format_hex_pretty_to(rx_hex_buf, rx_buf, rx_size)); + ESP_LOGVV_HEX(TAG, rx_buf, rx_size, USB_CDC_MAX_LOG_BYTES, "rx_buf = %s"); if (ret == ESP_OK && rx_size > 0) { RingbufHandle_t rx_ringbuf = instance->get_rx_ringbuf(); @@ -194,10 +191,7 @@ void USBCDCACMInstance::usb_tx_task() { } ESP_LOGV(TAG, "USB TX itf=%d: Read %d bytes from buffer", this->itf_, tx_data_size); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char tx_hex_buf[format_hex_pretty_size(USB_CDC_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "data = %s", format_hex_pretty_to(tx_hex_buf, data, tx_data_size)); + ESP_LOGVV_HEX(TAG, data, tx_data_size, USB_CDC_MAX_LOG_BYTES, "data = %s"); // Serial data will be split up into 64 byte chunks to be sent over USB so this // usually will take multiple iterations diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 57f26b6acc7..90ea5c32094 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -286,10 +286,7 @@ optional parse_xiaomi_header(const ble_device_base::ServiceDa bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address) { if ((raw.size() != 19) && ((raw.size() < 22) || (raw.size() > 24))) { ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): data packet has wrong size (%d)!", raw.size()); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(XIAOMI_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " Packet : %s", format_hex_pretty_to(hex_buf, raw.data(), raw.size())); + ESP_LOGVV_HEX(TAG, raw.data(), raw.size(), XIAOMI_MAX_LOG_BYTES, " Packet : %s"); return false; } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index a7b1fa0ced0..7134e223351 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -311,10 +311,7 @@ void ZWaveProxy::send_frame_(const uint8_t *data, size_t length) { return; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(ZWAVE_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "Sending: %s", format_hex_pretty_to(hex_buf, data, length)); + ESP_LOGVV_HEX(TAG, data, length, ZWAVE_MAX_LOG_BYTES, "Sending: %s"); this->write_array(data, length); } @@ -395,10 +392,7 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; } else { this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_ACK; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(ZWAVE_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty_to(hex_buf, this->buffer_.data(), this->buffer_index_)); + ESP_LOGVV_HEX(TAG, this->buffer_.data(), this->buffer_index_, ZWAVE_MAX_LOG_BYTES, "Received frame: %s"); frame_completed = true; } this->response_handler_(); From 7c6fac91fffb63b9f7617311ded8badfc7a824f9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:49:15 -0400 Subject: [PATCH 04/50] [esp32] Bump recommended ESP-IDF to 5.5.6 (#20987) --- esphome/components/esp32/__init__.py | 9 +++++---- platformio.ini | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index aae1a4c41f6..cd602e6c64b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1183,7 +1183,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), - cv.Version(3, 3, 12): cv.Version(5, 5, 5), + cv.Version(3, 3, 12): cv.Version(5, 5, 6), cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), @@ -1207,9 +1207,9 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 5), - "latest": cv.Version(5, 5, 5), - "dev": cv.Version(5, 5, 5), + "recommended": cv.Version(5, 5, 6), + "latest": cv.Version(5, 5, 6), + "dev": cv.Version(5, 5, 6), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { @@ -1219,6 +1219,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(5, 5, 6): cv.Version(55, 3, 312), cv.Version(5, 5, 5): cv.Version(55, 3, 312), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), diff --git a/platformio.ini b/platformio.ini index fe61470a358..e5d0200ea7c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -147,7 +147,7 @@ extends = common:arduino platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -183,7 +183,7 @@ extra_scripts = extends = common:idf platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = espidf lib_deps = From bd06d37d1a0a47c10f1862092ad5e4b4d6513931 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:49:46 -1000 Subject: [PATCH 05/50] [components] Check I2C chip IDs with check_chip_id (#20943) --- esphome/components/apds9306/apds9306.cpp | 11 ++-------- esphome/components/apds9930/apds9930.cpp | 10 +--------- esphome/components/as7341/as7341.cpp | 8 +------- esphome/components/bh1745/bh1745.cpp | 9 +-------- esphome/components/bmi270/bmi270.cpp | 11 +--------- esphome/components/bq25186/bq25186.cpp | 12 +---------- .../es9038q2m_katana/es9038q2m_katana.cpp | 10 +--------- esphome/components/lis3dh/lis3dh.cpp | 10 +--------- esphome/components/mmc5603/mmc5603.cpp | 11 ++-------- esphome/components/mmc5983/mmc5983.cpp | 17 +++------------- esphome/components/mpl3115a2/mpl3115a2.cpp | 9 +-------- esphome/components/msa3xx/msa3xx.cpp | 10 +--------- esphome/components/qmi8658/qmi8658.cpp | 10 +--------- esphome/components/qwiic_pir/qwiic_pir.cpp | 11 +--------- esphome/components/stts22h/stts22h.cpp | 20 +------------------ esphome/components/stts22h/stts22h.h | 1 - esphome/components/tsl2591/tsl2591.cpp | 12 +---------- 17 files changed, 20 insertions(+), 162 deletions(-) diff --git a/esphome/components/apds9306/apds9306.cpp b/esphome/components/apds9306/apds9306.cpp index af123d8c4f3..bcfcce1f3b4 100644 --- a/esphome/components/apds9306/apds9306.cpp +++ b/esphome/components/apds9306/apds9306.cpp @@ -52,16 +52,9 @@ enum { // APDS9306 registers } void APDS9306::setup() { - uint8_t id; - if (!this->read_byte(APDS9306_PART_ID, &id)) { // Part ID register - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + // 0xB1 for APDS9306, 0xB3 for APDS9306-065; they differ only in bit 1 + if (!this->check_chip_id(this, APDS9306_PART_ID, 0xB1, 0xFD)) return; - } - - if (id != 0xB1 && id != 0xB3) { // 0xB1 for APDS9306 0xB3 for APDS9306-065 - this->mark_failed(LOG_STR("APDS9306 has invalid id")); - return; - } // ALS resolution and measurement, see datasheet or init.py for options uint8_t als_meas_rate = ((this->bit_width_ & 0x07) << 4) | (this->measurement_rate_ & 0x07); diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp index 6debc0d9c7b..a4b836beaa1 100644 --- a/esphome/components/apds9930/apds9930.cpp +++ b/esphome/components/apds9930/apds9930.cpp @@ -64,16 +64,8 @@ static constexpr uint16_t ALS_SATURATION = 1024 * (256 - APDS9930_ATIME_VALUE); bool APDS9930Component::write_reg_(uint8_t reg, uint8_t value) { return this->write_byte(reg | APDS9930_CMD, value); } void APDS9930Component::setup() { - uint8_t id; - if (!this->read_byte(APDS9930_ID | APDS9930_CMD, &id)) { - this->mark_failed(LOG_STR("Communication failed")); + if (!this->check_chip_id(this, APDS9930_ID | APDS9930_CMD, APDS9930_CHIP_ID)) return; - } - if (id != APDS9930_CHIP_ID) { - ESP_LOGE(TAG, "Wrong ID 0x%02X (expected 0x%02X)", id, APDS9930_CHIP_ID); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } const uint8_t control = (this->led_drive_ & 0b11) << CONTROL_PDRIVE_SHIFT | CONTROL_PDIODE_CH1 << CONTROL_PDIODE_SHIFT | diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index 60f576f7bb9..cf02884c5e4 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -9,14 +9,8 @@ ESPHOME_LOG_TAG(TAG, "as7341"); void AS7341Component::setup() { LOG_I2C_DEVICE(this); - // Verify device ID - uint8_t id; - this->read_byte(AS7341_ID, &id); - ESP_LOGCONFIG(TAG, " Read ID: 0x%X", id); - if ((id & 0xFC) != (AS7341_CHIP_ID << 2)) { - this->mark_failed(LOG_STR("Wrong chip ID")); + if (!this->check_chip_id(this, AS7341_ID, AS7341_CHIP_ID << 2, 0xFC)) return; - } // Power on (enter IDLE state) if (!this->enable_power(true)) { diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp index 24744c3330d..ee7b9935f4c 100644 --- a/esphome/components/bh1745/bh1745.cpp +++ b/esphome/components/bh1745/bh1745.cpp @@ -63,15 +63,8 @@ static float calculate_cct(uint16_t red, uint16_t green, uint16_t blue, bool low } void BH1745Component::setup() { - uint8_t manufacturer_id; - if (!this->read_byte(REG_MANUFACTURER_ID, &manufacturer_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, REG_MANUFACTURER_ID, MANUFACTURER_ID)) return; - } - if (manufacturer_id != MANUFACTURER_ID) { - this->mark_failed(LOG_STR("Wrong manufacturer ID")); - return; - } uint8_t system_control; if (!this->read_byte(REG_SYSTEM_CONTROL, &system_control) || (system_control & SYSTEM_CONTROL_PART_ID_MASK) != PART_ID) { diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index e3c6d83a722..0e010b03118 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -78,17 +78,8 @@ bool BMI270Component::load_config_file_() { void BMI270Component::setup() { MotionComponent::setup(); // 1. Verify chip ID - uint8_t chip_id = 0; - if (!this->read_byte(BMI270_REG_CHIP_ID, &chip_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, BMI270_REG_CHIP_ID, BMI270_CHIP_ID_VALUE)) return; - } - if (chip_id != BMI270_CHIP_ID_VALUE) { - ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", chip_id, BMI270_CHIP_ID_VALUE); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } - ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); // 2. Soft-reset via CMD register (0x7E = 0xB6) if (!this->write_byte(BMI270_REG_CMD, BMI270_CMD_SOFT_RESET)) { diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp index edea50d1115..91bbacba467 100644 --- a/esphome/components/bq25186/bq25186.cpp +++ b/esphome/components/bq25186/bq25186.cpp @@ -79,19 +79,9 @@ bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { } void BQ25186Component::setup() { - uint8_t mask_id; - if (!this->read_byte(BQ25186_REG_MASK_ID, &mask_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return; - } - // Another charger at this address could lay its registers out differently, so do not write into it - const uint8_t device_id = mask_id & 0x0F; - if (device_id != 0x01) { - ESP_LOGE(TAG, "Unexpected device id 0x%X (expected 0x1)", device_id); - this->mark_failed(LOG_STR("Not a BQ25186")); + if (!this->check_chip_id(this, BQ25186_REG_MASK_ID, 0x01, 0x0F)) return; - } // A failed write only sets the warning status: the chip has identified itself, so a later // apply_configuration may retry diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp index 464f971acef..e3cd3820d7e 100644 --- a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -69,16 +69,8 @@ const LogString *deemphasis_to_str(DeemphasisMode mode) { } // namespace void ES9038Q2MKatana::setup() { - uint8_t chip_id = 0; - if (!this->read_byte(KATANA_REG_CHIP_ID, &chip_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, KATANA_REG_CHIP_ID, KATANA_CHIP_ID)) return; - } - if (chip_id != KATANA_CHIP_ID) { - ESP_LOGE(TAG, "Unexpected chip ID 0x%02X", chip_id); - this->mark_failed(LOG_STR("Unexpected chip ID")); - return; - } if (!this->write_byte(KATANA_REG_RESET, 0x01)) { this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp index f12c00852cd..64e6da4ef95 100644 --- a/esphome/components/lis3dh/lis3dh.cpp +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -27,16 +27,8 @@ static const uint16_t RATE_HZ[] PROGMEM = {1, 10, 25, 50, 100, 200, 400, 1620, 1 void LIS3DHComponent::setup() { MotionComponent::setup(); - uint8_t who_am_i = 0; - if (!this->read_byte(LIS3DH_REG_WHO_AM_I, &who_am_i)) { - this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); + if (!this->check_chip_id(this, LIS3DH_REG_WHO_AM_I, LIS3DH_WHO_AM_I_VALUE)) return; - } - if (who_am_i != LIS3DH_WHO_AM_I_VALUE) { - ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, LIS3DH_WHO_AM_I_VALUE); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) { this->mark_failed(LOG_STR("Failed to configure sensor")); diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index fa5ba5985d7..bb7ea34cbdf 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -32,16 +32,9 @@ static const uint8_t MMC56X3_CTRL2_REG = 0x1D; static const uint8_t MMC5603_ODR_REG = 0x1A; void MMC5603Component::setup() { - uint8_t id = 0; - if (!this->read_byte(MMC56X3_PRODUCT_ID, &id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + // Some chips report 0 instead of MMC56X3_CHIP_ID (0x10), so bit 4 is ignored + if (!this->check_chip_id(this, MMC56X3_PRODUCT_ID, 0x00, static_cast(~MMC56X3_CHIP_ID))) return; - } - - if (id != 0 && id != MMC56X3_CHIP_ID) { // ID is not reported correctly by all chips, 0 on some chips - this->mark_failed(LOG_STR("The ID registers don't match - Is this really an MMC5603?")); - return; - } if (!this->write_byte(MMC56X3_CTRL1_REG, 0x80)) { // turn on set bit this->mark_failed(LOG_STR("Control 1 failed for set bit")); diff --git a/esphome/components/mmc5983/mmc5983.cpp b/esphome/components/mmc5983/mmc5983.cpp index f7840aafbe9..8a636dacb5d 100644 --- a/esphome/components/mmc5983/mmc5983.cpp +++ b/esphome/components/mmc5983/mmc5983.cpp @@ -14,6 +14,7 @@ constexpr uint8_t IC1_ADDR = 0x0a; constexpr uint8_t IC2_ADDR = 0x0b; constexpr uint8_t IC3_ADDR = 0x0c; constexpr uint8_t PRODUCT_ID_ADDR = 0x2f; +constexpr uint8_t MMC5983_PRODUCT_ID = 0x30; float convert_data_to_millitesla(uint8_t data_17_10, uint8_t data_9_2, uint8_t data_1_0) { int32_t counts = (data_17_10 << 10) | (data_9_2 << 2) | data_1_0; @@ -66,25 +67,13 @@ void MMC5983Component::update() { } void MMC5983Component::setup() { - // Verify product id. - const uint8_t mmc5983_product_id = 0x30; - uint8_t id; - i2c::ErrorCode err = this->read_register(PRODUCT_ID_ADDR, &id, 1); - if (err != i2c::ErrorCode::ERROR_OK) { - ESP_LOGE(TAG, "Reading product id failed with i2c error %d", err); - this->mark_failed(); + if (!this->check_chip_id(this, PRODUCT_ID_ADDR, MMC5983_PRODUCT_ID)) return; - } - if (id != mmc5983_product_id) { - ESP_LOGE(TAG, "Product id 0x%02x does not match expected value 0x%02x", id, mmc5983_product_id); - this->mark_failed(); - return; - } // Initialize Internal Control registers to 0. // Internal Control 0. const uint8_t zero = 0; - err = this->write_register(IC0_ADDR, &zero, 1); + i2c::ErrorCode err = this->write_register(IC0_ADDR, &zero, 1); if (err != i2c::ErrorCode::ERROR_OK) { ESP_LOGE(TAG, "Initializing Internal Control 0 failed with i2c error %d", err); this->mark_failed(); diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index 4a206b30dfe..58f346ab77a 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -8,15 +8,8 @@ namespace esphome::mpl3115a2 { ESPHOME_LOG_TAG(TAG, "mpl3115a2"); void MPL3115A2Component::setup() { - uint8_t whoami = 0xFF; - if (!this->read_byte(MPL3115A2_WHOAMI, &whoami)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, MPL3115A2_WHOAMI, 0xC4)) return; - } - if (whoami != 0xC4) { - this->mark_failed(LOG_STR("MPL3115A2 has invalid id")); - return; - } // reset this->write_byte(MPL3115A2_CTRL_REG1, MPL3115A2_CTRL_REG1_RST); diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index 19a4b067bc7..ed713cb30e1 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -117,16 +117,8 @@ const char *orientation_xy_to_string(OrientationXY orientation) { const char *orientation_z_to_string(bool orientation) { return orientation ? "Downwards looking" : "Upwards looking"; } void MSA3xxComponent::setup() { - uint8_t part_id{0xff}; - if (!this->read_byte(static_cast(RegisterMap::PART_ID), &part_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, static_cast(RegisterMap::PART_ID), MSA_3XX_PART_ID)) return; - } - if (part_id != MSA_3XX_PART_ID) { - ESP_LOGE(TAG, "Part ID is wrong. Got 0x%02X", part_id); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } // Resolution LSB/g // Range : MSA301 : MSA311 diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index da1f735fb9e..f03aac9a80c 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -30,16 +30,8 @@ void QMI8658Component::setup() { MotionComponent::setup(); // 1. Verify chip ID - uint8_t who_am_i = 0; - if (!this->read_byte(QMI8658_REG_WHO_AM_I, &who_am_i)) { - this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); + if (!this->check_chip_id(this, QMI8658_REG_WHO_AM_I, QMI8658_WHO_AM_I_VALUE)) return; - } - if (who_am_i != QMI8658_WHO_AM_I_VALUE) { - ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, QMI8658_WHO_AM_I_VALUE); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } // 2. Soft reset if (!this->write_byte(QMI8658_REG_RESET, QMI8658_RESET_CMD)) { diff --git a/esphome/components/qwiic_pir/qwiic_pir.cpp b/esphome/components/qwiic_pir/qwiic_pir.cpp index de9be3c8f0c..4b30a21a831 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.cpp +++ b/esphome/components/qwiic_pir/qwiic_pir.cpp @@ -6,17 +6,8 @@ namespace esphome::qwiic_pir { ESPHOME_LOG_TAG(TAG, "qwiic_pir"); void QwiicPIRComponent::setup() { - // Verify I2C communcation by reading and verifying the chip ID - uint8_t chip_id; - if (!this->read_byte(QWIIC_PIR_CHIP_ID, &chip_id)) { - this->mark_failed(LOG_STR("Failed to read chip ID")); + if (!this->check_chip_id(this, QWIIC_PIR_CHIP_ID, QWIIC_PIR_DEVICE_ID)) return; - } - - if (chip_id != QWIIC_PIR_DEVICE_ID) { - this->mark_failed(LOG_STR("Unknown chip ID")); - return; - } if (!this->write_byte_16(QWIIC_PIR_DEBOUNCE_TIME, this->debounce_time_)) { this->mark_failed(LOG_STR("Failed to configure debounce time")); diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index 7d66dd0a183..5132b2a3aa5 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -19,11 +19,8 @@ static const uint8_t WHOAMI_STTS22H_IDENTIFICATION = 0xA0; // ID value of STTS2 static const float SENSOR_SCALE = 0.01f; // Sensor resolution in degrees Celsius void STTS22HComponent::setup() { - // Check if device is a STTS22H - if (!this->is_stts22h_sensor_()) { - this->mark_failed(LOG_STR("Device is not a STTS22H sensor")); + if (!this->check_chip_id(this, WHOAMI_REG, WHOAMI_STTS22H_IDENTIFICATION)) return; - } this->initialize_sensor_(); } @@ -58,21 +55,6 @@ float STTS22HComponent::read_temperature_() { return temp_raw_value * SENSOR_SCALE; // Apply sensor resolution } -bool STTS22HComponent::is_stts22h_sensor_() { - uint8_t whoami_value; - if (this->read_register(WHOAMI_REG, &whoami_value, 1) != i2c::NO_ERROR) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return false; - } - - if (whoami_value != WHOAMI_STTS22H_IDENTIFICATION) { - this->mark_failed(LOG_STR("Unexpected WHOAMI identifier. Sensor is not a STTS22H")); - return false; - } - - return true; -} - void STTS22HComponent::initialize_sensor_() { // Read current CTRL_REG configuration uint8_t ctrl_value; diff --git a/esphome/components/stts22h/stts22h.h b/esphome/components/stts22h/stts22h.h index d8d7a485cf6..e1becb4b507 100644 --- a/esphome/components/stts22h/stts22h.h +++ b/esphome/components/stts22h/stts22h.h @@ -14,7 +14,6 @@ class STTS22HComponent final : public sensor::Sensor, public PollingComponent, p protected: void initialize_sensor_(); - bool is_stts22h_sensor_(); float read_temperature_(); }; diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index f1f9ae9cfdb..493f4ad3627 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -62,18 +62,8 @@ void TSL2591Component::setup() { break; } - uint8_t id; - if (!this->read_byte(TSL2591_COMMAND_BIT | TSL2591_REGISTER_DEVICE_ID, &id)) { - ESP_LOGE(TAG, "I2C read failed"); - this->mark_failed(); + if (!this->check_chip_id(this, TSL2591_COMMAND_BIT | TSL2591_REGISTER_DEVICE_ID, 0x50)) return; - } - - if (id != 0x50) { - ESP_LOGE(TAG, "Unknown chip ID"); - this->mark_failed(); - return; - } this->set_integration_time_and_gain(this->integration_time_, this->gain_); this->disable_if_power_saving_(); From 1ead61f11f3349fc7cd41dede4891b5de96cfd1e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:50:11 -0400 Subject: [PATCH 06/50] [esp32] Remove trailing comment from ECDSA test signing key (#20988) --- tests/components/esp32/dummy_signing_key_v1_ecdsa.pem | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem index bd092056069..eb27f9b2c48 100644 --- a/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem +++ b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem @@ -4,4 +4,3 @@ MHcCAQEEIEZIp96p7Z7QN6vxOFE5FdRNm535vW81Ax07KnGxVjiMoAoGCCqGSM49 AwEHoUQDQgAEK+fBQDn1Q+r5lGwcDoMUgeg2Aq16LLrLUz7xWI6mS0PUClzolDIo eaV/Pfjl7zAvkbQQsZq3rTNnr1eGAk5P+A== -----END EC PRIVATE KEY----- -*** DO NOT USE THIS KEY...EVER *** From d16d8c111ebf992f3d9054295298d030533ef1f2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:50:54 -0400 Subject: [PATCH 07/50] [debug] Log uptime in dump_config (#20989) --- esphome/components/debug/debug_component.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 97f8550c5bc..b2cb1910f68 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -34,6 +34,15 @@ void DebugComponent::dump_config() { this->free_heap_ = get_free_heap_(); ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); + uint32_t uptime = static_cast(millis_64() / 1000); + uint32_t days = uptime / 86400; + uptime -= days * 86400; + uint32_t hours = uptime / 3600; + uptime -= hours * 3600; + uint32_t minutes = uptime / 60; + uptime -= minutes * 60; + ESP_LOGD(TAG, "Uptime: %" PRIu32 "d %02" PRIu32 "h %02" PRIu32 "m %02" PRIu32 "s", days, hours, minutes, uptime); + pos = get_device_info_(std::span(device_info_buffer), pos); #ifdef USE_TEXT_SENSOR From 635df9c6ada2d7eb88ce69a60b635dbd45ae8eda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:51:29 -1000 Subject: [PATCH 08/50] [socket] Share the allow-list reject log and config lines through Ipv4Allow (#20983) --- esphome/components/socket/__init__.py | 1 + esphome/components/socket/ipv4_allow.cpp | 36 +++++++++++++++++ esphome/components/socket/ipv4_allow.h | 9 ++++- esphome/components/socket/tcp_listener.cpp | 22 ++-------- esphome/components/socket/tcp_listener.h | 1 - .../socket/test_ipv4_allow_host.cpp | 40 ++++++++++++++++++- 6 files changed, 87 insertions(+), 22 deletions(-) create mode 100644 esphome/components/socket/ipv4_allow.cpp diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index effa7d864fc..09d58d22e9f 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -274,6 +274,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines( "lwip_raw_udp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "bsd_sockets_impl.cpp": "USE_SOCKET_IMPL_BSD_SOCKETS", "lwip_sockets_impl.cpp": "USE_SOCKET_IMPL_LWIP_SOCKETS", + "ipv4_allow.cpp": "USE_SOCKET_IPV4_ALLOW", "ipv4_resolve.cpp": "USE_SOCKET_IPV4_RESOLVE", "tcp_client_link.cpp": "USE_SOCKET_TCP_CLIENT_LINK", "tcp_listener.cpp": "USE_SOCKET_TCP_LISTENER", diff --git a/esphome/components/socket/ipv4_allow.cpp b/esphome/components/socket/ipv4_allow.cpp new file mode 100644 index 00000000000..4791eeda369 --- /dev/null +++ b/esphome/components/socket/ipv4_allow.cpp @@ -0,0 +1,36 @@ +#include "ipv4_allow.h" + +#ifdef USE_SOCKET_IPV4_ALLOW + +#include "esphome/core/log.h" + +#include + +namespace esphome::socket { + +bool Ipv4Allow::admit(const struct sockaddr *peer, socklen_t len, const char *tag, uint32_t now) { + if (this->allows(peer)) { + return true; + } + if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { + this->last_reject_log_ms_ = now; + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(peer, len, std::span(text)); + ESP_LOGW(tag, "Rejected %s", text); + } + return false; +} + +void Ipv4Allow::dump_config(const char *tag) const { + for (size_t i = 0; i < this->count_; i++) { + const Ipv4AllowEntry e = this->entry(i); + // Network order is dotted order, and the contiguous mask's popcount is the prefix. + const auto *b = reinterpret_cast(&e.addr); + ESP_LOGCONFIG(tag, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], + static_cast(__builtin_popcount(e.mask))); + } +} + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/socket/ipv4_allow.h b/esphome/components/socket/ipv4_allow.h index f2f66edc05f..64116691812 100644 --- a/esphome/components/socket/ipv4_allow.h +++ b/esphome/components/socket/ipv4_allow.h @@ -47,6 +47,10 @@ class Ipv4Allow { return false; } + /// allows(peer), logging a refused peer at most once every REJECT_LOG_INTERVAL_MS. + bool admit(const struct sockaddr *peer, socklen_t len, const char *tag, uint32_t now); + /// One config line per allowed network. + void dump_config(const char *tag) const; size_t size() const { return this->count_; } /// A copy of entry i, read from flash. Ipv4AllowEntry entry(size_t i) const { @@ -55,9 +59,12 @@ class Ipv4Allow { return e; } - private: + protected: + static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; + const Ipv4AllowEntry *entries_{nullptr}; size_t count_{0}; + uint32_t last_reject_log_ms_{0}; }; } // namespace esphome::socket diff --git a/esphome/components/socket/tcp_listener.cpp b/esphome/components/socket/tcp_listener.cpp index e1f00e29277..cfd53b9e83f 100644 --- a/esphome/components/socket/tcp_listener.cpp +++ b/esphome/components/socket/tcp_listener.cpp @@ -12,9 +12,6 @@ namespace esphome::socket { // One client at a time; a second connection waits in the stack until the first drops. static constexpr int LISTEN_BACKLOG = 1; -#ifdef USE_SOCKET_IPV4_ALLOW -static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; -#endif void TcpListener::try_listen_(TcpClientLink &link) { this->listen_ = socket_ip_loop_monitored(SOCK_STREAM, IPPROTO_TCP); @@ -57,31 +54,20 @@ void TcpListener::accept_(TcpClientLink &link) { return; } const auto *sa = reinterpret_cast(&peer); - char text[SOCKADDR_STR_LEN]; - format_sockaddr_to(sa, peer_len, std::span(text)); #ifdef USE_SOCKET_IPV4_ALLOW - if (!this->allow_.allows(sa)) { - uint32_t now = App.get_loop_component_start_time(); - if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { - this->last_reject_log_ms_ = now; - ESP_LOGW(this->tag_, "Rejected %s", text); - } + if (!this->allow_.admit(sa, peer_len, this->tag_, App.get_loop_component_start_time())) { return; } #endif + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(sa, peer_len, std::span(text)); link.adopt(std::move(client)); ESP_LOGI(this->tag_, "Client connected from %s", text); } void TcpListener::dump_config() const { #ifdef USE_SOCKET_IPV4_ALLOW - for (size_t i = 0; i < this->allow_.size(); i++) { - Ipv4AllowEntry e = this->allow_.entry(i); - // Network order is dotted order, and the contiguous mask's popcount is the prefix. - const auto *b = reinterpret_cast(&e.addr); - ESP_LOGCONFIG(this->tag_, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], - static_cast(__builtin_popcount(e.mask))); - } + this->allow_.dump_config(this->tag_); #endif } diff --git a/esphome/components/socket/tcp_listener.h b/esphome/components/socket/tcp_listener.h index a42080e641f..6448a969bdd 100644 --- a/esphome/components/socket/tcp_listener.h +++ b/esphome/components/socket/tcp_listener.h @@ -50,7 +50,6 @@ class TcpListener { std::unique_ptr listen_; const char *tag_{nullptr}; #ifdef USE_SOCKET_IPV4_ALLOW - uint32_t last_reject_log_ms_{0}; Ipv4Allow allow_; #endif }; diff --git a/tests/components/socket/test_ipv4_allow_host.cpp b/tests/components/socket/test_ipv4_allow_host.cpp index a8924de3713..ab89a59b78a 100644 --- a/tests/components/socket/test_ipv4_allow_host.cpp +++ b/tests/components/socket/test_ipv4_allow_host.cpp @@ -9,8 +9,8 @@ namespace esphome::socket::testing { -// The size_t count packs into the pointer's padding; no RAM over a uint8_t. -static_assert(sizeof(Ipv4Allow) == 2 * sizeof(void *), "unexpected padding in Ipv4Allow"); +// Entries pointer, count and the reject-log timestamp; nothing else may creep in. +static_assert(sizeof(Ipv4Allow) <= 3 * sizeof(void *), "unexpected padding in Ipv4Allow"); // 192.168.175.20/32 and 192.168.175.0/24, network order, host bits cleared, // mirroring what add_ipv4_allow emits. @@ -26,6 +26,42 @@ static bool allows_peer(const Ipv4Allow &list, const char *ip) { return list.allows(reinterpret_cast(&peer)); } +// Exposes the reject-log timestamp, the only trace the throttle leaves besides the log line. +struct ThrottledAllow : public Ipv4Allow { + using Ipv4Allow::last_reject_log_ms_; +}; + +// admit() with the same production parser. +static bool admits_peer(Ipv4Allow &list, const char *ip, uint32_t now) { + struct sockaddr_storage peer {}; + socklen_t len = set_sockaddr(reinterpret_cast(&peer), sizeof(peer), ip, 0); + EXPECT_NE(len, 0); + return list.admit(reinterpret_cast(&peer), len, "test", now); +} + +TEST(Ipv4Allow, AdmitLogsARejectAtMostOncePerInterval) { + ThrottledAllow list; + list.set(ENTRIES, std::size(ENTRIES)); + EXPECT_TRUE(admits_peer(list, "192.168.175.66", 1000)); + EXPECT_TRUE(admits_peer(list, "::ffff:192.168.175.20", 1000)); + EXPECT_EQ(list.last_reject_log_ms_, 0u); + + EXPECT_FALSE(admits_peer(list, "10.0.0.1", 1000)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + // Inside the interval the peer is still refused, but the log line is skipped. + EXPECT_FALSE(admits_peer(list, "10.0.0.2", 1001)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + EXPECT_FALSE(admits_peer(list, "10.0.0.3", 5999)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + // Exactly one interval later it logs again. + EXPECT_FALSE(admits_peer(list, "10.0.0.4", 6000)); + EXPECT_EQ(list.last_reject_log_ms_, 6000u); + // An admitted peer leaves the throttle alone. + EXPECT_TRUE(admits_peer(list, "192.168.175.1", 6001)); + EXPECT_EQ(list.last_reject_log_ms_, 6000u); + list.dump_config("test"); +} + TEST(Ipv4Allow, EmptyAllowsEveryPeer) { Ipv4Allow list; EXPECT_TRUE(list.allows(htonl(0xC0A8AF01))); From 35276a38bc7e769f8e9c2f32ec1757338a354e34 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 17 Oct 2026 18:18:52 +0100 Subject: [PATCH 09/50] [rtsp_server] Add an RTSP server that streams the esp32_camera picture (#17990) Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/camera/camera.h | 3 +- esphome/components/rtsp_server/__init__.py | 85 +++ .../components/rtsp_server/rtsp_server.cpp | 144 +++++ esphome/components/rtsp_server/rtsp_server.h | 50 ++ .../components/rtsp_server/rtsp_session.cpp | 545 ++++++++++++++++++ esphome/components/rtsp_server/rtsp_session.h | 119 ++++ esphome/core/defines.h | 1 + tests/components/rtsp_server/__init__.py | 13 + tests/components/rtsp_server/common.yaml | 10 + .../rtsp_server/test.esp32-idf.yaml | 3 + .../rtsp_server/test_rtsp_session_host.cpp | 367 ++++++++++++ 12 files changed, 1340 insertions(+), 1 deletion(-) create mode 100644 esphome/components/rtsp_server/__init__.py create mode 100644 esphome/components/rtsp_server/rtsp_server.cpp create mode 100644 esphome/components/rtsp_server/rtsp_server.h create mode 100644 esphome/components/rtsp_server/rtsp_session.cpp create mode 100644 esphome/components/rtsp_server/rtsp_session.h create mode 100644 tests/components/rtsp_server/__init__.py create mode 100644 tests/components/rtsp_server/common.yaml create mode 100644 tests/components/rtsp_server/test.esp32-idf.yaml create mode 100644 tests/components/rtsp_server/test_rtsp_session_host.cpp diff --git a/CODEOWNERS b/CODEOWNERS index c9ff7c6bb32..f23f3613032 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -512,6 +512,7 @@ esphome/components/rp2040_pwm/* @jesserockz esphome/components/rp2_ble_tracker/* @bdraco esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 +esphome/components/rtsp_server/* @max246 esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index 433361d2985..9e3a95220fa 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -11,8 +11,9 @@ namespace esphome::camera { * IDLE: Camera requests to send an image to the API. * API_REQUESTER: API requests a new image. * WEB_REQUESTER: ESP32 web server request an image. Ignored by API. + * RTSP_REQUESTER: RTSP server requests a stream of images. Ignored by API and web server. */ -enum CameraRequester : uint8_t { IDLE, API_REQUESTER, WEB_REQUESTER }; +enum CameraRequester : uint8_t { IDLE, API_REQUESTER, WEB_REQUESTER, RTSP_REQUESTER }; /// Enumeration of different pixel formats. enum PixelFormat : uint8_t { diff --git a/esphome/components/rtsp_server/__init__.py b/esphome/components/rtsp_server/__init__.py new file mode 100644 index 00000000000..86b1d380267 --- /dev/null +++ b/esphome/components/rtsp_server/__init__.py @@ -0,0 +1,85 @@ +import esphome.codegen as cg +from esphome.components import network, socket +from esphome.components.const import CONF_ALLOWED_IPS +from esphome.components.esp32_camera import ( + CONF_JPEG_QUALITY, + CONF_PIXEL_FORMAT, + DOMAIN as ESP32_CAMERA_DOMAIN, + FRAME_SIZES, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MAX_CONNECTIONS, CONF_PORT, CONF_RESOLUTION +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@max246"] +AUTO_LOAD = ["camera", "socket"] +DEPENDENCIES = ["network"] +DOMAIN = "rtsp_server" + +# RFC 2435 stores width and height in units of 8 pixels in one byte each. +RTP_JPEG_MAX_DIMENSION = 255 * 8 + +rtsp_server_ns = cg.esphome_ns.namespace("rtsp_server") +RTSPServer = rtsp_server_ns.class_("RTSPServer", cg.Component) + + +def _consume_rtsp_server_sockets(config: ConfigType) -> ConfigType: + socket.consume_sockets(config[CONF_MAX_CONNECTIONS], DOMAIN)(config) + socket.consume_sockets(1, DOMAIN, socket.SocketType.TCP_LISTEN)(config) + # A 13 KB frame fills the default 5.7 KB TCP send buffer four segments at a time; over Wi-Fi + # that is about 1 fps, so ask for the larger buffers (network: enable_high_performance: false undoes it) + network.require_high_performance_networking() + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RTSPServer), + cv.Optional(CONF_PORT, default=554): cv.port, + cv.Optional(CONF_MAX_CONNECTIONS, default=2): cv.int_range(min=1, max=4), + cv.Optional(CONF_ALLOWED_IPS): socket.IPV4_ALLOW_SCHEMA, + } + ).extend(cv.COMPONENT_SCHEMA), + _consume_rtsp_server_sockets, +) + + +def _final_validate(config: ConfigType) -> None: + camera = fv.full_config.get().get(ESP32_CAMERA_DOMAIN) + if camera is None: + raise cv.Invalid( + f"{DOMAIN} requires a camera, add an '{ESP32_CAMERA_DOMAIN}:' block" + ) + pixel_format = camera[CONF_PIXEL_FORMAT] + # A GRAYSCALE frame converts to a one-component JPEG, which RTP/JPEG cannot carry. + if pixel_format == "GRAYSCALE": + raise cv.Invalid( + f"{DOMAIN} cannot stream '{CONF_PIXEL_FORMAT}: GRAYSCALE', use a color " + f"format in '{ESP32_CAMERA_DOMAIN}:'" + ) + # Other formats are converted to JPEG only when jpeg_quality is set. + if pixel_format != "JPEG" and camera[CONF_JPEG_QUALITY] == 0: + raise cv.Invalid( + f"{DOMAIN} streams JPEG frames, set '{CONF_JPEG_QUALITY}' in " + f"'{ESP32_CAMERA_DOMAIN}:' to convert '{CONF_PIXEL_FORMAT}: {pixel_format}'" + ) + resolution = camera[CONF_RESOLUTION] + # The enum names end in the frame size, e.g. ESP32_CAMERA_SIZE_2560X1440 + width, height = map(int, str(FRAME_SIZES[resolution]).rsplit("_", 1)[1].split("X")) + if max(width, height) > RTP_JPEG_MAX_DIMENSION: + raise cv.Invalid( + f"{DOMAIN} supports frames up to {RTP_JPEG_MAX_DIMENSION} pixels per side, " + f"lower '{CONF_RESOLUTION}' in '{ESP32_CAMERA_DOMAIN}:' (got {width}x{height})" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_PORT]) + await cg.register_component(var, config) + cg.add_define("MAX_RTSP_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) + socket.add_ipv4_allow(var.set_allow, config.get(CONF_ALLOWED_IPS), config[CONF_ID]) diff --git a/esphome/components/rtsp_server/rtsp_server.cpp b/esphome/components/rtsp_server/rtsp_server.cpp new file mode 100644 index 00000000000..3c344e354d7 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_server.cpp @@ -0,0 +1,144 @@ +#include "rtsp_server.h" + +#ifdef USE_CAMERA + +#include +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::rtsp_server { + +ESPHOME_LOG_TAG(TAG, "rtsp_server"); + +void RTSPServer::socket_failed_(const char *what) { + ESP_LOGE(TAG, "Socket %s failed: errno %d", what, errno); + delete this->socket_; + this->socket_ = nullptr; + this->mark_failed(); +} + +void RTSPServer::setup() { + this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); + if (this->socket_ == nullptr) { + ESP_LOGE(TAG, "Socket creation failed: errno %d", errno); + this->mark_failed(); + return; + } + + int enable = 1; + this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)); + + if (this->socket_->setblocking(false) != 0) { + this->socket_failed_("nonblocking"); + return; + } + + struct sockaddr_storage server {}; + socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); + if (sl == 0 || this->socket_->bind((struct sockaddr *) &server, sl) != 0) { + this->socket_failed_("bind"); + return; + } + + if (this->socket_->listen(4) != 0) { + this->socket_failed_("listen"); + return; + } + + camera::Camera::instance()->add_listener(this); +} + +void RTSPServer::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (this->socket_ != nullptr && this->socket_->ready()) { + this->accept_new_connections_(now); + } + + bool playing = false; + for (auto &session : this->sessions_) { + session.loop(now, this->port_); + playing |= session.is_playing(); + } + + if (playing != this->streaming_) { + this->streaming_ = playing; + if (playing) { + camera::Camera::instance()->start_stream(camera::RTSP_REQUESTER); + } else { + camera::Camera::instance()->stop_stream(camera::RTSP_REQUESTER); + } + } +} + +void RTSPServer::accept_new_connections_(uint32_t now) { + while (true) { + struct sockaddr_storage source_addr; + socklen_t addr_len = sizeof(source_addr); + + auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); + if (!sock) + break; +#ifdef USE_SOCKET_IPV4_ALLOW + if (!this->allow_.admit(reinterpret_cast(&source_addr), addr_len, TAG, now)) + continue; +#endif + + RTSPSession *free_slot = nullptr; + for (auto &session : this->sessions_) { + if (!session.is_active()) { + free_slot = &session; + break; + } + } + if (free_slot == nullptr) { + ESP_LOGW(TAG, "Max connections (%d) reached, rejecting connection", MAX_RTSP_CONNECTIONS); + continue; + } + + ESP_LOGD(TAG, "Accepted RTSP connection"); + free_slot->open(std::move(sock), now); + } +} + +void RTSPServer::on_camera_image(const std::shared_ptr &image) { + if (!this->streaming_ || !image->was_requested_by(camera::RTSP_REQUESTER)) + return; + + JpegInfo jpeg; + if (!parse_jpeg_info(image->get_data_buffer(), image->get_data_length(), jpeg) || + jpeg.width > RTP_JPEG_MAX_DIMENSION || jpeg.height > RTP_JPEG_MAX_DIMENSION) { + if (!this->frame_warned_) { + this->frame_warned_ = true; + ESP_LOGW(TAG, + "Dropping frame %" PRIu16 "x%" PRIu16 ": not a baseline 4:2:2/4:2:0 JPEG without restart markers" + " of at most %" PRIu16 " px per side", + jpeg.width, jpeg.height, RTP_JPEG_MAX_DIMENSION); + } + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + for (auto &session : this->sessions_) { + session.on_new_frame(image, jpeg, now); + } +} + +void RTSPServer::dump_config() { + ESP_LOGCONFIG(TAG, + "RTSP Server:\n" + " Port: %u\n" + " Max connections: %u", + this->port_, MAX_RTSP_CONNECTIONS); +#ifdef USE_SOCKET_IPV4_ALLOW + this->allow_.dump_config(TAG); +#endif +} + +float RTSPServer::get_setup_priority() const { return setup_priority::LATE; } + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_server.h b/esphome/components/rtsp_server/rtsp_server.h new file mode 100644 index 00000000000..48b7fd77ed5 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_server.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_CAMERA + +#include +#include + +#include "esphome/components/camera/camera.h" +#ifdef USE_SOCKET_IPV4_ALLOW +#include "esphome/components/socket/ipv4_allow.h" +#endif +#include "esphome/components/socket/socket.h" +#include "esphome/core/component.h" +#include "rtsp_session.h" + +namespace esphome::rtsp_server { + +class RTSPServer : public Component, public camera::CameraListener { + public: + explicit RTSPServer(uint16_t port) : port_(port) {} + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + void on_camera_image(const std::shared_ptr &image) override; +#ifdef USE_SOCKET_IPV4_ALLOW + void set_allow(const socket::Ipv4AllowEntry *entries, size_t count) { this->allow_.set(entries, count); } +#endif + + protected: + void accept_new_connections_(uint32_t now); + void socket_failed_(const char *what); + + socket::ListenSocket *socket_{nullptr}; + std::array sessions_{}; +#ifdef USE_SOCKET_IPV4_ALLOW + socket::Ipv4Allow allow_; +#endif + uint16_t port_; + bool streaming_{false}; + bool frame_warned_{false}; +}; + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_session.cpp b/esphome/components/rtsp_server/rtsp_session.cpp new file mode 100644 index 00000000000..ee188f07b20 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_session.cpp @@ -0,0 +1,545 @@ +#include "rtsp_session.h" + +#ifdef USE_CAMERA + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::rtsp_server { + +ESPHOME_LOG_TAG(TAG, "rtsp_server"); + +static constexpr uint8_t RTP_PAYLOAD_TYPE_JPEG = 26; +static constexpr uint32_t RTP_CLOCK_RATE = 90000; +static constexpr uint32_t SESSION_TIMEOUT_MS = 60000; +static constexpr char REQUEST_END[] = "\r\n\r\n"; +static constexpr size_t REQUEST_END_LEN = sizeof(REQUEST_END) - 1; +static constexpr uint8_t ZERO_FILL[64]{}; +static constexpr char SDP_BODY[] = "v=0\r\n" + "o=- 0 0 IN IP4 0.0.0.0\r\n" + "s=ESPHome RTSP\r\n" + "t=0 0\r\n" + "m=video 0 RTP/AVP 26\r\n" + "a=control:trackID=0\r\n"; + +namespace { + +StringRef trim(const StringRef &s) { + const char *start = s.c_str(); + size_t len = s.size(); + while (len > 0 && (*start == ' ' || *start == '\t')) { + start++; + len--; + } + while (len > 0 && (start[len - 1] == ' ' || start[len - 1] == '\t' || start[len - 1] == '\r')) { + len--; + } + return {start, len}; +} + +const char *find_request_end(const char *buf, size_t len) { + for (size_t i = 0; i + REQUEST_END_LEN <= len; i++) { + if (std::memcmp(buf + i, REQUEST_END, REQUEST_END_LEN) == 0) + return buf + i; + } + return nullptr; +} + +/// Bounded substring search; StringRef::find() uses strstr and could run past the header value. +bool contains(const StringRef &hay, const StringRef &needle) { + if (needle.size() > hay.size()) + return false; + for (size_t i = 0; i + needle.size() <= hay.size(); i++) { + if (std::memcmp(hay.c_str() + i, needle.c_str(), needle.size()) == 0) + return true; + } + return false; +} + +} // namespace + +/// Scans JPEG marker segments (SOF0/1, DQT, SOS) for the dimensions, quantization tables and the start of +/// the entropy-coded scan data. Assumes the standard Huffman tables (true for esp32-camera), so DHT segments +/// are not parsed; receivers rebuild them per RFC 2435. Only three-component 4:2:2 or 4:2:0 frames without +/// restart markers (DRI) can be carried as RFC 2435 types 0 and 1; anything else returns false. +bool parse_jpeg_info(const uint8_t *data, size_t len, JpegInfo &out) { + if (len < 4 || data[0] != 0xFF || data[1] != 0xD8) + return false; // no SOI + + size_t pos = 2; + while (pos + 2 <= len) { + if (data[pos] != 0xFF) { + pos++; // resync on a stray non-marker byte + continue; + } + uint8_t marker = data[pos + 1]; + if (marker == 0xFF) { + pos++; // fill byte before the real marker + continue; + } + if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD9)) { + pos += 2; // standalone marker, no length field (TEM, RSTn, SOI, EOI) + continue; + } + if (pos + 4 > len) + break; + + uint16_t seg_len = encode_uint16(data[pos + 2], data[pos + 3]); + size_t seg_start = pos + 4; + if (seg_len < 2 || seg_start + (seg_len - 2) > len) + return false; + size_t seg_end = seg_start + (seg_len - 2); + + if (marker == 0xC0 || marker == 0xC1) { // SOF0 / SOF1 + if (seg_len < 8 + 3 * 3 || data[seg_start + 5] != 3) + return false; // not three components + out.height = encode_uint16(data[seg_start + 1], data[seg_start + 2]); + out.width = encode_uint16(data[seg_start + 3], data[seg_start + 4]); + const uint8_t sampling = data[seg_start + 7]; // Y component H/V sampling factors + if (sampling == 0x21) { + out.type = 0; // 4:2:2 + } else if (sampling == 0x22) { + out.type = 1; // 4:2:0 + } else { + return false; + } + } else if (marker == 0xDD) { // DRI + return false; + } else if (marker == 0xDB) { // DQT, one or more tables back to back + size_t p = seg_start; + while (p < seg_end) { + uint8_t precision = data[p] >> 4; + uint8_t table_id = data[p] & 0x0F; + size_t table_len = precision == 0 ? QTABLE_LEN : 2 * QTABLE_LEN; + if (p + 1 + table_len > seg_end) + break; + if (precision == 0 && table_id == 0) { + out.qtable0 = &data[p + 1]; + } else if (precision == 0 && table_id == 1) { + out.qtable1 = &data[p + 1]; + } + p += 1 + table_len; + } + } else if (marker == 0xDA) { // SOS, scan data follows + if (out.width == 0 || out.height == 0 || out.qtable0 == nullptr) + return false; + if (out.qtable1 == nullptr) + out.qtable1 = out.qtable0; + out.scan_data = data + seg_end; + out.scan_len = len - seg_end; + if (out.scan_len >= 2 && out.scan_data[out.scan_len - 2] == 0xFF && out.scan_data[out.scan_len - 1] == 0xD9) + out.scan_len -= 2; // the RFC 2435 payload is the entropy-coded data only + return true; + } + + pos = seg_end; + } + return false; +} + +void RTSPSession::open(std::unique_ptr socket, uint32_t now) { + this->socket_ = std::move(socket); + this->socket_->setblocking(false); + int enable = 1; + this->socket_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(enable)); + this->should_close_ = false; + this->close_after_send_ = false; + this->read_pending_ = false; + this->request_len_ = 0; + this->skip_len_ = 0; + this->filler_len_ = 0; + this->cseq_ = 0; + this->last_frame_ms_ = 0; + this->last_activity_ms_ = now; + this->ssrc_ = random_uint32(); + this->sequence_number_ = static_cast(random_uint32()); + this->rtp_timestamp_ = random_uint32(); + format_hex_to(this->session_id_, random_uint32()); +} + +void RTSPSession::close_() { + this->frame_.reset(); + this->socket_.reset(); + this->state_ = State::INIT; + this->iov_pos_ = 0; + this->iov_count_ = 0; + this->filler_len_ = 0; +} + +void RTSPSession::loop(uint32_t now, uint16_t port) { + if (this->socket_ == nullptr) + return; + if (now - this->last_activity_ms_ > SESSION_TIMEOUT_MS) { + ESP_LOGW(TAG, "Session %s timed out", this->session_id_); + this->should_close_ = true; + } else { + this->service_(now, port); + } + if (this->should_close_) + this->close_(); +} + +void RTSPSession::service_(uint32_t now, uint16_t port) { + if (this->frame_ != nullptr && now - this->frame_progress_ms_ > FRAME_STALL_MS) { + ESP_LOGV(TAG, "Session %s stalled, dropping frame", this->session_id_); + this->drop_frame_(); + } + if (!this->flush_(now)) + return; + if (!this->close_after_send_) + this->read_requests_(now, port); + // Drain the current frame until done or the socket backs up; one packet per loop would cap the frame rate. + while (this->flush_(now)) { + if (this->close_after_send_) { + this->should_close_ = true; + return; + } + if (!this->queue_next_packet_()) + return; + } +} + +void RTSPSession::read_requests_(uint32_t now, uint16_t port) { + // A pipelined request may already be buffered; it is handled once the previous response is written. + if (this->process_input_(port)) + return; + if (!this->read_pending_ && !this->socket_->ready()) + return; + this->read_pending_ = false; + + while (true) { + if (this->request_len_ >= this->request_buf_.size()) { + ESP_LOGW(TAG, "Request too large, closing session"); + this->should_close_ = true; + return; + } + ssize_t n = this->socket_->read(this->request_buf_.data() + this->request_len_, + this->request_buf_.size() - this->request_len_); + if (n > 0) { + this->request_len_ += static_cast(n); + this->last_activity_ms_ = now; + if (this->process_input_(port)) { + this->read_pending_ = true; // stopped before EAGAIN, so ready() may not report what is left + return; + } + continue; + } + if (n == 0) { + this->should_close_ = true; + return; + } + if (errno != EAGAIN && errno != EWOULDBLOCK) { + ESP_LOGW(TAG, "Read failed: errno %d", errno); + this->should_close_ = true; + } + return; + } +} + +bool RTSPSession::process_input_(uint16_t port) { + while (this->request_len_ > 0) { + if (this->skip_len_ > 0) { + const size_t len = std::min(this->skip_len_, this->request_len_); + this->consume_input_(len); + this->skip_len_ -= len; + continue; + } + char *buf = this->request_buf_.data(); + if (buf[0] == '$') { // interleaved packet: '$', channel, 16-bit length, payload + if (this->request_len_ < INTERLEAVED_HEADER_LEN) + return false; + this->skip_len_ = + INTERLEAVED_HEADER_LEN + encode_uint16(static_cast(buf[2]), static_cast(buf[3])); + continue; + } + const char *end = find_request_end(buf, this->request_len_); + if (end == nullptr) + return false; // wait for the rest of the request + const size_t len = end - buf; + buf[len] = '\0'; // over the terminator's first CR + this->handle_request_(StringRef(buf, len), port); + this->consume_input_(len + REQUEST_END_LEN); + return true; + } + return false; +} + +void RTSPSession::consume_input_(size_t len) { + this->request_len_ -= len; + std::memmove(this->request_buf_.data(), this->request_buf_.data() + len, this->request_len_); +} + +void RTSPSession::handle_request_(StringRef request, uint16_t port) { + size_t line_end = request.find("\r\n"); + StringRef first_line = line_end == std::string::npos ? request : StringRef(request.c_str(), line_end); + + this->cseq_ = 0; + StringRef transport; + int64_t content_length = 0; + + // Headers first: even a 400 reply has to echo the request's CSeq + size_t pos = line_end == std::string::npos ? request.size() : line_end + 2; + while (pos < request.size()) { + size_t next = request.find("\r\n", pos); + size_t header_end = next == std::string::npos ? request.size() : next; + StringRef header(request.c_str() + pos, header_end - pos); + + size_t colon = header.find(':'); + if (colon != std::string::npos) { + StringRef key = trim(StringRef(header.c_str(), colon)); + StringRef value = trim(StringRef(header.c_str() + colon + 1, header.size() - colon - 1)); + if (str_equals_case_insensitive(key, StringRef::from_lit("CSeq"))) { + this->cseq_ = static_cast(stol(value)); + } else if (str_equals_case_insensitive(key, StringRef::from_lit("Transport"))) { + transport = value; + } else if (str_equals_case_insensitive(key, StringRef::from_lit("Content-Length"))) { + content_length = stol(value); + } + } + + if (next == std::string::npos) + break; + pos = next + 2; + } + + if (content_length < 0 || content_length > static_cast(this->request_buf_.size())) { + this->queue_response_(400, "Bad Request"); + this->close_after_send_ = true; + return; + } + // The body is ignored; it is discarded as it arrives, like an interleaved packet. + this->skip_len_ = static_cast(content_length); + + size_t sp1 = first_line.find(' '); + size_t sp2 = sp1 == std::string::npos ? std::string::npos : first_line.find(' ', sp1 + 1); + if (sp1 == std::string::npos || sp2 == std::string::npos) { + this->queue_response_(400, "Bad Request"); + return; + } + StringRef method(first_line.c_str(), sp1); + + if (str_equals_case_insensitive(method, StringRef::from_lit("OPTIONS"))) { + this->queue_response_(200, "OK", "Public: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN, GET_PARAMETER\r\n"); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("DESCRIBE"))) { + char addr_buf[socket::SOCKADDR_STR_LEN]; + this->socket_->getsockname_to(addr_buf); + const bool ipv6 = std::strchr(addr_buf, ':') != nullptr; + this->queue_response_(200, "OK", + "Content-Base: rtsp://%s%s%s:%u/\r\n" + "Content-Type: application/sdp\r\n" + "Content-Length: %u\r\n", + ipv6 ? "[" : "", addr_buf, ipv6 ? "]" : "", port, + static_cast(sizeof(SDP_BODY) - 1)); + this->iov_[this->iov_count_++] = {const_cast(SDP_BODY), sizeof(SDP_BODY) - 1}; + } else if (str_equals_case_insensitive(method, StringRef::from_lit("SETUP"))) { + this->handle_setup_(transport); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("PLAY"))) { + this->handle_play_(); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("TEARDOWN"))) { + this->stop_playing_(); + this->queue_response_(200, "OK"); + this->close_after_send_ = true; + } else if (str_equals_case_insensitive(method, StringRef::from_lit("GET_PARAMETER"))) { + this->queue_response_(200, "OK"); + } else { + this->queue_response_(501, "Not Implemented"); + } +} + +void RTSPSession::queue_response_(uint16_t code, const char *reason, const char *extra_headers_fmt, ...) { + char *buf = this->send_buf_.data(); + const size_t size = this->send_buf_.size(); + size_t pos = buf_append_printf(buf, size, 0, "RTSP/1.0 %u %s\r\nCSeq: %" PRIu32 "\r\n", code, reason, this->cseq_); + if (this->state_ != State::INIT) + pos = buf_append_printf(buf, size, pos, "Session: %s;timeout=60\r\n", this->session_id_); + if (extra_headers_fmt != nullptr && pos < size) { + va_list args; + va_start(args, extra_headers_fmt); + int written = vsnprintf(buf + pos, size - pos, extra_headers_fmt, args); + va_end(args); + if (written > 0) + pos = std::min(pos + static_cast(written), size); + } + pos = buf_append_str(buf, size, pos, "\r\n"); + this->iov_[0] = {buf, std::min(pos, size - 1)}; + this->iov_pos_ = 0; + this->iov_count_ = 1; + this->sending_packet_ = false; +} + +void RTSPSession::handle_setup_(StringRef transport_header) { + if (!contains(transport_header, StringRef::from_lit("RTP/AVP/TCP"))) { + this->queue_response_(461, "Unsupported Transport"); + return; + } + // A SETUP while streaming stops playback, ending in READY like a first SETUP. + this->stop_playing_(); + // The single video track always uses interleaved channels 0 (RTP) and 1 (RTCP, unused). + this->queue_response_(200, "OK", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n"); +} + +void RTSPSession::handle_play_() { + if (this->state_ == State::INIT) { + this->queue_response_(455, "Method Not Valid In This State"); + return; + } + this->state_ = State::PLAYING; + this->frame_.reset(); // a repeated PLAY may arrive mid-frame + this->queue_response_(200, "OK", "Range: npt=0.000-\r\n"); +} + +void RTSPSession::stop_playing_() { + this->state_ = State::READY; + this->frame_.reset(); +} + +void RTSPSession::on_new_frame(const std::shared_ptr &image, const JpegInfo &jpeg, uint32_t now) { + // Pending output means a frame is in flight or a stalled one is still being padded out. + if (this->state_ != State::PLAYING || this->frame_ != nullptr || this->iov_pos_ < this->iov_count_ || + this->filler_len_ > 0) + return; + this->frame_ = image; + this->jpeg_ = jpeg; + this->frame_sent_ = 0; + this->frame_progress_ms_ = now; + + uint32_t elapsed_ms = this->last_frame_ms_ == 0 ? 0 : now - this->last_frame_ms_; + this->rtp_timestamp_ += static_cast((static_cast(elapsed_ms) * RTP_CLOCK_RATE) / 1000); + this->last_frame_ms_ = now; +} + +bool RTSPSession::queue_next_packet_() { + if (this->frame_ == nullptr) + return false; + if (this->frame_sent_ >= this->jpeg_.scan_len) { + this->frame_.reset(); // its last packet is written + return false; + } + + const bool first_fragment = this->frame_sent_ == 0; + const size_t header_len = + INTERLEAVED_HEADER_LEN + RTP_HEADER_LEN + JPEG_HEADER_LEN + (first_fragment ? QUANT_HEADER_LEN : 0); + const size_t qtables_len = first_fragment ? 2 * QTABLE_LEN : 0; + const size_t payload_len = + std::min(this->jpeg_.scan_len - this->frame_sent_, MAX_PACKET_SIZE - header_len - qtables_len); + const bool last_fragment = this->frame_sent_ + payload_len >= this->jpeg_.scan_len; + const size_t rtp_len = header_len - INTERLEAVED_HEADER_LEN + qtables_len + payload_len; + + auto *buf = reinterpret_cast(this->send_buf_.data()); + size_t pos = 0; + buf[pos++] = '$'; + buf[pos++] = 0; // interleaved channel 0 + buf[pos++] = static_cast(rtp_len >> 8); + buf[pos++] = static_cast(rtp_len); + + buf[pos++] = 0x80; // V=2, P=0, X=0, CC=0 + buf[pos++] = static_cast((last_fragment ? 0x80 : 0x00) | RTP_PAYLOAD_TYPE_JPEG); + buf[pos++] = static_cast(this->sequence_number_ >> 8); + buf[pos++] = static_cast(this->sequence_number_); + this->sequence_number_++; + buf[pos++] = static_cast(this->rtp_timestamp_ >> 24); + buf[pos++] = static_cast(this->rtp_timestamp_ >> 16); + buf[pos++] = static_cast(this->rtp_timestamp_ >> 8); + buf[pos++] = static_cast(this->rtp_timestamp_); + buf[pos++] = static_cast(this->ssrc_ >> 24); + buf[pos++] = static_cast(this->ssrc_ >> 16); + buf[pos++] = static_cast(this->ssrc_ >> 8); + buf[pos++] = static_cast(this->ssrc_); + + // RFC 2435 main JPEG header + buf[pos++] = 0; // type-specific + buf[pos++] = static_cast(this->frame_sent_ >> 16); + buf[pos++] = static_cast(this->frame_sent_ >> 8); + buf[pos++] = static_cast(this->frame_sent_); + buf[pos++] = this->jpeg_.type; + buf[pos++] = 255; // Q >= 128: the quantization tables follow in the first packet + buf[pos++] = static_cast(this->jpeg_.width / 8); + buf[pos++] = static_cast(this->jpeg_.height / 8); + + uint8_t count = 0; + if (first_fragment) { + buf[pos++] = 0; // MBZ + buf[pos++] = 0; // 8-bit precision + buf[pos++] = static_cast(qtables_len >> 8); + buf[pos++] = static_cast(qtables_len); + this->iov_[0] = {buf, header_len}; + this->iov_[1] = {const_cast(this->jpeg_.qtable0), QTABLE_LEN}; + this->iov_[2] = {const_cast(this->jpeg_.qtable1), QTABLE_LEN}; + count = 3; + } else { + this->iov_[0] = {buf, header_len}; + count = 1; + } + this->iov_[count++] = {const_cast(this->jpeg_.scan_data + this->frame_sent_), payload_len}; + this->iov_pos_ = 0; + this->iov_count_ = count; + this->sending_packet_ = true; + this->frame_sent_ += payload_len; + return true; +} + +void RTSPSession::drop_frame_() { + this->frame_.reset(); + if (!this->sending_packet_ || this->iov_pos_ >= this->iov_count_) + return; + // Even an unwritten packet is kept, so output stays pending and on_new_frame() takes nothing until the + // socket drains. Entries after the header point into the frame. + for (uint8_t i = std::max(this->iov_pos_, 1); i < this->iov_count_; i++) + this->filler_len_ += this->iov_[i].iov_len; + this->iov_count_ = std::max(this->iov_pos_, 1); + this->sending_packet_ = false; +} + +bool RTSPSession::flush_(uint32_t now) { + if (this->should_close_) + return false; + while (true) { + if (this->iov_pos_ >= this->iov_count_) { + if (this->filler_len_ == 0) + return true; + this->iov_pos_ = 0; + this->iov_count_ = 0; + while (this->filler_len_ > 0 && this->iov_count_ < this->iov_.size()) { + const size_t len = std::min(this->filler_len_, sizeof(ZERO_FILL)); + this->iov_[this->iov_count_++] = {const_cast(ZERO_FILL), len}; + this->filler_len_ -= len; + } + } + ssize_t written = this->socket_->writev(&this->iov_[this->iov_pos_], this->iov_count_ - this->iov_pos_); + if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return false; // retry next loop + if (written <= 0) { + if (written < 0) { + ESP_LOGW(TAG, "Write failed: errno %d", errno); + } + this->should_close_ = true; + return false; + } + this->last_activity_ms_ = now; + this->frame_progress_ms_ = now; + // Step over what was written; a partial write leaves the rest of the current entry pending. + auto remaining = static_cast(written); + while (remaining > 0) { + struct iovec &entry = this->iov_[this->iov_pos_]; + if (remaining < entry.iov_len) { + entry.iov_base = static_cast(entry.iov_base) + remaining; + entry.iov_len -= remaining; + break; + } + remaining -= entry.iov_len; + this->iov_pos_++; + } + } +} + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_session.h b/esphome/components/rtsp_server/rtsp_session.h new file mode 100644 index 00000000000..b34b7faecba --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_session.h @@ -0,0 +1,119 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_CAMERA + +#include +#include + +#include "esphome/components/camera/camera.h" +#include "esphome/components/socket/socket.h" +#include "esphome/core/string_ref.h" + +namespace esphome::rtsp_server { + +static constexpr size_t INTERLEAVED_HEADER_LEN = 4; +static constexpr size_t RTP_HEADER_LEN = 12; +static constexpr size_t JPEG_HEADER_LEN = 8; +static constexpr size_t QUANT_HEADER_LEN = 4; +static constexpr size_t QTABLE_LEN = 64; // one 8-bit precision quantization table +static constexpr size_t RTP_JPEG_PAYLOAD_SIZE = 1024; +static constexpr size_t MAX_PACKET_SIZE = INTERLEAVED_HEADER_LEN + RTP_HEADER_LEN + JPEG_HEADER_LEN + QUANT_HEADER_LEN + + 2 * QTABLE_LEN + RTP_JPEG_PAYLOAD_SIZE; +// RFC 2435 stores width and height in units of 8 pixels in one byte each. +static constexpr uint16_t RTP_JPEG_MAX_DIMENSION = 255 * 8; +// A frame whose packets make no write progress for this long is dropped, so a stalled client cannot hold +// the camera image (esp32_camera captures nothing while any copy is held). +static constexpr uint32_t FRAME_STALL_MS = 500; + +/// The fields of a JPEG frame that an RFC 2435 RTP/JPEG payload needs. Pointers refer into the frame. +struct JpegInfo { + const uint8_t *qtable0{nullptr}; + const uint8_t *qtable1{nullptr}; // same as qtable0 when the frame has one table + const uint8_t *scan_data{nullptr}; + size_t scan_len{0}; // entropy-coded data, without the trailing EOI marker + uint16_t width{0}; + uint16_t height{0}; + uint8_t type{0}; // RFC 2435 type: 0 = 4:2:2 subsampling, 1 = 4:2:0 +}; + +bool parse_jpeg_info(const uint8_t *data, size_t len, JpegInfo &out); + +/** A single RTSP client: one TCP connection carrying both the RTSP control channel and, once playing, + * RTP/JPEG media interleaved on the same socket (RFC 2326 §10.12). No UDP transport is supported. + * The object is a reusable slot: open() starts a connection, and it closes itself when the connection ends. + */ +class RTSPSession { + public: + bool is_active() const { return this->socket_ != nullptr; } + bool is_playing() const { return this->state_ == State::PLAYING; } + + void open(std::unique_ptr socket, uint32_t now); + void loop(uint32_t now, uint16_t port); + /// Ignored unless playing with no output pending; packets of the frame being sent point into that frame. + void on_new_frame(const std::shared_ptr &image, const JpegInfo &jpeg, uint32_t now); + + protected: + enum class State : uint8_t { INIT, READY, PLAYING }; + + void service_(uint32_t now, uint16_t port); + void close_(); + void read_requests_(uint32_t now, uint16_t port); + /// Consumes buffered input up to and including the next request; true once a request was handled. + bool process_input_(uint16_t port); + void consume_input_(size_t len); + void handle_request_(StringRef request, uint16_t port); + void handle_setup_(StringRef transport_header); + void handle_play_(); + /// Queues "RTSP/1.0 ", CSeq, Session once set up, the extra headers and the blank line. + void queue_response_(uint16_t code, const char *reason, const char *extra_headers_fmt = nullptr, ...) + __attribute__((format(printf, 4, 5))); + void stop_playing_(); + bool queue_next_packet_(); + /// Releases the frame; the frame-backed rest of the current packet is sent as zeros to keep the framing. + void drop_frame_(); + /// Writes what is queued; true once all of it is written, false while backlogged or after a failure. + bool flush_(uint32_t now); + + std::unique_ptr socket_; + // Held until its last packet is written; esp32_camera captures nothing new while any copy is held. + std::shared_ptr frame_; + JpegInfo jpeg_; + size_t frame_sent_{0}; + + uint32_t cseq_{0}; + uint32_t rtp_timestamp_{0}; + uint32_t ssrc_{0}; + uint32_t last_activity_ms_{0}; + uint32_t last_frame_ms_{0}; + uint32_t frame_progress_ms_{0}; + // Bytes of an interleaved packet (RTCP) or request body from the client still to discard. + uint32_t skip_len_{0}; + uint16_t sequence_number_{0}; + uint16_t request_len_{0}; + // Zeros still owed for a packet whose frame was dropped part way through. + uint16_t filler_len_{0}; + + // Pending output: a response or one RTP packet. Packets point at the frame's tables and scan data. + std::array iov_{}; + uint8_t iov_pos_{0}; + uint8_t iov_count_{0}; + + State state_{State::INIT}; + bool should_close_{false}; + bool close_after_send_{false}; + bool sending_packet_{false}; // iov_ holds an RTP packet rather than a response + bool read_pending_{false}; // reading stopped before EAGAIN; read again without waiting for ready() + char session_id_[9]{}; + + // Requests and interleaved packets from the client. A request is NUL terminated in place before it is + // handled, for StringRef::find(), which uses strstr. + std::array request_buf_{}; + // Response text, or the header of the RTP packet being sent. + std::array send_buf_{}; +}; + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8d17dc3f514..dbd6c8d8538 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -268,6 +268,7 @@ #define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 +#define MAX_RTSP_CONNECTIONS 2 #define USE_SCRIPT_ON_STOP #define USE_SELECT #define USE_SELEC_METER_EM4M diff --git a/tests/components/rtsp_server/__init__.py b/tests/components/rtsp_server/__init__.py new file mode 100644 index 00000000000..25a08c171d9 --- /dev/null +++ b/tests/components/rtsp_server/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # No host camera platform emits USE_CAMERA, and the real to_code needs a configured server. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_CAMERA") + cg.add_define("MAX_RTSP_CONNECTIONS", 1) + cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + + manifest.to_code = to_code_testing diff --git a/tests/components/rtsp_server/common.yaml b/tests/components/rtsp_server/common.yaml new file mode 100644 index 00000000000..6f1e0dd078a --- /dev/null +++ b/tests/components/rtsp_server/common.yaml @@ -0,0 +1,10 @@ +rtsp_server: + port: 554 + max_connections: 2 + allowed_ips: + - 192.168.1.0/24 + - 10.0.0.5 + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/rtsp_server/test.esp32-idf.yaml b/tests/components/rtsp_server/test.esp32-idf.yaml new file mode 100644 index 00000000000..d186203c80d --- /dev/null +++ b/tests/components/rtsp_server/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml + rtsp_server: !include common.yaml diff --git a/tests/components/rtsp_server/test_rtsp_session_host.cpp b/tests/components/rtsp_server/test_rtsp_session_host.cpp new file mode 100644 index 00000000000..9d9154f587e --- /dev/null +++ b/tests/components/rtsp_server/test_rtsp_session_host.cpp @@ -0,0 +1,367 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/rtsp_server/rtsp_session.h" + +#if defined(USE_HOST) && defined(USE_CAMERA) + +namespace esphome::rtsp_server::testing { + +static constexpr uint16_t PORT = 554; +static constexpr size_t REQUEST_BUF_SIZE = 512; + +class StubImage : public camera::CameraImage { + public: + explicit StubImage(size_t len) : data_(len, 0x55) {} + uint8_t *get_data_buffer() override { return this->data_.data(); } + size_t get_data_length() override { return this->data_.size(); } + bool was_requested_by(camera::CameraRequester requester) const override { return true; } + + protected: + std::vector data_; +}; + +class RTSPSessionUnderTest : public RTSPSession { + public: + bool has_frame() const { return this->frame_ != nullptr; } +}; + +class RTSPSessionTest : public ::testing::Test { + protected: + void SetUp() override { + signal(SIGPIPE, SIG_IGN); + int fds[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + this->peer_fd_ = fds[1]; + this->session_.open(std::make_unique(fds[0]), this->now_); + } + void TearDown() override { ::close(this->peer_fd_); } + + // Reopens the session on a loopback TCP connection, which (unlike AF_UNIX) accepts partial writes. + void reopen_over_tcp() { + int listener = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(listener, 0); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(addr); + ASSERT_EQ(::bind(listener, reinterpret_cast(&addr), sizeof(addr)), 0); + ASSERT_EQ(::listen(listener, 1), 0); + ASSERT_EQ(::getsockname(listener, reinterpret_cast(&addr), &len), 0); + int client = ::socket(AF_INET, SOCK_STREAM, 0); + int size = 8192; + ::setsockopt(client, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)); + ASSERT_EQ(::connect(client, reinterpret_cast(&addr), sizeof(addr)), 0); + int server = ::accept(listener, nullptr, nullptr); + ::close(listener); + ASSERT_GE(server, 0); + ::setsockopt(server, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)); +#ifdef SO_SNDLOWAT + int lowat = 1; // macOS otherwise refuses writes smaller than its 2 KiB low-water mark + ::setsockopt(server, SOL_SOCKET, SO_SNDLOWAT, &lowat, sizeof(lowat)); +#endif + ::close(this->peer_fd_); + this->peer_fd_ = client; + this->session_.open(std::make_unique(server), this->now_); + } + + void send(const std::string &data) { + ASSERT_EQ(::write(this->peer_fd_, data.data(), data.size()), static_cast(data.size())); + } + void loops(int count) { + for (int i = 0; i < count; i++) { + usleep(1000); // let loopback TCP deliver + this->session_.loop(this->now_, PORT); + } + } + // Everything the session has written so far. + std::string receive() { + usleep(1000); + std::string out; + char buf[4096]; + while (true) { + ssize_t n = ::recv(this->peer_fd_, buf, sizeof(buf), MSG_DONTWAIT); + if (n <= 0) + break; + out.append(buf, n); + } + return out; + } + static std::string request(const char *method, int cseq, const std::string &extra = "") { + char line[48]; + snprintf(line, sizeof(line), "%s rtsp://host/ RTSP/1.0\r\nCSeq: %d\r\n", method, cseq); + return line + extra + "\r\n"; + } + static size_t count(const std::string &haystack, const std::string &needle) { + size_t n = 0; + for (size_t pos = haystack.find(needle); pos != std::string::npos; pos = haystack.find(needle, pos + 1)) + n++; + return n; + } + void start_playing() { + this->send(request("SETUP", 1, "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n")); + this->loops(2); + this->send(request("PLAY", 2)); + this->loops(2); + std::string out = this->receive(); + ASSERT_NE(out.find("CSeq: 2\r\n"), std::string::npos); + ASSERT_TRUE(this->session_.is_playing()); + } + + RTSPSessionUnderTest session_; + int peer_fd_{-1}; + uint32_t now_{1000}; +}; + +TEST_F(RTSPSessionTest, InterleavedRtcpBeforeRequestIsSkipped) { + std::string rtcp("$\x01\x00\x08", 4); + rtcp.append("\x81\xc9\x00\x01\x00\x00\x00\x00", 8); // contains NUL bytes + this->send(rtcp + request("GET_PARAMETER", 7)); + this->loops(1); + std::string out = this->receive(); + EXPECT_EQ(out.rfind("RTSP/1.0 200 OK\r\nCSeq: 7\r\n", 0), 0u) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, InterleavedPacketSplitAcrossReads) { + this->send(std::string("$\x01\x01", 3)); + this->loops(1); + std::string rest("\x00", 1); + rest.append(256, '\0'); + this->send(rest + request("GET_PARAMETER", 8)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 8\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, MalformedRequestLineEchoesCSeq) { + this->send("OPTIONS\r\nCSeq: 9\r\n\r\n"); + this->loops(1); + std::string out = this->receive(); + EXPECT_EQ(out.rfind("RTSP/1.0 400 Bad Request\r\nCSeq: 9\r\n", 0), 0u) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, PipelinedRequestsBothAnswered) { + this->send(request("OPTIONS", 1) + request("GET_PARAMETER", 2)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + size_t first = out.find("CSeq: 1\r\n"); + size_t second = out.find("CSeq: 2\r\n"); + ASSERT_NE(first, std::string::npos); + ASSERT_NE(second, std::string::npos); + EXPECT_LT(first, second); +} + +TEST_F(RTSPSessionTest, RequestBodyIsConsumed) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 6\r\n") + "ping\r\n" + request("OPTIONS", 4)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + EXPECT_EQ(out.find("400"), std::string::npos) << out; + size_t first = out.find("CSeq: 3\r\n"); + size_t second = out.find("CSeq: 4\r\n"); + ASSERT_NE(first, std::string::npos); + ASSERT_NE(second, std::string::npos); + EXPECT_LT(first, second); +} + +TEST_F(RTSPSessionTest, RequestBodySplitAcrossReads) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 6\r\n") + "pi"); + this->loops(2); + this->send("ng\r\n" + request("OPTIONS", 4)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + EXPECT_EQ(out.find("400"), std::string::npos) << out; + EXPECT_NE(out.find("CSeq: 4\r\n"), std::string::npos) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, OversizedContentLengthClosesSession) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 100000\r\n")); + this->loops(2); + EXPECT_EQ(this->receive().rfind("RTSP/1.0 400 Bad Request\r\nCSeq: 3\r\n", 0), 0u); + EXPECT_FALSE(this->session_.is_active()); +} + +static std::string padded_request(size_t total_len) { + std::string head = "GET_PARAMETER rtsp://host/ RTSP/1.0\r\nCSeq: 9\r\nX-Pad: "; + std::string tail = "\r\n\r\n"; + return head + std::string(total_len - head.size() - tail.size(), 'a') + tail; +} + +TEST_F(RTSPSessionTest, RequestOfBufferSizeMinusOneIsHandled) { + this->send(padded_request(REQUEST_BUF_SIZE - 1)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 9\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, RequestFillingTheBufferIsHandled) { + this->send(padded_request(REQUEST_BUF_SIZE)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 9\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, OversizedRequestClosesSession) { + this->send(std::string(REQUEST_BUF_SIZE + 1, 'a')); + this->loops(1); + EXPECT_FALSE(this->session_.is_active()); +} + +// Parses '$' channel length packets after the RTSP responses; false if the stream is not well framed. +static bool parse_interleaved(const std::string &stream, size_t start, std::vector &sequences) { + size_t pos = start; + while (pos < stream.size()) { + if (pos + 4 > stream.size() || stream[pos] != '$') + return false; + size_t len = (static_cast(stream[pos + 2]) << 8) | static_cast(stream[pos + 3]); + if (pos + 4 + len > stream.size() || len < 12) + return false; + sequences.push_back((static_cast(stream[pos + 6]) << 8) | static_cast(stream[pos + 7])); + pos += 4 + len; + } + return true; +} + +static JpegInfo stub_jpeg(StubImage &image) { + JpegInfo jpeg; + jpeg.qtable0 = image.get_data_buffer(); + jpeg.qtable1 = image.get_data_buffer() + QTABLE_LEN; + jpeg.scan_data = image.get_data_buffer() + 2 * QTABLE_LEN; + jpeg.scan_len = image.get_data_length() - 2 * QTABLE_LEN; + jpeg.width = 640; + jpeg.height = 480; + return jpeg; +} + +TEST_F(RTSPSessionTest, StalledWriterDropsFrame) { + this->reopen_over_tcp(); + this->start_playing(); + auto image = std::make_shared(8 * 1024 * 1024); + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(100); // the peer does not read, so the socket buffers fill up + ASSERT_TRUE(this->session_.has_frame()); + EXPECT_GT(image.use_count(), 1); + + this->now_ += FRAME_STALL_MS; + this->loops(1); + EXPECT_TRUE(this->session_.has_frame()) << "dropped before the stall limit"; + + this->now_ += 1; + this->loops(1); + EXPECT_FALSE(this->session_.has_frame()); + EXPECT_EQ(image.use_count(), 1); + EXPECT_TRUE(this->session_.is_active()); + + // Until the socket drains, new frames are refused so the camera can keep capturing for other consumers. + for (int i = 0; i < 5; i++) { + this->now_ += 100; + auto next = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(next, stub_jpeg(*next), this->now_); + this->loops(1); + EXPECT_FALSE(this->session_.has_frame()); + EXPECT_EQ(next.use_count(), 1); + } + + // The packet in flight is completed with zeros, so the interleaved stream stays well framed. + std::string stream; + for (int i = 0; i < 100; i++) { + stream += this->receive(); + this->loops(1); + } + stream += this->receive(); + std::vector sequences; + ASSERT_TRUE(parse_interleaved(stream, 0, sequences)); + ASSERT_FALSE(sequences.empty()); + + // Once drained, the next frame is taken and follows on in sequence. + auto next = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(next, stub_jpeg(*next), this->now_); + EXPECT_TRUE(this->session_.has_frame()); + this->loops(2); + std::vector after; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, after)); + ASSERT_EQ(after.size(), 1u); + EXPECT_EQ(after[0], static_cast(sequences.back() + 1)); + EXPECT_EQ(next.use_count(), 1); +} + +TEST_F(RTSPSessionTest, RepeatPlayContinuesSequence) { + this->start_playing(); + auto image = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(1); + std::vector sequences; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, sequences)); + ASSERT_EQ(sequences.size(), 1u); + + this->send(request("PLAY", 3)); + this->loops(1); + std::string reply = this->receive(); + ASSERT_NE(reply.find("CSeq: 3\r\n"), std::string::npos); + this->now_ += 100; + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(1); + std::vector after; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, after)); + ASSERT_EQ(after.size(), 1u); + EXPECT_EQ(after[0], static_cast(sequences[0] + 1)); +} + +// Minimal JPEG: SOI, DQT (two tables), optional DRI, SOF0, SOS, scan byte, EOI. +static std::vector make_jpeg(uint8_t components, uint8_t y_sampling, bool dri) { + std::vector j = {0xFF, 0xD8, 0xFF, 0xDB, 0x00, 2 + 2 * 65}; + for (uint8_t id = 0; id < 2; id++) { + j.push_back(id); + j.insert(j.end(), 64, 1); + } + if (dri) + j.insert(j.end(), {0xFF, 0xDD, 0x00, 0x04, 0x00, 0x10}); + j.insert(j.end(), + {0xFF, 0xC0, 0x00, static_cast(8 + 3 * components), 8, 0x01, 0xE0, 0x02, 0x80, components}); + for (uint8_t c = 0; c < components; c++) + j.insert(j.end(), {static_cast(c + 1), c == 0 ? y_sampling : static_cast(0x11), + static_cast(c == 0 ? 0 : 1)}); + j.insert(j.end(), {0xFF, 0xDA, 0x00, 0x02, 0x12, 0xFF, 0xD9}); + return j; +} + +TEST(RTSPParseJpeg, AcceptsBaselineColor) { + JpegInfo info; + auto j420 = make_jpeg(3, 0x22, false); + ASSERT_TRUE(parse_jpeg_info(j420.data(), j420.size(), info)); + EXPECT_EQ(info.type, 1); + EXPECT_EQ(info.width, 640); + EXPECT_EQ(info.height, 480); + EXPECT_EQ(info.scan_len, 1u); + JpegInfo info422; + auto j422 = make_jpeg(3, 0x21, false); + ASSERT_TRUE(parse_jpeg_info(j422.data(), j422.size(), info422)); + EXPECT_EQ(info422.type, 0); +} + +TEST(RTSPParseJpeg, RejectsUnsupportedFrames) { + JpegInfo info; + auto gray = make_jpeg(1, 0x11, false); + EXPECT_FALSE(parse_jpeg_info(gray.data(), gray.size(), info)); + auto j444 = make_jpeg(3, 0x11, false); + EXPECT_FALSE(parse_jpeg_info(j444.data(), j444.size(), info)); + auto restart = make_jpeg(3, 0x22, true); + EXPECT_FALSE(parse_jpeg_info(restart.data(), restart.size(), info)); +} + +} // namespace esphome::rtsp_server::testing + +#endif From 4ef17cebdb8c024ee27f73c4ac7f5c2b380675cd Mon Sep 17 00:00:00 2001 From: Hasan Khalil Date: Sat, 17 Oct 2026 14:01:01 -0400 Subject: [PATCH 10/50] [api] Pre-reserve Home Assistant state subscriptions vector (#20986) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/api/api_server.cpp | 3 + esphome/components/homeassistant/__init__.py | 31 ++++++++- .../homeassistant/button/__init__.py | 3 +- .../homeassistant/number/__init__.py | 3 +- .../homeassistant/select/__init__.py | 3 +- .../components/homeassistant/text/__init__.py | 3 +- esphome/core/defines.h | 1 + .../homeassistant/mixed_entities.yaml | 65 +++++++++++++++++++ .../test_state_subscription_count.py | 51 +++++++++++++++ 9 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/homeassistant/mixed_entities.yaml create mode 100644 tests/component_tests/homeassistant/test_state_subscription_count.py diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index a8d14256dcb..99ce39670f2 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -65,6 +65,9 @@ void APIServer::socket_failed_(const LogString *msg) { } void APIServer::setup() { +#if defined(USE_API_HOMEASSISTANT_STATES) && defined(HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT) + this->state_subs_.reserve(HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT); +#endif #ifdef USE_API_NOISE // Always reserve the slot: flash preferences are positional on esp8266, so // a yaml key build must keep the layout of a runtime key build diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 3851bc3c0bc..6f5360bfed1 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -1,8 +1,11 @@ from collections.abc import Callable, Iterable +from dataclasses import dataclass import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -59,7 +62,28 @@ def final_validate_entity_id(config: ConfigType) -> ConfigType: return config -def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: +@dataclass +class HomeAssistantData: + state_subscription_count: int = 0 + + +def _get_data() -> HomeAssistantData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = HomeAssistantData() + return CORE.data[DOMAIN] + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _emit_state_subscription_count_define() -> None: + """Emit the HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT define with the total count.""" + count = _get_data().state_subscription_count + if count > 0: + cg.add_define("HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT", count) + + +def setup_home_assistant_entity( + var: MockObj, config: ConfigType, state_subscription_count: int = 1 +) -> None: from esphome.components.api import wizard if (buffer := wizard.wizard_input_buffer(config[CONF_ID])) is not None: @@ -70,3 +94,8 @@ def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) cg.add_define("USE_API_HOMEASSISTANT_STATES") + if state_subscription_count > 0: + data = _get_data() + if data.state_subscription_count == 0: + CORE.add_job(_emit_state_subscription_count_define) + data.state_subscription_count += state_subscription_count diff --git a/esphome/components/homeassistant/button/__init__.py b/esphome/components/homeassistant/button/__init__.py index f486a841263..7b4ad004e18 100644 --- a/esphome/components/homeassistant/button/__init__.py +++ b/esphome/components/homeassistant/button/__init__.py @@ -37,4 +37,5 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await button.new_button(config) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # Button only calls services, does not subscribe to state + setup_home_assistant_entity(var, config, state_subscription_count=0) diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index 6288db68805..658cb8762c9 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -39,4 +39,5 @@ async def to_code(config: ConfigType) -> None: step=0, ) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + min/max/step fetches in HomeassistantNumber::setup() + setup_home_assistant_entity(var, config, state_subscription_count=4) diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py index 1bdf0f2cade..e6a129cda0d 100644 --- a/esphome/components/homeassistant/select/__init__.py +++ b/esphome/components/homeassistant/select/__init__.py @@ -55,4 +55,5 @@ async def to_code(config: ConfigType) -> None: options=[], ) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + options fetch in HomeassistantSelect::setup() + setup_home_assistant_entity(var, config, state_subscription_count=2) diff --git a/esphome/components/homeassistant/text/__init__.py b/esphome/components/homeassistant/text/__init__.py index 67377e02d05..eb8613f76d5 100644 --- a/esphome/components/homeassistant/text/__init__.py +++ b/esphome/components/homeassistant/text/__init__.py @@ -37,4 +37,5 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await text.new_text(config) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + min/max/mode fetches in HomeassistantText::setup() + setup_home_assistant_entity(var, config, state_subscription_count=4) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index dbd6c8d8538..de4dfd2607b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -319,6 +319,7 @@ #define USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON #define USE_API_HOMEASSISTANT_SERVICES #define USE_API_HOMEASSISTANT_STATES +#define HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT 1 #define USE_API_NOISE #define USE_API_OUTGOING_CONNECTION #define API_OUTGOING_CONNECTION_PORT 6054 diff --git a/tests/component_tests/homeassistant/mixed_entities.yaml b/tests/component_tests/homeassistant/mixed_entities.yaml new file mode 100644 index 00000000000..8c96e1ad521 --- /dev/null +++ b/tests/component_tests/homeassistant/mixed_entities.yaml @@ -0,0 +1,65 @@ +esphome: + name: test-mixed-ha + +esp32: + variant: esp32 + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + +sensor: + - platform: homeassistant + id: ha_sensor_1 + name: HA Sensor 1 + entity_id: sensor.temp_1 + - platform: homeassistant + id: ha_sensor_2 + name: HA Sensor 2 + entity_id: sensor.temp_2 + +binary_sensor: + - platform: homeassistant + id: ha_binary_sensor + name: HA Binary Sensor + entity_id: binary_sensor.motion + +text_sensor: + - platform: homeassistant + id: ha_text_sensor + name: HA Text Sensor + entity_id: sensor.status_text + +switch: + - platform: homeassistant + id: ha_switch + name: HA Switch + entity_id: switch.relay + +select: + - platform: homeassistant + id: ha_select + name: HA Select + entity_id: select.mode + +number: + - platform: homeassistant + id: ha_number + name: HA Number + entity_id: number.target_temp + +text: + - platform: homeassistant + id: ha_text + name: HA Text + entity_id: text.custom_note + +button: + - platform: homeassistant + id: ha_button + name: HA Button + entity_id: button.restart diff --git a/tests/component_tests/homeassistant/test_state_subscription_count.py b/tests/component_tests/homeassistant/test_state_subscription_count.py new file mode 100644 index 00000000000..c3832369117 --- /dev/null +++ b/tests/component_tests/homeassistant/test_state_subscription_count.py @@ -0,0 +1,51 @@ +"""Tests for Home Assistant state subscription count calculation and pre-reservation.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +DIR = Path(__file__).parent + + +def test_mixed_entities_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with mixed entities computes the exact weighted subscription count.""" + generate_main(DIR / "mixed_entities.yaml") + + # sensor (2*1) + binary_sensor (1) + text_sensor (1) + switch (1) + # + select (2) + number (4) + text (4) + button (0) = 15 + define_match = [ + d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" + ] + assert len(define_match) == 1 + assert str(define_match[0].value) == "15" + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) + + +def test_button_only_no_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with only button entities has no state subscriptions.""" + generate_main(DIR / "button_valid.yaml") + + assert not any( + d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" for d in CORE.defines + ) + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) + assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines) + + +def test_select_only_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with 2 select entities calculates 2 * 2 = 4 subscriptions.""" + generate_main(DIR / "select_valid.yaml") + + define_match = [ + d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" + ] + assert len(define_match) == 1 + assert str(define_match[0].value) == "4" + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) From 4f192130f80ccb70813891d99d0a771c5f063fa0 Mon Sep 17 00:00:00 2001 From: Douwe <61123717+dhoeben@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:51:06 +0200 Subject: [PATCH 11/50] [water_heater] Hand the component to the core team (#20999) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- esphome/components/water_heater/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f23f3613032..65ed44ee30b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -688,7 +688,7 @@ esphome/components/version/* @esphome/core esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher -esphome/components/water_heater/* @dhoeben +esphome/components/water_heater/* @esphome/core esphome/components/waveshare_epaper/* @clydebarrow esphome/components/waveshare_io_ch32v003/* @latonita esphome/components/web_server/ota/* @esphome/core diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index a3a7f047069..9277f0ecbf6 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -17,7 +17,7 @@ from esphome.core.entity_helpers import ( from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType -CODEOWNERS = ["@dhoeben"] +CODEOWNERS = ["@esphome/core"] DOMAIN = "water_heater" IS_PLATFORM_COMPONENT = True From 37d6140193c7b215852834b54d05a50f0fe60d8f Mon Sep 17 00:00:00 2001 From: Dane Powell Date: Sat, 17 Oct 2026 11:55:37 -0700 Subject: [PATCH 12/50] [epaper_spi] Fix partial refresh and port waveshare 2.90inv2 (#18959) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: luar123 <49960470+luar123@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/epaper_spi/epaper_spi.cpp | 4 +- esphome/components/epaper_spi/epaper_spi.h | 2 + .../components/epaper_spi/epaper_spi_mono.cpp | 2 +- .../components/epaper_spi/epaper_spi_mono.h | 2 + .../epaper_spi/epaper_waveshare.cpp | 15 ++- .../components/epaper_spi/epaper_waveshare.h | 26 +++- .../components/epaper_spi/models/waveshare.py | 125 +++++++++++++++--- .../epaper_spi/test.esp32-s3-idf.yaml | 17 +++ 8 files changed, 167 insertions(+), 26 deletions(-) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index a0c90a0b686..e95d2fd2869 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -90,7 +90,9 @@ bool EPaperBase::is_idle_() const { return !this->busy_pin_->digital_read(); } -bool EPaperBase::reset() { +bool EPaperBase::reset() { return this->hardware_reset_(); } + +bool EPaperBase::hardware_reset_() { if (this->reset_pin_ != nullptr) { if (this->state_ == EPaperState::RESET) { this->reset_pin_->digital_write(false); diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index dccff3cbef1..046df97cccd 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -129,6 +129,8 @@ class EPaperBase : public Display, bool is_idle_() const; void setup_pins_() const; virtual bool reset(); + /// The reset pin part of reset(), for a driver that must skip what its parent adds to it + bool hardware_reset_(); virtual bool initialise(bool partial); void send_init_sequence_(const uint8_t *sequence, size_t length); void wait_for_idle_(bool should_wait); diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index b6653038c33..a00104edeae 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -68,7 +68,7 @@ bool HOT EPaperMono::transfer_data() { while (this->current_data_index_ != this->y_high_) { size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_ / 8; for (size_t i = 0; i != row_length; i++) { - bytes_to_send[i] = this->send_red_ ? 0 : this->buffer_[data_idx++]; + bytes_to_send[i] = this->send_red_ && !this->send_red_as_image_ ? 0 : this->buffer_[data_idx++]; } ++this->current_data_index_; this->write_array(&bytes_to_send.front(), row_length); // NOLINT diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index 4dc54ae60ba..c7de7a309af 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -21,6 +21,8 @@ class EPaperMono : public EPaperBase { virtual void set_window(); bool transfer_data() override; bool send_red_{true}; + // When set, the red RAM pass writes the image rather than blank data. + bool send_red_as_image_{false}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare.cpp b/esphome/components/epaper_spi/epaper_waveshare.cpp index d4c7ba5fdb5..17808001aa6 100644 --- a/esphome/components/epaper_spi/epaper_waveshare.cpp +++ b/esphome/components/epaper_spi/epaper_waveshare.cpp @@ -8,15 +8,19 @@ bool EpaperWaveshare::initialise(bool partial) { EPaperBase::initialise(partial); if (partial) { this->cmd_data(0x32, this->partial_lut_, this->partial_lut_length_); + this->send_init_sequence_(this->partial_sequence_, this->partial_sequence_length_); this->cmd_data(0x3C, {0x80}); this->cmd_data(0x22, {0xC0}); this->command(0x20); this->next_delay_ = 100; } else { this->cmd_data(0x32, this->lut_, this->lut_length_); + this->send_init_sequence_(this->full_sequence_, this->full_sequence_length_); this->cmd_data(0x3C, {0x05}); } - this->send_red_ = true; + // The previous image must stay in the base RAM for partial updates to compare against, + // so models needing it write the base RAM on full updates only. + this->send_red_ = !(this->base_image_required_ && partial); return true; } @@ -45,4 +49,13 @@ void EpaperWaveshare::refresh_screen(bool partial) { } void EpaperWaveshare::deep_sleep() { this->cmd_data(0x10, {0x01}); } + +bool EpaperWaveshare::reset() { + // Skip the software reset (0x12) EPaperMono adds: it restarts the alternation between the two RAM + // banks, so a partial update would compare against the wrong bank. The RST pulse only wakes the + // controller from sleep. + if (this->must_keep_base_image_()) + return this->hardware_reset_(); + return EPaperMono::reset(); +} } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare.h b/esphome/components/epaper_spi/epaper_waveshare.h index d3ad313d925..b12aeb93822 100644 --- a/esphome/components/epaper_spi/epaper_waveshare.h +++ b/esphome/components/epaper_spi/epaper_waveshare.h @@ -10,21 +10,39 @@ class EpaperWaveshare final : public EPaperMono { public: EpaperWaveshare(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length, const uint8_t *lut, size_t lut_length, const uint8_t *partial_lut, - uint16_t partial_lut_length) + uint16_t partial_lut_length, const uint8_t *full_sequence = nullptr, size_t full_sequence_length = 0, + const uint8_t *partial_sequence = nullptr, size_t partial_sequence_length = 0, + bool base_image_required = false) : EPaperMono(name, width, height, init_sequence, init_sequence_length), lut_(lut), - lut_length_(lut_length), partial_lut_(partial_lut), - partial_lut_length_(partial_lut_length) {} + full_sequence_(full_sequence), + partial_sequence_(partial_sequence), + lut_length_(lut_length), + full_sequence_length_(full_sequence_length), + partial_sequence_length_(partial_sequence_length), + partial_lut_length_(partial_lut_length), + base_image_required_(base_image_required) { + this->send_red_as_image_ = base_image_required; + } protected: bool initialise(bool partial) override; void set_window() override; void refresh_screen(bool partial) override; void deep_sleep() override; + bool reset() override; + // True while the base image in the controller RAM must be preserved for the next update. + bool must_keep_base_image_() const { return this->base_image_required_ && this->update_count_ != 0; } const uint8_t *lut_; - size_t lut_length_; const uint8_t *partial_lut_; + // Commands sent after the LUT, e.g. voltage settings that differ between full and partial updates. + const uint8_t *full_sequence_; + const uint8_t *partial_sequence_; + size_t lut_length_; + size_t full_sequence_length_; + size_t partial_sequence_length_; uint16_t partial_lut_length_; + bool base_image_required_; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/waveshare.py b/esphome/components/epaper_spi/models/waveshare.py index aecda723643..7594c9d0d0d 100644 --- a/esphome/components/epaper_spi/models/waveshare.py +++ b/esphome/components/epaper_spi/models/waveshare.py @@ -1,5 +1,10 @@ +from collections.abc import Sequence +from typing import Any + import esphome.codegen as cg +from esphome.components.mipi import flatten_sequence from esphome.core import ID +from esphome.types import ConfigType from ..display import CONF_INIT_SEQUENCE_ID from . import EpaperModel @@ -7,34 +12,49 @@ from . import EpaperModel class WaveshareModel(EpaperModel): def __init__( - self, name, lut, lut_partial=None, class_name="EpaperWaveshare", **defaults - ): + self, + name: str, + lut: tuple[int, ...], + lut_partial: tuple[int, ...] | None = None, + sequence_full: tuple[tuple[int, ...], ...] = (), + sequence_partial: tuple[tuple[int, ...], ...] = (), + base_image_required: bool = False, + class_name: str = "EpaperWaveshare", + **defaults: Any, + ) -> None: # A partial LUT is what lets EpaperWaveshare do partial refresh defaults.setdefault("partial_update", lut_partial is not None) super().__init__(name, class_name=class_name, **defaults) self.lut = lut self.lut_partial = lut_partial + self.sequence_full = sequence_full + self.sequence_partial = sequence_partial + self.base_image_required = base_image_required - def get_constructor_args(self, config) -> tuple: - lut = ( + def _array_arg( + self, config: ConfigType, suffix: str, data: Sequence[int] | None + ) -> tuple: + if not data: + return cg.nullptr, 0 + return ( cg.static_const_array( - ID(config[CONF_INIT_SEQUENCE_ID].id + "_lut", type=cg.uint8), self.lut + ID(config[CONF_INIT_SEQUENCE_ID].id + suffix, type=cg.uint8), data ), - len(self.lut), + len(data), + ) + + def get_constructor_args(self, config: ConfigType) -> tuple: + return ( + *self._array_arg(config, "_lut", self.lut), + *self._array_arg(config, "_lut_partial", self.lut_partial), + *self._array_arg( + config, "_sequence_full", flatten_sequence(self.sequence_full) + ), + *self._array_arg( + config, "_sequence_partial", flatten_sequence(self.sequence_partial) + ), + self.base_image_required, ) - if self.lut_partial is None: - lut_partial = cg.nullptr, 0 - else: - lut_partial = ( - cg.static_const_array( - ID( - config[CONF_INIT_SEQUENCE_ID].id + "_lut_partial", type=cg.uint8 - ), - self.lut_partial, - ), - len(self.lut_partial), - ) - return *lut, *lut_partial # fmt: off @@ -90,3 +110,70 @@ WaveshareModel( 0x0, 0x0, 0x0, ), ) + +WaveshareModel( + "waveshare-2.9in-v2", + width=128, + height=296, + initsequence=( + (0x01, 0x27, 0x01, 0x00), # driver output control + (0x0C, 0xD7, 0xD6, 0x9D), # booster soft start control + (0x3A, 0x1A), # dummy line period + (0x3B, 0x08), # gate line width + (0x11, 0x03), # data entry mode + (0x21, 0x00, 0x80), # display update control + (0x18, 0x80), # select internal temperature sensor + ), + # The last 6 bytes of the LUTs in the Waveshare sample code are voltage settings + # rather than waveform data, and are sent as separate commands below. + lut=( + 0x80, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, + 0x00, 0x00, 0x10, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x80, 0x66, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x10, 0x66, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x14, 0x08, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0A, 0x0A, 0x00, + 0x0A, 0x0A, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x08, 0x00, 0x01, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, + 0x00, 0x00, 0x00, + ), + lut_partial=( + 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x00, 0x00, 0x00, + ), + sequence_full=( + (0x3F, 0x22), # option for LUT end + (0x03, 0x17), # gate voltage + (0x04, 0x41, 0x00, 0x32), # source voltage: VSH1, VSH2, VSL + (0x2C, 0x36), # VCOM + ), + sequence_partial=( + (0x3F, 0x22), # option for LUT end + (0x03, 0x17), # gate voltage + (0x04, 0x41, 0xB0, 0x32), # source voltage: VSH1, VSH2, VSL + (0x2C, 0x36), # VCOM + (0x37, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00), + ), + base_image_required=True, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index f6091e5715c..6a487064cbc 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -70,6 +70,23 @@ display: allow_other_uses: true number: GPIO4 + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.9in-v2 + full_update_every: 10 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + - platform: epaper_spi spi_id: spi_bus model: waveshare-7.5in-H From 4324b7d084c69d3f6c4657ee3779217e8ee3ce67 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:58:49 +0200 Subject: [PATCH 13/50] [zigbee] bump esp-zigbee-sdk to 2.0.5 (#20998) --- esphome/components/zigbee/zigbee_esp32.cpp | 26 ----------------- esphome/components/zigbee/zigbee_esp32.h | 33 +--------------------- esphome/components/zigbee/zigbee_esp32.py | 12 ++------ esphome/idf_component.yml | 2 +- tests/component_tests/esp32/test_esp32.py | 16 ++++++----- 5 files changed, 14 insertions(+), 75 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 29bbdc2b0b5..35f12b3a4e3 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -388,15 +388,6 @@ void ZigbeeComponent::setup() { }; ezb_af_set_node_power_desc(&desc); - // Finish zigbee data model - for (auto &attr_value : this->attr_values_) { - ezb_zcl_attr_desc_t attr_desc = attr_value.attr_desc; - void *value_p = &attr_value.value; - ezb_zcl_attr_desc_set_value(attr_desc, value_p); - } - // free memory - std::vector().swap(this->attr_values_); - // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network @@ -470,23 +461,6 @@ void ZigbeeComponent::dump_config() { esp_zigbee_lock_release(); } } - -bool ZigbeeComponent::string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id) { - ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); - if (ep_desc == NULL) { - return false; - } - ezb_zcl_cluster_desc_t cluster_desc = ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role); - if (cluster_desc == NULL) { - return false; - } - if (ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE) == NULL) { - return false; - } - ESP_LOGW(TAG, "Attribute 0x%04X already exists in endpoint %u cluster 0x%04X. Can't add new value", attr_id, - endpoint_id, cluster_id); - return true; -} } // namespace esphome::zigbee #endif diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index d5ed7439bd2..93a4f1ba780 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -29,11 +29,6 @@ static constexpr uint8_t MAX_ZB_QUEUE_SIZE = 32; uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); -struct AttrValue { - ezb_zcl_attr_desc_t attr_desc; - uint8_t value[4]; -}; - class ZigbeeAttribute; class ZigbeeComponent final : public Component { @@ -101,7 +96,6 @@ class ZigbeeComponent final : public Component { // automations // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; - std::vector attr_values_; ezb_af_device_desc_t dev_desc_ = ezb_af_create_device_desc(); CallbackManager join_cb_{}; template friend void enqueue_zb_event(Args... args); @@ -119,8 +113,6 @@ class ZigbeeComponent final : public Component { std::atomic joined_ = false; std::atomic join_pending_ = false; std::atomic factory_new_ = false; - // TODO: remove when esp-zigbee-lib fixes set_value before init - bool string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id); }; template @@ -135,22 +127,10 @@ void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint1 // The size byte of the zcl_str must be set to the maximum value, // even though the initial string may be shorter. if constexpr (std::is_same::value) { - if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { - if (attr != nullptr) { - this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; - } - return; - } auto zcl_str = get_zcl_string(value.c_str(), max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; } else if constexpr (std::is_convertible::value) { - if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { - if (attr != nullptr) { - this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; - } - return; - } auto zcl_str = get_zcl_string(value, max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; @@ -170,18 +150,7 @@ void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint if (cluster_desc == NULL) { return; } - // TODO: revert when esp-zigbee-lib fixes set_value before init - ezb_zcl_attr_desc_t attr_desc = ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE); - - if (attr_desc != NULL) { - static_assert(sizeof(*value_p) <= 4); - AttrValue attr_value; - attr_value.attr_desc = attr_desc; - memcpy(&attr_value.value, value_p, sizeof(*value_p)); - attr_values_.push_back(attr_value); - } else { - esphome_zb_cluster_add_attr(cluster_id, cluster_desc, attr_id, value_p); - } + esphome_zb_cluster_add_or_update_attr(cluster_id, cluster_desc, attr_id, value_p); if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 99f075ecb2f..30c94cde6cb 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -13,7 +13,6 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, add_partition, include_builtin_idf_component, - require_mbedtls_ecp, require_mbedtls_tls_extras, require_vfs_select, ) @@ -395,17 +394,12 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.4", + ref="2.0.5", ) - # The esp-zigbee-lib blobs reference mbedtls_ecp_* (Zigbee Direct, install - # code ECDH); keep ECP without relying on esp_wifi's Kconfig select. - require_mbedtls_ecp() - # Zigbee's crypto platform uses AES-CCM and deterministic ECDSA directly. + # Zigbee's crypto platform uses AES-CCM directly. # Keep the esp32 component from trimming them out of mbedTLS. - require_mbedtls_tls_extras( - ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") - ) + require_mbedtls_tls_extras({"CONFIG_MBEDTLS_CCM_C"}) if CONF_WIFI in CORE.config: # zigbee_esp32.cpp uses esp_coexist.h when WiFi is present diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5ed84fa2a3a..dd837d61632 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -54,7 +54,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.4 + version: 2.0.5 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 3cde877f7ad..86b27776dc6 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -1016,8 +1016,8 @@ def _tls_compiled_out(sdkconfig: dict[str, Any]) -> bool: "exclusion_reincludes_nextion.yaml", False, False, False, id="nextion" ), pytest.param("mbedtls_tls_wifi_eap.yaml", False, False, True, id="wifi_eap"), - # zigbee requests ECP for the esp-zigbee-lib blobs, without TLS. - pytest.param("tls_zigbee_c6.yaml", True, False, True, id="zigbee"), + # zigbee needs neither TLS nor ECP + pytest.param("tls_zigbee_c6.yaml", True, True, True, id="zigbee"), # A raw bundle keeps the TLS role but no longer compiles esp-tls. pytest.param( "certificate_bundle_sdkconfig.yaml", False, False, True, id="raw_bundle" @@ -2188,19 +2188,21 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses( assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) +_CCM_EXTRAS = {"CONFIG_MBEDTLS_CCM_C"} + + def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: """Nothing in the Zigbee config links TLS, so the stack is compiled out and - no role is written; the extras trim still runs and Zigbee keeps CCM and - deterministic ECDSA.""" + no role is written; the extras trim still runs and Zigbee keeps CCM.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert _tls_compiled_out(sdkconfig) assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: - assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) + assert sdkconfig.get(name) is (None if name in _CCM_EXTRAS else False) def test_mbedtls_tls_opt_out_keeps_stack_and_trims_role( @@ -2252,10 +2254,10 @@ def test_mbedtls_tls_zigbee_requires_extras( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The Zigbee hooks mark the CCM/deterministic ECDSA as required.""" + """The Zigbee hooks mark the CCM as required.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] - assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS + assert mbedtls.tls_extras_required == _CCM_EXTRAS _VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} From e110613ef1120a20b0a6d730f7c4c6bbf96de709 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:00:37 +0200 Subject: [PATCH 14/50] [core] Construct App once in the C++ unit test main (#21001) Co-authored-by: J. Nick Koston --- tests/components/daikin/daikin_regression_tests.cpp | 3 --- tests/components/daikin/daikin_tests.cpp | 4 ---- tests/components/display/test_test_card.cpp | 5 ----- tests/components/emontx/common.h | 6 ------ tests/components/emontx/emontx_test.cpp | 1 - tests/components/emontx/text_sensor/emontx_version_test.cpp | 1 - tests/components/filter_lifetime/test_filter_lifetime.cpp | 5 ++++- .../haier_water_heater/water_heater/test_hon_controller.cpp | 4 ---- tests/components/i2c/test_i2c_chip_id.cpp | 4 +++- tests/components/main.cpp | 5 +++++ tests/components/mistral_ir/mistral_ir_tests.cpp | 3 --- tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp | 3 --- tests/components/snapshot/snapshot_test.cpp | 3 --- tests/components/tmp102/test_tmp102.cpp | 5 ++++- tests/components/xiaomi_body_scale/common.h | 3 --- 15 files changed, 16 insertions(+), 39 deletions(-) diff --git a/tests/components/daikin/daikin_regression_tests.cpp b/tests/components/daikin/daikin_regression_tests.cpp index fc642a84e9d..6bb41d30f49 100644 --- a/tests/components/daikin/daikin_regression_tests.cpp +++ b/tests/components/daikin/daikin_regression_tests.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include "esphome/components/daikin/daikin.h" @@ -124,8 +123,6 @@ struct Rig { // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_daikin_regression"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/daikin/daikin_tests.cpp b/tests/components/daikin/daikin_tests.cpp index 0664a82571c..3cea96cce4b 100644 --- a/tests/components/daikin/daikin_tests.cpp +++ b/tests/components/daikin/daikin_tests.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include @@ -146,9 +145,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved if (!keep_preferences && global_preferences != nullptr) global_preferences->reset(); - // The test main does not construct App as generated code does; the boost timer needs its scheduler - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_daikin"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/display/test_test_card.cpp b/tests/components/display/test_test_card.cpp index 555611bc093..a97301ac1e9 100644 --- a/tests/components/display/test_test_card.cpp +++ b/tests/components/display/test_test_card.cpp @@ -31,11 +31,6 @@ class TestCardDisplay : public Display { namespace { class TestCard : public ::testing::Test { protected: - void SetUp() override { - // test_card() stops the poller, which needs the scheduler; the test main does not construct App. - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; - } int writer_calls_{0}; display_writer_t count_writer_() { return [this](Display &) { this->writer_calls_++; }; diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h index 678d582f62f..932fac91cd2 100644 --- a/tests/components/emontx/common.h +++ b/tests/components/emontx/common.h @@ -44,12 +44,6 @@ 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); diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 71bb6c70d36..227fa91a599 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -14,7 +14,6 @@ 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_++; }); diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp index 34f6315f3ab..fa51db63e5f 100644 --- a/tests/components/emontx/text_sensor/emontx_version_test.cpp +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -12,7 +12,6 @@ namespace { class EmonTxVersionTest : public ::testing::Test { protected: void SetUp() override { - construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.set_version_text_sensor(&this->version_); } diff --git a/tests/components/filter_lifetime/test_filter_lifetime.cpp b/tests/components/filter_lifetime/test_filter_lifetime.cpp index a4c89edd856..4f33afa24ef 100644 --- a/tests/components/filter_lifetime/test_filter_lifetime.cpp +++ b/tests/components/filter_lifetime/test_filter_lifetime.cpp @@ -39,7 +39,9 @@ class FilterLifetimeTest : public ::testing::Test { unsigned publications_{0}; void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21); host::setup_preferences(); host::host_preferences->reset(); @@ -55,6 +57,7 @@ class FilterLifetimeTest : public ::testing::Test { void TearDown() override { host::host_preferences->reset(); App.~Application(); + new (&App) Application(); // later tests and the test listener use App } void attach(FilterLifetime &filter) { diff --git a/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp index 41b40687d48..0de87d7a7da 100644 --- a/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp +++ b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include "esphome/components/haier_water_heater/hon_water_heater.h" @@ -53,9 +52,6 @@ class TestableHeater : public HonWaterHeater { class HonControllerTest : public ::testing::Test { protected: void SetUp() override { - // The test runner skips generated setup(), which normally constructs App and its scheduler. - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; this->heater_.set_uart_parent(&this->uart_); this->heater_.set_network_pending(false); this->set_clock_(100); diff --git a/tests/components/i2c/test_i2c_chip_id.cpp b/tests/components/i2c/test_i2c_chip_id.cpp index c3e3fdd4907..a3b567d917d 100644 --- a/tests/components/i2c/test_i2c_chip_id.cpp +++ b/tests/components/i2c/test_i2c_chip_id.cpp @@ -33,7 +33,9 @@ class ChipDevice : public Component, public I2CDevice {}; class I2CChipIdTest : public ::testing::Test { protected: void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); static char name[] = "i2c-chip-id-tests"; static char friendly_name[] = "I2C chip ID tests"; App.pre_setup(name, sizeof(name) - 1, friendly_name, sizeof(friendly_name) - 1); diff --git a/tests/components/main.cpp b/tests/components/main.cpp index 4020dd9a505..cbf19f31ba4 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -32,6 +32,11 @@ class ResetAppListener : public ::testing::EmptyTestEventListener { }; void setup() { + // Construct App once, as generated code does, before the logger, the listener or any test touches it. + // A fixture that destroys App must construct it again before returning. + // Placement new needs , which the generated include block already provides + new (&esphome::App) esphome::Application; + // Log functions call global_logger->log_vprintf_() without a null check, // so we must set up a Logger before any test that triggers logging. static esphome::logger::Logger test_logger(0); diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp index 9b489154d02..b7260f95d88 100644 --- a/tests/components/mistral_ir/mistral_ir_tests.cpp +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -84,9 +84,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_mistral_ir"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp index 293c363d5d6..76ffd5a70d4 100644 --- a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp +++ b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp @@ -72,9 +72,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_pd_pioneer_ir"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/snapshot/snapshot_test.cpp b/tests/components/snapshot/snapshot_test.cpp index d63101357a2..92200814181 100644 --- a/tests/components/snapshot/snapshot_test.cpp +++ b/tests/components/snapshot/snapshot_test.cpp @@ -46,9 +46,6 @@ class SnapshotAnimationTest : public ::testing::Test { fs::remove_all(this->dir_); fs::create_directories(this->dir_); setenv("ESPHOME_SNAPSHOT_DIR", this->dir_.c_str(), 1); - // The test main does not construct App as generated code does, and recording needs its scheduler. - static const bool app_constructed = (new (&App) Application(), true); - (void) app_constructed; App.pre_setup("test_snapshot", 10, "", 0); } void TearDown() override { diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index a5b65b060d4..081dfba8d2c 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -71,7 +71,9 @@ class TMP102Test : public ::testing::Test { unsigned publications_{0}; void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); App.pre_setup("tmp102-regression-tests", 23, "TMP102 tests", 12); host::setup_preferences(); host::host_preferences->reset(); @@ -93,6 +95,7 @@ class TMP102Test : public ::testing::Test { App.scheduler.call(millis()); host::host_preferences->reset(); App.~Application(); + new (&App) Application(); // later tests and the test listener use App } static void run_timers(uint32_t duration) { diff --git a/tests/components/xiaomi_body_scale/common.h b/tests/components/xiaomi_body_scale/common.h index 67f1eaae91a..e21b014efff 100644 --- a/tests/components/xiaomi_body_scale/common.h +++ b/tests/components/xiaomi_body_scale/common.h @@ -52,9 +52,6 @@ inline ble_device_base::ESPBTDevice advert(uint64_t address, const Frame &frame) struct Harness { Harness(uint64_t address, const char *key) : scale(address, key) { - // The test main does not construct App as generated code does; the stabilized reset needs its scheduler - static const bool app_constructed = (new (&App) Application(), true); - (void) app_constructed; App.pre_setup("test_scale", 10, "", 0); this->scale.set_weight(&this->weight); this->scale.set_impedance_low(&this->impedance_low); From 5c2b05d0eb5fe08cb1cbb4b7d133e30308c49835 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 09:00:51 -1000 Subject: [PATCH 15/50] [ci] Run the C++ unit tests on macOS without a sanitizer whose runtime aborts (#21000) --- script/cpp_unit_test.py | 57 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index c5179a1b1e9..c2b34e1b409 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -3,7 +3,9 @@ import argparse from functools import partial import os from pathlib import Path +import subprocess import sys +import tempfile from build_helpers import ( PLATFORMIO_GOOGLE_TEST_LIB, @@ -12,6 +14,9 @@ from build_helpers import ( ) from helpers import get_all_components, root_path +from esphome.core import EsphomeError +from esphome.host.toolchain import find_compilers + # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" @@ -20,11 +25,55 @@ BUILD_FLAGS = [ "-g3", # max debug info "-ggdb3", "-DESPHOME_DEBUG", # enable debug assertions - # Enable the address and undefined behavior sanitizers - "-fsanitize=address", - "-fsanitize=undefined", "-fno-omit-frame-pointer", ] +SANITIZER_FLAGS = ["-fsanitize=address", "-fsanitize=undefined"] + + +def sanitizer_flags() -> list[str]: + """The sanitizer flags to build with: all of them, except on macOS the ones whose runtime + does not start. Apple clang 15's AddressSanitizer aborts before main() on macOS 26, which + would fail every test for a toolchain problem, so each flag is tried on an empty program + first. A compiler that cannot build the empty program is an error, not a dropped flag. + CI keeps every flag. + """ + if sys.platform != "darwin": + return SANITIZER_FLAGS + try: + cxx = find_compilers().cxx + except EsphomeError as err: + sys.exit(str(err)) + flags: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + program = Path(tmp) / "probe" + for flag in SANITIZER_FLAGS: + compiled = subprocess.run( + [*cxx, flag, "-x", "c++", "-", "-o", program], + check=False, + input=b"int main() { return 0; }", + capture_output=True, + ) + if compiled.returncode != 0: + sys.exit( + f"{cxx[0]} cannot build an empty program with {flag}:\n" + f"{compiled.stderr.decode(errors='replace').strip()}" + ) + ran = subprocess.run([program], check=False, capture_output=True) + if ran.returncode == 0: + flags.append(flag) + continue + lines = [ + line + for line in ran.stderr.decode(errors="replace").splitlines() + if line.strip() + ] + print( + f"{flag} aborts at start with {cxx[0]}: " + f"{lines[0] if lines else f'exit code {ran.returncode}'}\n" + " building the tests without it; set CXX to a compiler whose runtime works to keep it", + file=sys.stderr, + ) + return flags def run_tests(selected_components: list[str]) -> int: @@ -39,7 +88,7 @@ def run_tests(selected_components: list[str]) -> int: config_prefix="cpptests", friendly_name="CPP Unit Tests", libraries=PLATFORMIO_GOOGLE_TEST_LIB, - build_flags=BUILD_FLAGS, + build_flags=BUILD_FLAGS + sanitizer_flags(), main_entry="main.cpp", label="unit tests", ) From c2c8cb5eb37eb244a82d65cfb319a92fd44756fa Mon Sep 17 00:00:00 2001 From: Matias Fernandez <7898112+MatiasFernandez@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:12:35 -0300 Subject: [PATCH 16/50] [climate_ir_lg] Fix swing command to not include climate state (#17417) Co-authored-by: J. Nick Koston --- .../climate_ir_lg/climate_ir_lg.cpp | 8 +- .../climate/lg_ir_climate_tests.cpp | 90 +++++++++++++++++++ tests/components/climate_ir_lg/common.h | 33 +++++++ 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp create mode 100644 tests/components/climate_ir_lg/common.h diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.cpp b/esphome/components/climate_ir_lg/climate_ir_lg.cpp index 042d1dcb21f..16ed9831fd7 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.cpp +++ b/esphome/components/climate_ir_lg/climate_ir_lg.cpp @@ -135,13 +135,14 @@ void LgIrClimate::transmit_state() { default: return; } - this->transmit_(remote_state); - this->publish_state(); - return; } else { // just toggle swing when advanced_commands_support is not set remote_state |= HEADER_BASIC; remote_state |= BASIC_SWING_TOGGLE; } + // The remote's swing button sends a fixed code with no fan speed or temperature bits, so the + // frame goes out as is instead of falling through to them + this->transmit_(remote_state); + return; } else { // Mode commands const bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF); switch (this->mode) { @@ -208,7 +209,6 @@ void LgIrClimate::transmit_state() { } this->transmit_(remote_state); - this->publish_state(); } bool LgIrClimate::on_receive(remote_base::RemoteReceiveData data) { diff --git a/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp b/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp new file mode 100644 index 00000000000..c8c8e79db65 --- /dev/null +++ b/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp @@ -0,0 +1,90 @@ +#include +#include "../common.h" + +namespace esphome::climate_ir_lg::testing { + +namespace { + +constexpr uint32_t BIT_ONE_LOW = 1600; +// A complete LG frame is header (2) + 28 bit pairs (56) + trailing mark (1) +constexpr size_t FRAME_SIZE = 2 + 28 * 2 + 1; + +// A climate with timings set, so transmit_() produces a frame decode_lg_frame() can read back +void configure(LgIrClimate &sut, MockRemoteTransmitter &transmitter) { + sut.set_transmitter(&transmitter); + sut.set_header_high(8000); + sut.set_header_low(4000); + sut.set_bit_high(600); + sut.set_bit_one_low(BIT_ONE_LOW); + sut.set_bit_zero_low(550); +} + +// COOL at 20 °C with the fan on HIGH: a distinctive state the swing tests must not leak into their frame +void turn_on(LgIrClimate &sut) { + climate::ClimateCall on_call(&sut); + on_call.set_mode(climate::CLIMATE_MODE_COOL); + on_call.set_target_temperature(20); + on_call.set_fan_mode(climate::CLIMATE_FAN_HIGH); + sut.control(on_call); +} + +uint32_t last_frame(const MockRemoteTransmitter &transmitter) { + // Guard against out-of-bounds reads in decode_lg_frame() if the transmission failed + if (transmitter.last_data.size() < FRAME_SIZE) { + ADD_FAILURE() << "no complete frame was transmitted"; + return 0; + } + return decode_lg_frame(transmitter.last_data, BIT_ONE_LOW); +} + +} // namespace + +// A mode command carries the mode, the fan speed and the temperature, with the checksum nibble +TEST(LgIrClimateTests, ModeCommandCarriesModeFanSpeedAndTemperature) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + + turn_on(sut); + + // From OFF: COMMAND_ON_COOL, FAN_MAX, 20 °C encoded as (20 - 15) << 8, checksum 9 + EXPECT_EQ(last_frame(transmitter), 0x8800549u); +} + +// The swing command is a distinct, self-contained IR code: the real remote transmits it +// on its own, independent of the unit's current mode, target temperature, or fan speed. +TEST(LgIrClimateTests, SwingCommandIsIndependentOfClimateState) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + turn_on(sut); + + // Toggle swing -- this must be the fixed swing code below, unaffected by the state set above. + climate::ClimateCall swing_call(&sut); + swing_call.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + sut.control(swing_call); + + // The swing command is always this exact fixed code, regardless of climate state. + EXPECT_EQ(last_frame(transmitter), 0x8810001u); +} + +// With advanced commands the swing frames are fixed codes too, one to start and one to stop the swing +TEST(LgIrClimateTests, AdvancedSwingCommandsAreIndependentOfClimateState) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + sut.set_advanced_commands_support(true); + turn_on(sut); + + climate::ClimateCall swing_on(&sut); + swing_on.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + sut.control(swing_on); + EXPECT_EQ(last_frame(transmitter), 0x8813149u); // HEADER_ADV_SWING | VERT_SWING_ON + + climate::ClimateCall swing_off(&sut); + swing_off.set_swing_mode(climate::CLIMATE_SWING_OFF); + sut.control(swing_off); + EXPECT_EQ(last_frame(transmitter), 0x881315Au); // HEADER_ADV_SWING | VERT_SWING_OFF +} + +} // namespace esphome::climate_ir_lg::testing diff --git a/tests/components/climate_ir_lg/common.h b/tests/components/climate_ir_lg/common.h new file mode 100644 index 00000000000..7e9a79cc615 --- /dev/null +++ b/tests/components/climate_ir_lg/common.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include "esphome/components/climate_ir_lg/climate_ir_lg.h" +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::climate_ir_lg::testing { + +// Records the raw mark/space timings instead of driving real IR hardware. +class MockRemoteTransmitter : public remote_base::RemoteTransmitterBase { + public: + MockRemoteTransmitter() : RemoteTransmitterBase(nullptr) {} + + std::vector last_data; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { this->last_data = this->temp_.get_data(); } +}; + +// Decodes the raw mark/space timings produced by LgIrClimate::transmit_() back into the +// 28-bit value that was transmitted (header + bits, ignoring the trailing mark). +inline uint32_t decode_lg_frame(const std::vector &data, uint32_t bit_one_low) { + uint32_t value = 0; + // data[0]/data[1] are the header mark/space; each bit thereafter is a mark/space pair. + for (size_t i = 0; i < 28; i++) { + int32_t space = data[2 + 2 * i + 1]; + value = (value << 1) | (static_cast(-space) == bit_one_low ? 1 : 0); + } + return value; +} + +} // namespace esphome::climate_ir_lg::testing From 5f8f075e767a7b07e16323dc4645304b38e4b71a Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 17 Oct 2026 21:22:28 +0200 Subject: [PATCH 17/50] [zephyr_coredump] Store a coredump in flash on nRF52 and print it to the next log reader (#13122) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/logger/logger_zephyr.cpp | 38 +++- esphome/components/nrf52/clang_tidy.py | 1 + .../components/zephyr_coredump/__init__.py | 111 ++++++++++ .../components/zephyr_coredump/coredump.cpp | 191 ++++++++++++++++++ esphome/components/zephyr_coredump/coredump.h | 43 ++++ .../components/zephyr_mcumgr/ota/__init__.py | 26 ++- esphome/core/defines.h | 1 + .../zephyr_coredump/__init__.py | 0 .../config/mcumgr_adafruit.yaml | 15 ++ .../zephyr_coredump/test_zephyr_coredump.py | 104 ++++++++++ tests/components/zephyr_coredump/common.yaml | 1 + .../zephyr_coredump/test.nrf52-adafruit.yaml | 2 + .../zephyr_coredump/test.nrf52-mcumgr.yaml | 2 + .../test.nrf52-nrf52840dongle.yaml | 2 + .../zephyr_coredump/test.nrf52-xiao-ble.yaml | 2 + 16 files changed, 526 insertions(+), 14 deletions(-) create mode 100644 esphome/components/zephyr_coredump/__init__.py create mode 100644 esphome/components/zephyr_coredump/coredump.cpp create mode 100644 esphome/components/zephyr_coredump/coredump.h create mode 100644 tests/component_tests/zephyr_coredump/__init__.py create mode 100644 tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml create mode 100644 tests/component_tests/zephyr_coredump/test_zephyr_coredump.py create mode 100644 tests/components/zephyr_coredump/common.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-adafruit.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 65ed44ee30b..539730a4a67 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -727,6 +727,7 @@ esphome/components/xl9535/* @mreditor97 esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 +esphome/components/zephyr_coredump/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 esphome/components/zephyr_pwm/* @wiomoc esphome/components/zhlt01/* @cfeenstra1024 diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 95cc5d4a14b..c74943940c8 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -3,6 +3,9 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "logger.h" +#ifdef USE_CRASH_HANDLER +#include "esphome/components/zephyr_coredump/coredump.h" +#endif #include #include @@ -12,12 +15,6 @@ #include #endif -namespace esphome::zephyr_coredump { - -__attribute__((weak)) void print_coredump() {} - -} // namespace esphome::zephyr_coredump - namespace esphome::logger { // Zephyr 3.7 renamed z_arch_esf_t to struct arch_esf; the old name was later removed. @@ -56,6 +53,14 @@ void Logger::cdc_loop_() { if (!opened) { App.schedule_dump_config(); } +#ifdef USE_CRASH_HANDLER + // The new reader also gets a coredump stored by the last crash; a closed port keeps it for the next one + if (!opened) { + log_crash_to_client(); + } else { + zephyr_coredump::stop_coredump(); + } +#endif opened = !opened; } #endif @@ -103,7 +108,16 @@ void Logger::pre_setup() { const char *reset_reason = zephyr::get_reset_reason(std::span(reason_buffer)); ESP_LOGI(TAG, "Reset reason: %s", reset_reason); dump_crash_(); - zephyr_coredump::print_coredump(); +#endif +#ifdef USE_CRASH_HANDLER +#ifdef USE_LOGGER_UART_SELECTION_USB_CDC + // Nobody reads a USB port this early; cdc_loop_() reports the dump once the port is opened + if (this->uart_ == UART_SELECTION_USB_CDC) + return; +#endif + // Without a UART (baud_rate: 0, or one that did not come up) nothing reaches the console + if (this->uart_dev_ != nullptr) + log_crash_to_client(); #endif } @@ -195,6 +209,16 @@ void k_sys_fatal_error_handler(unsigned int reason, const FatalErrorEsf *esf) { arch_restart(); } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return zephyr_coredump::coredump_pending(); } +void log_crash_to_client() { + // A level that drops the lines would erase the dump unseen; keep it for a client that can see it + if (global_logger->get_log_level(zephyr_coredump::CRASH_LOG_TAG) < ESPHOME_LOG_LEVEL_ERROR) + return; + zephyr_coredump::print_coredump(); +} +#endif + } // namespace esphome::logger extern "C" { diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py index d1f95979767..f4afafa0e49 100644 --- a/esphome/components/nrf52/clang_tidy.py +++ b/esphome/components/nrf52/clang_tidy.py @@ -93,6 +93,7 @@ CONFIG_NVS=y CONFIG_SETTINGS=y """, ), + _TidySubsystem("coredump", "CONFIG_DEBUG_COREDUMP=y\n"), ) _TIDY_PRJ_CONF = "".join( diff --git a/esphome/components/zephyr_coredump/__init__.py b/esphome/components/zephyr_coredump/__init__.py new file mode 100644 index 00000000000..cc36ac7dc4c --- /dev/null +++ b/esphome/components/zephyr_coredump/__init__.py @@ -0,0 +1,111 @@ +import esphome.codegen as cg +from esphome.components.nrf52 import NRF52840_FLASH_SIZE, TESTING_FLASH_SIZE +from esphome.components.nrf52.boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG +from esphome.components.zephyr import ( + Section, + zephyr_add_overlay, + zephyr_add_pm_static, + zephyr_add_prj_conf, + zephyr_data, +) +from esphome.components.zephyr.const import ( + BOOTLOADER_MCUBOOT, + KEY_BOARD, + KEY_BOOTLOADER, + KEY_PM_STATIC, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType + +CODEOWNERS = ["@tomaszduda23"] +DEPENDENCIES = ["logger"] +DOMAIN = "zephyr_coredump" + +Coredump = cg.esphome_ns.namespace(DOMAIN).class_("Coredump", cg.Component) + +# SDK 2.9.2 and later can store every thread's stack; older ones only the faulting thread +DUMP_THREADS_MIN_VERSION = cv.Version(2, 9, 2) +# Zephyr 4.0 (from SDK 3.0) carries the fault stack pointer fix (zephyr PR 79622) +SP_FIX_MIN_VERSION = cv.Version(3, 0, 0) +# One size for every SDK, so enabling the component moves the OTA image slots the same way everywhere +PARTITION_SIZE = 0x8000 + +CONFIG_SCHEMA = cv.All( + cv.Schema({cv.GenerateID(): cv.declare_id(Coredump)}).extend(cv.COMPONENT_SCHEMA), + cv.only_on_nrf52, +) + + +def _framework_version() -> cv.Version: + return CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + + +def _partition_address() -> int: + """Place the partition directly below the lowest section reserved above the + SoftDevice (the bootloader), or at the end of flash with MCUboot.""" + data = zephyr_data() + # The SoftDevice (or MBR) is the only section that can start at 0 + reserved = [s.address for s in data[KEY_PM_STATIC] if s.address > 0] + if reserved: + return min(reserved) - PARTITION_SIZE + if (bootloader := data[KEY_BOOTLOADER]) != BOOTLOADER_MCUBOOT: + raise cv.Invalid( + f"{DOMAIN} does not know the flash layout of bootloader '{bootloader}', " + f"select one of: {', '.join(BOOTLOADER_CONFIG)}" + ) + # MCUboot reserves nothing, so the flash size has to come from the board + board = data[KEY_BOARD] + if "nrf52840" not in board and board not in BOARDS_ZEPHYR: + raise cv.Invalid( + f"{DOMAIN} does not know the flash size of board '{board}', " + "only nRF52840 boards are supported" + ) + flash_size = TESTING_FLASH_SIZE if CORE.testing_mode else NRF52840_FLASH_SIZE + return flash_size - PARTITION_SIZE + + +def _final_validate(config: ConfigType) -> ConfigType: + _partition_address() + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +# Runs before zephyr_mcumgr OTA (OTA_UPDATES), which sizes its image slots below this partition +@coroutine_with_priority(CoroPriority.DIAGNOSTICS) +async def to_code(config: ConfigType) -> None: + # The logger's crash handler hooks: the dump reaches serial, API and MQTT log clients + cg.add_define("USE_CRASH_HANDLER") + zephyr_add_prj_conf("DEBUG_COREDUMP", True) + zephyr_add_prj_conf("DEBUG_COREDUMP_BACKEND_FLASH_PARTITION", True) + framework_ver = _framework_version() + if framework_ver >= DUMP_THREADS_MIN_VERSION: + zephyr_add_prj_conf("DEBUG_COREDUMP_MEMORY_DUMP_THREADS", True) + else: + zephyr_add_prj_conf("DEBUG_COREDUMP_MEMORY_DUMP_MIN", True) + if framework_ver < SP_FIX_MIN_VERSION: + cg.add_define("USE_ZEPHYR_COREDUMP_SP_FIX") + cg.add_build_flag("-Wl,--wrap=z_arm_fatal_error") + cg.add_build_flag("-Wl,--wrap=z_arm_fault") + zephyr_add_prj_conf("PARTITION_MANAGER_ENABLED", True) + addr = _partition_address() + zephyr_add_pm_static( + [Section("coredump_partition", addr, PARTITION_SIZE, "flash_primary")] + ) + zephyr_add_overlay( + f""" + &flash0 {{ + partitions {{ + coredump_partition: partition@{addr:x} {{ + label = "coredump-partition"; + reg = <{addr:#x} {PARTITION_SIZE:#x}>; + }}; + }}; + }}; + """ + ) + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/esphome/components/zephyr_coredump/coredump.cpp b/esphome/components/zephyr_coredump/coredump.cpp new file mode 100644 index 00000000000..7bfbcc927a0 --- /dev/null +++ b/esphome/components/zephyr_coredump/coredump.cpp @@ -0,0 +1,191 @@ +#ifdef USE_ZEPHYR +#include "coredump.h" +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include +#include +// The header has no C++ linkage guards of its own +extern "C" { +#include +} + +namespace esphome::zephyr_coredump { + +ESPHOME_LOG_TAG(TAG, "zephyr_coredump"); + +// Lines in the "#CD:" format that Zephyr's coredump_serial_log_parser.py reads: the log header (time, level, +// tag, line number and colour codes, under 64 characters), "#CD:" and two hex characters per byte must fit the +// logger's line buffer, so a small buffer gets shorter lines +static constexpr size_t LINE_OVERHEAD = 64; +static constexpr size_t CHUNK_SIZE = + std::min(64, (ESPHOME_LOGGER_TX_BUFFER_SIZE - LINE_OVERHEAD) / 2 / 16 * 16); +static_assert(CHUNK_SIZE >= 16, "logger line buffer too small for a coredump line"); +// A USB host starts reading the port only some 100 ms after opening it; until then the CDC driver discards output +static constexpr uint32_t START_DELAY_MS = 1000; +static constexpr uint32_t START_DELAY_ID = 0; +// Between lines, so a loop that runs faster than usual (a stepper moving) cannot overrun the USB CDC ring buffer +static constexpr uint32_t LINE_INTERVAL_MS = 10; + +// On a UART the logger reports the reader before this component exists, so the flag outlives the pointer +static Coredump *global_coredump = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool reader_present = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool coredump_pending() { return global_coredump != nullptr && global_coredump->stored_; } + +void print_coredump() { + reader_present = true; + if (global_coredump != nullptr) + global_coredump->start_print_(); +} + +void stop_coredump() { + reader_present = false; + if (global_coredump != nullptr) + global_coredump->stop_print_(); +} + +void Coredump::setup() { + global_coredump = this; + this->disable_loop(); + // The size comes from the header alone; "has stored dump" also checks the checksum and would hide a + // corrupt dump instead of letting it be reported and erased + const int size = coredump_query(COREDUMP_QUERY_GET_STORED_DUMP_SIZE, nullptr); + if (size <= 0) { + return; + } + this->stored_ = true; + this->total_ = size; + // A bad dump is reported and erased from loop() like a good one, once somebody can read the message + this->verify_ = coredump_cmd(COREDUMP_CMD_VERIFY_STORED_DUMP, nullptr); + if (reader_present) + this->start_print_(); +} + +// A new reader gets the whole dump: a print cut short by the port closing starts over +void Coredump::start_print_() { + if (!this->stored_ || this->printing_) + return; + this->printing_ = true; + this->offset_ = 0; + this->set_timeout(START_DELAY_ID, START_DELAY_MS, [this]() { this->enable_loop(); }); +} + +void Coredump::stop_print_() { + this->printing_ = false; + this->cancel_timeout(START_DELAY_ID); + this->disable_loop(); +} + +void Coredump::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_line_ms_ < LINE_INTERVAL_MS) + return; + this->last_line_ms_ = now; + if (this->verify_ != 1) { + ESP_LOGE(TAG, "Stored coredump verification failed: %d", this->verify_); + this->finish_(0); + return; + } + if (this->offset_ == 0) { + ESP_LOGE(TAG, "#CD:BEGIN#"); + } + uint8_t buf[CHUNK_SIZE]; + char hex[format_hex_size(CHUNK_SIZE)]; + coredump_cmd_copy_arg arg{static_cast(this->offset_), buf, + std::min(CHUNK_SIZE, this->total_ - this->offset_)}; + const int ret = coredump_cmd(COREDUMP_CMD_COPY_STORED_DUMP, &arg); + if (ret > 0) { + ESP_LOGE(TAG, "#CD:%s", format_hex_to(hex, buf, ret)); + this->offset_ += ret; + } + if (ret <= 0 || this->offset_ >= this->total_) { + ESP_LOGE(TAG, "#CD:END#"); + this->finish_(ret < 0 ? ret : 0); + } +} + +// Erase so the dump is not reported again; the next crash writes a new one. Erasing the partition halts the +// CPU for about 85 ms per 4 KB page, so this one call takes most of a second +void Coredump::finish_(int err) { + if (err < 0) { + ESP_LOGE(TAG, "Failed to copy coredump: %d", err); + } + const int erase = coredump_cmd(COREDUMP_CMD_ERASE_STORED_DUMP, nullptr); + if (erase < 0) { + ESP_LOGE(TAG, "Failed to erase coredump: %d", erase); + } + this->stored_ = false; + this->total_ = 0; + this->verify_ = 0; + this->printing_ = false; + this->disable_loop(); +} + +void Coredump::dump_config() { + ESP_LOGCONFIG(TAG, + "Coredump:\n" + " Stored dump: %s\n" + " Size: %" PRIu32 "\n" + " Verified: %s\n" + " Error: %d", + YESNO(this->stored_), this->total_, YESNO(this->verify_ == 1), + coredump_query(COREDUMP_QUERY_GET_ERROR, nullptr)); +} + +} // namespace esphome::zephyr_coredump + +// Backport of https://github.com/zephyrproject-rtos/zephyr/pull/79622 (Zephyr 4.0): give GDB the stack +// pointer from before the exception frame so it can unwind. The Python side links it in with --wrap. +#ifdef USE_ZEPHYR_COREDUMP_SP_FIX +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +#if KERNEL_VERSION_NUMBER >= 0x030700 +using esf_t = struct arch_esf; +#else +using esf_t = z_arch_esf_t; +#endif + +static constexpr uint8_t EXC_RETURN_STACK_FRAME_TYPE_POS = 4; +static constexpr uint32_t EXC_RETURN_STACK_FRAME_TYPE_EXTENDED = 0; + +static uint32_t global_exc_return; + +void __real_z_arm_fault(uint32_t msp, uint32_t psp, uint32_t exc_return, _callee_saved_t *callee_regs); +void __wrap_z_arm_fault(uint32_t msp, uint32_t psp, uint32_t exc_return, _callee_saved_t *callee_regs) { + global_exc_return = exc_return; + __real_z_arm_fault(msp, psp, exc_return, callee_regs); +} + +// Faults from z_arm_fault() come here with the frame z_arm_fault() saved; a spurious interrupt has none. +// Software fatal errors (k_panic, k_oops) reach z_arm_fatal_error() inside its own object, which --wrap cannot +// intercept, so their dumps keep Zephyr's stack pointer. +void __real_z_arm_fatal_error(unsigned int reason, const esf_t *esf); +void __wrap_z_arm_fatal_error(unsigned int reason, const esf_t *esf) { +#if defined(CONFIG_ARMV7_M_ARMV8_M_MAINLINE) || defined(CONFIG_ARMV6_M_ARMV8_M_BASELINE) + if (esf == nullptr) { + __real_z_arm_fatal_error(reason, esf); + return; + } + z_arm_coredump_fault_sp += sizeof(esf->basic); +#if defined(CONFIG_FPU) && defined(CONFIG_FPU_SHARING) + // The thread used the FP registers, so the frame also holds them + if ((global_exc_return & BIT(EXC_RETURN_STACK_FRAME_TYPE_POS)) == EXC_RETURN_STACK_FRAME_TYPE_EXTENDED) { + z_arm_coredump_fault_sp += sizeof(esf->fpu); + } +#endif +#ifndef CONFIG_ARMV8_M_MAINLINE + // PSR bit 9 set: the frame was padded for alignment + if ((esf->basic.xpsr & SCB_CCR_STKALIGN_Msk) == SCB_CCR_STKALIGN_Msk) { + z_arm_coredump_fault_sp |= 0x4; + } +#endif +#endif + __real_z_arm_fatal_error(reason, esf); +} +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif +#endif diff --git a/esphome/components/zephyr_coredump/coredump.h b/esphome/components/zephyr_coredump/coredump.h new file mode 100644 index 00000000000..f56568e3555 --- /dev/null +++ b/esphome/components/zephyr_coredump/coredump.h @@ -0,0 +1,43 @@ +#pragma once +#include "esphome/core/component.h" + +namespace esphome::zephyr_coredump { + +/// The tag the dump is logged under, so a client can check the level in force for it +inline constexpr const char *CRASH_LOG_TAG = "zephyr_coredump"; + +/// Whether a dump from before this boot is waiting to be printed. +bool coredump_pending(); +/// A log reader is present: serial at boot, USB CDC when the port opens, an API or MQTT client subscribing. +/// The stored dump is printed from the start and erased once it has gone out; a print already under way +/// carries on. +void print_coredump(); +/// The reader went away (the USB port was closed); the print stops and the dump stays for the next one. +/// Readers are not told apart: closing the USB port also stops a print an API or MQTT client is receiving, +/// and a client that disconnects does not stop one, the same as the other platforms' crash records. +void stop_coredump(); + +class Coredump : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + + protected: + friend bool coredump_pending(); + friend void print_coredump(); + friend void stop_coredump(); + /// Start printing after a short delay, one line per loop so the USB CDC transmit buffer and the main loop keep up + void start_print_(); + void stop_print_(); + void finish_(int err); + + uint32_t offset_{0}; + uint32_t total_{0}; + uint32_t last_line_ms_{0}; + int verify_{0}; // result of verifying the stored dump, 1 when it is good + bool stored_{false}; + bool printing_{false}; +}; + +} // namespace esphome::zephyr_coredump diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 5b438bbda1c..b6ef932e4f8 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components.nrf52.boards import MCUMGR_OTA_BOOTLOADERS @@ -31,8 +33,11 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@tomaszduda23"] DEPENDENCIES = ["zephyr"] @@ -114,6 +119,12 @@ def _validate_bootloader(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> None: _validate_ble_server(config) _validate_bootloader(config) + if "zephyr_coredump" in fv.full_config.get(): + _LOGGER.warning( + "zephyr_coredump moves the OTA image slots. A device that already runs mcumgr OTA " + "needs one flash over USB after enabling or disabling it; an OTA update alone leaves " + "the installed MCUboot with the old slot layout" + ) if (uart := config[CONF_TRANSPORT].get(CONF_HARDWARE_UART)) is not None: cdc_id = UARTS[uart][1] if cdc_id >= 0: @@ -210,17 +221,18 @@ async def to_code(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader != BOOTLOADER_MCUBOOT: - # Derive partition addresses from the SoftDevice and bootloader sections - # registered with the Partition Manager (moved in testing mode) so that - # the DTS flash map matches what it produces: - # MCUboot sits immediately after the SoftDevice, then slot0, then slot1. + # Derive partition addresses from the sections registered with the + # Partition Manager (moved in testing mode) so that the DTS flash map + # matches what it produces: + # MCUboot sits immediately after the SoftDevice, then slot0, then slot1, + # which ends at the lowest section reserved above (bootloader, coredump). sections = zephyr_data()[KEY_PM_STATIC] mcuboot_size = 0x9000 - sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name) - bl_start = next(s.address for s in sections if "Adafruit" in s.name) + sd_end = next(s.end_address for s in sections if "SoftDevice" in s.name) + top = min(s.address for s in sections if s.address >= sd_end) slot0_start = sd_end + mcuboot_size # Align slot size down to a 4 KB sector boundary - slot_size = ((bl_start - slot0_start) // 2 // 0x1000) * 0x1000 + slot_size = ((top - slot0_start) // 2 // 0x1000) * 0x1000 slot1_start = slot0_start + slot_size def _mcuboot_partition_overlay() -> str: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index de4dfd2607b..331a637b6b3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -756,6 +756,7 @@ #define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_SOFTDEVICE_ID 7 #define USE_SOFTDEVICE_VERSION 1 +#define USE_ZEPHYR_COREDUMP_SP_FIX #define USE_ZIGBEE #define USE_ZIGBEE_WIPE_ON_BOOT #define USE_ZIGBEE_WIPE_ON_BOOT_MAGIC 1 diff --git a/tests/component_tests/zephyr_coredump/__init__.py b/tests/component_tests/zephyr_coredump/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml b/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml new file mode 100644 index 00000000000..cb67b8ef9f7 --- /dev/null +++ b/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml @@ -0,0 +1,15 @@ +esphome: + name: coredumptest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +zephyr_ble_server: + +ota: + - platform: zephyr_mcumgr + +zephyr_coredump: diff --git a/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py b/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py new file mode 100644 index 00000000000..19252188cb8 --- /dev/null +++ b/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py @@ -0,0 +1,104 @@ +"""Tests for the placement of the zephyr_coredump flash partition.""" + +from collections.abc import Callable +import logging +from pathlib import Path +import re + +import pytest + +from esphome.components.nrf52 import set_core_data +from esphome.components.zephyr import zephyr_data +from esphome.components.zephyr.const import KEY_OVERLAY, KEY_PM_STATIC +from esphome.components.zephyr_coredump import ( + DUMP_THREADS_MIN_VERSION, + PARTITION_SIZE, + _partition_address, +) +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + + +def _core_data( + board: str, + bootloader: str, + testing_mode: bool = False, + version: cv.Version = DUMP_THREADS_MIN_VERSION, +) -> None: + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: version} + CORE.testing_mode = testing_mode + set_core_data({"board": board, "bootloader": bootloader}) + + +@pytest.mark.parametrize( + ("board", "bootloader", "testing_mode", "expected"), + [ + # Below the Adafruit bootloader at 0xF4000 + ("adafruit_itsybitsy_nrf52840", "adafruit_nrf52_sd140_v6", False, 0xEC000), + # Below the factory Open DFU bootloader at 0xE0000 + ("nrf52840dongle", "nrf", False, 0xD8000), + # Nothing reserved: the end of the 1 MB flash + ("adafruit_feather_nrf52840", "mcuboot", False, 0xF8000), + # An nRF52840 board whose name does not say so + ("xiao_ble", "mcuboot", False, 0xF8000), + # Testing mode moves the bootloader to the end of the faked 4 MB flash + ("adafruit_itsybitsy_nrf52840", "adafruit_nrf52_sd140_v6", True, 0x3EC000), + ("adafruit_feather_nrf52840", "mcuboot", True, 0x3F8000), + ], +) +def test_partition_address( + board: str, bootloader: str, testing_mode: bool, expected: int +) -> None: + _core_data(board, bootloader, testing_mode) + assert _partition_address() == expected + assert PARTITION_SIZE % 0x1000 == 0 + + +def test_partition_size_is_the_same_on_every_sdk() -> None: + _core_data("adafruit_feather_nrf52840", "mcuboot", version=cv.Version(2, 6, 1)) + assert _partition_address() == 0x100000 - PARTITION_SIZE + + +def test_unknown_flash_size_rejected() -> None: + _core_data("bbc_microbit", "mcuboot") + with pytest.raises(cv.Invalid, match="only nRF52840 boards are supported"): + _partition_address() + + +def test_bootloader_without_layout_rejected() -> None: + """The generic adafruit bootloader registers no sections, so the partition would + land inside the bootloader if it were treated like MCUboot.""" + _core_data("adafruit_itsybitsy_nrf52840", "adafruit") + with pytest.raises(cv.Invalid, match="flash layout of bootloader 'adafruit'"): + _partition_address() + + +def test_mcumgr_slot_ends_below_partition( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The OTA image slot1 must end at or before the coredump partition.""" + generate_main(component_config_path("mcumgr_adafruit.yaml")) + # The logger's crash handler hooks are compiled in for the dump + assert any(d.name == "USE_CRASH_HANDLER" for d in CORE.defines) + sections = {s.name: s for s in zephyr_data()[KEY_PM_STATIC]} + coredump = sections["coredump_partition"] + assert coredump.address == 0xEC000 + match = re.search( + r"slot1_partition: partition@\w+ \{\s*reg = <(0x\w+) (0x\w+)>", + zephyr_data()[KEY_OVERLAY][""], + ) + assert match is not None + start, size = (int(v, 16) for v in match.groups()) + assert start + size <= coredump.address + + +def test_mcumgr_warns_that_the_slots_move( + caplog: pytest.LogCaptureFixture, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + with caplog.at_level(logging.WARNING): + generate_main(component_config_path("mcumgr_adafruit.yaml")) + assert "zephyr_coredump moves the OTA image slots" in caplog.text diff --git a/tests/components/zephyr_coredump/common.yaml b/tests/components/zephyr_coredump/common.yaml new file mode 100644 index 00000000000..e8d8a8b8954 --- /dev/null +++ b/tests/components/zephyr_coredump/common.yaml @@ -0,0 +1 @@ +zephyr_coredump: diff --git a/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml b/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml b/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml b/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml b/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml From f72857151af118e0a05cdf1bd7c99bb09fbedcb2 Mon Sep 17 00:00:00 2001 From: functionpointer Date: Sat, 17 Oct 2026 21:28:52 +0200 Subject: [PATCH 18/50] [pylontech] Add individual cell voltage and temperature sensors (#16852) Co-authored-by: Michael Co-authored-by: ritschys <117527311+ritschys@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/pylontech/__init__.py | 32 ++- esphome/components/pylontech/pylontech.cpp | 225 +++++++++++---- esphome/components/pylontech/pylontech.h | 55 +++- .../components/pylontech/sensor/__init__.py | 48 +++- .../pylontech/sensor/pylontech_sensor.cpp | 23 ++ .../pylontech/sensor/pylontech_sensor.h | 16 +- esphome/core/defines.h | 2 + tests/components/pylontech/__init__.py | 4 +- tests/components/pylontech/common.yaml | 21 ++ tests/components/pylontech/pylontech_test.cpp | 260 +++++++++++++++++- tests/integration/test_uart_mock_pylontech.py | 2 +- 11 files changed, 630 insertions(+), 58 deletions(-) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 8bd57f252c1..e15f8f04c82 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -1,8 +1,10 @@ from dataclasses import dataclass, field import logging +from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE @@ -29,11 +31,13 @@ CV_NUM_BATTERIES = cv.int_range(1, 16) _request_listener_slot = cg.slot_counter("PYLONTECH_LISTENER_COUNT") _request_battery_slot = cg.slot_counter("PYLONTECH_BATTERY_COUNT") +_request_cell_battery_slot = cg.slot_counter("PYLONTECH_CELL_BATTERY_COUNT") @dataclass class PylontechData: batteries: set[tuple[str, int]] = field(default_factory=set) + cell_batteries: set[tuple[str, int]] = field(default_factory=set) def _get_data() -> PylontechData: @@ -42,15 +46,26 @@ def _get_data() -> PylontechData: return CORE.data[DOMAIN] -async def register_listener(config: ConfigType, listener: MockObj) -> None: - """Register a battery listener; the hub keeps one line slot per distinct battery.""" +async def register_listener( + config: ConfigType, listener: MockObj, *, cells: bool = False +) -> None: + """Register a battery listener; the hub keeps one line slot per distinct battery. + + With cells, the hub also reads that battery's cells after every pwr reply and keeps + one cell slot per distinct battery. + """ hub = await cg.get_variable(config[CONF_PYLONTECH_ID]) hub_key = str(hub) _request_listener_slot(hub_key) data = _get_data() - if (key := (hub_key, config[CONF_BATTERY])) not in data.batteries: + key = (hub_key, config[CONF_BATTERY]) + if key not in data.batteries: data.batteries.add(key) _request_battery_slot(hub_key) + if cells and key not in data.cell_batteries: + data.cell_batteries.add(key) + _request_cell_battery_slot(hub_key) + cg.add(hub.add_cell_battery(config[CONF_BATTERY])) cg.add(hub.register_listener(listener)) @@ -80,6 +95,17 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( stop_bits=1, ) +automation.register_apply_action( + "pylontech.set_cell_polling", + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(PylontechComponent), + cv.Required(CONF_ENABLED): cv.templatable(cv.boolean), + } + ), + automation.ApplyField(CONF_ENABLED, "set_cell_polling_enabled", cg.bool_), +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 5e48e8b726c..90cb4a8b855 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -1,7 +1,9 @@ #include "pylontech.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include #include #include @@ -20,13 +22,13 @@ #define PARSE_INT(field, field_name) \ { \ - get_token(token_buf); \ + get_token(cursor, token_buf); \ PARSE_TOKEN_INT(field, field_name) \ } #define PARSE_STR(field, field_name) \ { \ - get_token(field); \ + get_token(cursor, field); \ if (strlen(field) < 2) { \ ESP_LOGV(TAG, "too short " field_name " in line %s", buffer); \ this->reject_line_(); \ @@ -38,6 +40,11 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech"); +#ifdef PYLONTECH_CELL_BATTERY_COUNT +// The pack drops a command that follows its reply without a pause +static constexpr uint32_t BAT_COMMAND_DELAY_MS = 20; +#endif + // Rejects values that would wrap in a narrowed field template static bool in_range(int value) { if constexpr (sizeof(T) >= sizeof(int)) { @@ -47,6 +54,37 @@ template static bool in_range(int value) { } } +#ifdef PYLONTECH_LISTENER_COUNT +// Copies the next whitespace separated token, at most TEXT_SENSOR_MAX_LEN - 1 characters, and moves cursor past it +static void get_token(const char *&cursor, char *token_buf) { + // Skip leading whitespace + while (*cursor == ' ' || *cursor == '\t') { + cursor++; + } + + if (*cursor == '\0') { + token_buf[0] = 0; + return; + } + + const char *start = cursor; + + // Find end of field + while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { + cursor++; + } + + size_t token_len = std::min(static_cast(cursor - start), static_cast(TEXT_SENSOR_MAX_LEN - 1)); + memcpy(token_buf, start, token_len); + token_buf[token_len] = 0; +} + +// Slots are kept in battery order, so a battery's slot index is the number of lower batteries in the mask +static uint8_t slot_index(battery_mask_t batteries, uint8_t bat_index) { + return __builtin_popcount(batteries & ((1u << bat_index) - 1u)); +} +#endif + PylontechComponent::PylontechComponent() {} void PylontechComponent::dump_config() { @@ -67,13 +105,34 @@ void PylontechComponent::setup() { } } -void PylontechComponent::update() { this->write_str("pwr\n"); } +void PylontechComponent::update() { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // A "bat N" that was not sent yet, or whose reply never finished, must not get in the way of "pwr" + this->bat_command_due_ = false; + this->late_reply_ = this->bat_reply_ != 0; + this->bat_reply_ = 0; +#endif + this->write_str("pwr\n"); +} void PylontechComponent::loop() { // Drain the UART quickly; pylontech sends large bursts. Logging and publishing wait until it is idle. size_t avail = this->available(); #ifdef PYLONTECH_LISTENER_COUNT if (avail == 0) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->bat_command_due_ && + static_cast(App.get_loop_component_start_time() - this->bat_command_at_) >= 0) { + this->bat_command_due_ = false; + if (this->cell_polling_enabled_) { + char cmd[9]; // "bat 16\n" + snprintf(cmd, sizeof(cmd), "bat %u\n", this->bat_reply_); + this->write_str(cmd); + } else { + this->bat_reply_ = 0; // turned off during the pause: nothing is coming + } + } +#endif this->dispatch_one_(); return; } @@ -120,20 +179,59 @@ void PylontechComponent::dispatch_one_() { } return; } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->cells_pending_ != 0) { + const uint8_t bat_index = __builtin_ctz(this->cells_pending_); + this->cells_pending_ &= ~(1u << bat_index); + const uint8_t slot = slot_index(this->cell_batteries_, bat_index); + ESP_LOGV(TAG, "Publishing cells of battery %u", bat_index + 1); + for (PylontechListener *listener : this->listeners_) { + if (listener->get_battery() == bat_index + 1) { + listener->on_cells_read(this->cell_slots_[slot], this->cells_read_[slot]); + } + } + return; + } +#endif if (this->rejected_ != 0) { - ESP_LOGW(TAG, "Rejected %u malformed battery lines", this->rejected_); + ESP_LOGW(TAG, "Rejected %u malformed lines", this->rejected_); this->rejected_ = 0; } } void PylontechComponent::parse_header_(const char *buffer) { this->has_tlow_id_ = strstr(buffer, "Tlow.Id") != nullptr; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // The header opens a "pwr" reply, so what follows are battery lines even after a late "bat N" reply + this->bat_reply_ = 0; + this->late_reply_ = false; + this->pwr_reply_ = true; +#endif ESP_LOGV(TAG, "header line %s Tlow.Id: %s", this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), buffer); } // Runs while the UART drains: no logging above VERBOSE and no publishing here void PylontechComponent::process_line_(const char *buffer) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (strcmp(buffer, "$$") == 0) { + // Every reply ends with "$$"; after the "pwr" reply one "bat N" follows per battery with cell sensors. + // A "$$" that ends neither of the two, such as a late reply to an abandoned "bat N", sends nothing. + if (this->bat_reply_ != 0) { + this->cells_pending_ |= 1u << (this->bat_reply_ - 1); + this->send_next_bat_(); + } else if (this->pwr_reply_) { + this->pwr_reply_ = false; + this->send_next_bat_(); + } + return; + } + // The "pwr" header always starts a "pwr" reply, whatever was expected + if (this->bat_reply_ != 0 && strncmp(buffer, "Power", 5) != 0) { + this->parse_cell_line_(buffer); + return; + } +#endif // clang-format off // example lines to parse: // Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St @@ -147,31 +245,7 @@ void PylontechComponent::process_line_(const char *buffer) { const char *cursor = buffer; char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; - // Helper Lambda to extract tokens - auto get_token = [&](char *token_buf) -> void { - // Skip leading whitespace - while (*cursor == ' ' || *cursor == '\t') { - cursor++; - } - - if (*cursor == '\0') { - token_buf[0] = 0; - return; - } - - const char *start = cursor; - - // Find end of field - while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { - cursor++; - } - - size_t token_len = std::min(static_cast(cursor - start), static_cast(TEXT_SENSOR_MAX_LEN - 1)); - memcpy(token_buf, start, token_len); - token_buf[token_len] = 0; - }; - - get_token(token_buf); + get_token(cursor, token_buf); auto bat = parse_number(token_buf); if (!bat.has_value()) { if (strcmp(token_buf, "Power") == 0) { @@ -182,20 +256,20 @@ void PylontechComponent::process_line_(const char *buffer) { } return; } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->late_reply_) { + ESP_LOGV(TAG, "dropped late line %s", buffer); // cell rows of the abandoned "bat N" look like battery lines + return; + } +#endif const unsigned bat_index = static_cast(*bat) - 1u; if (bat_index >= MAX_BATTERIES || (this->batteries_ & (1u << bat_index)) == 0) { return; // no listener for this battery } - const battery_mask_t bat_bit = 1u << bat_index; - // The slot index is the number of listened batteries below this one - uint8_t slot = 0; - for (battery_mask_t below = this->batteries_ & (bat_bit - 1); below != 0; below &= below - 1) { - slot++; - } - const battery_mask_t slot_bit = 1u << slot; + const battery_mask_t slot_bit = 1u << slot_index(this->batteries_, bat_index); // Cleared until the whole line parses, so a bad line never publishes a half written slot this->pending_ &= ~slot_bit; - PylontechListener::LineContents &l = this->slots_[slot]; + PylontechListener::LineContents &l = this->slots_[__builtin_ctz(slot_bit)]; l.bat_num = *bat; PARSE_INT(l.volt, "Volt"); @@ -203,40 +277,99 @@ void PylontechComponent::process_line_(const char *buffer) { PARSE_INT(l.tempr, "Tempr"); PARSE_INT(l.tlow, "Tlow"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Tlow.Id + get_token(cursor, token_buf); // Skip Tlow.Id } PARSE_INT(l.thigh, "Thigh"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Thigh.Id + get_token(cursor, token_buf); // Skip Thigh.Id } PARSE_INT(l.vlow, "Vlow"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Vlow.Id + get_token(cursor, token_buf); // Skip Vlow.Id } PARSE_INT(l.vhigh, "Vhigh"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Vhigh.Id + get_token(cursor, token_buf); // Skip Vhigh.Id } PARSE_STR(l.base_st, "Base.St"); PARSE_STR(l.volt_st, "Volt.St"); PARSE_STR(l.curr_st, "Curr.St"); PARSE_STR(l.temp_st, "Temp.St"); - get_token(token_buf); + get_token(cursor, token_buf); if (char *pct = strchr(token_buf, '%')) { *pct = '\0'; } PARSE_TOKEN_INT(l.coulomb, "Coulomb"); - get_token(token_buf); // Skip Date - get_token(token_buf); // Skip Time - get_token(token_buf); // Skip B.V.St - get_token(token_buf); // Skip B.T.St + get_token(cursor, token_buf); // Skip Date + get_token(cursor, token_buf); // Skip Time + get_token(cursor, token_buf); // Skip B.V.St + get_token(cursor, token_buf); // Skip B.T.St PARSE_INT(l.mostempr, "Mostempr"); this->pending_ |= slot_bit; } + +#ifdef PYLONTECH_CELL_BATTERY_COUNT +// Picks the next cell battery above the one just read and schedules "bat N" for it; the cycle ends when none +// is left or cell polling was turned off +void PylontechComponent::send_next_bat_() { + // Battery n is bit n - 1, so clearing the low bat_reply_ bits leaves the batteries above it + const battery_mask_t remaining = + this->cell_batteries_ & static_cast(~((1u << this->bat_reply_) - 1u)); + if (remaining == 0 || !this->cell_polling_enabled_) { + this->bat_reply_ = 0; + return; + } + const uint8_t bat_index = __builtin_ctz(remaining); + this->bat_reply_ = bat_index + 1; + // The slot is about to be rewritten, so an unpublished record must not go out half filled + this->cells_pending_ &= ~(1u << bat_index); + this->cells_read_[slot_index(this->cell_batteries_, bat_index)] = 0; + // loop() writes the command once the pause has passed and the line is idle + this->bat_command_due_ = true; + this->bat_command_at_ = App.get_loop_component_start_time() + BAT_COMMAND_DELAY_MS; +} + +// Runs while the UART drains, like process_line_ +void PylontechComponent::parse_cell_line_(const char *buffer) { + // clang-format off + // example lines to parse, the reply to "bat N": + // Battery Volt Curr Tempr Base State Volt. State Curr. State Temp. State SOC Coulomb BAL + // 0 3308 -914 18300 Dischg Normal Normal Normal 70% 34015 mAH N + // clang-format on + + const char *cursor = buffer; + char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; + + get_token(cursor, token_buf); + auto cell = parse_number(token_buf); + if (!cell.has_value() || *cell < 0 || *cell >= MAX_CELLS) { + // the header, the echoed command or "Command completed successfully" + ESP_LOGV(TAG, "unknown line %s", buffer); + return; + } + PylontechListener::CellContents c{}; + PARSE_INT(c.volt, "Volt"); + get_token(cursor, token_buf); // Skip Curr + int32_t tempr; // m°C + PARSE_INT(tempr, "Tempr"); + tempr /= 10; + if (!in_range(tempr)) { + ESP_LOGV(TAG, "invalid Tempr in line %s", buffer); + this->reject_line_(); + return; + } + c.tempr = tempr; + + const uint8_t slot = slot_index(this->cell_batteries_, this->bat_reply_ - 1); + this->cell_slots_[slot][*cell] = c; + this->cells_read_[slot] |= 1u << *cell; +} +#endif #endif } // namespace esphome::pylontech +#undef PARSE_TOKEN_INT #undef PARSE_INT #undef PARSE_STR diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index aa652056dca..77403478291 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -16,6 +16,10 @@ static constexpr uint8_t TEXT_SENSOR_MAX_LEN = 14; static constexpr uint8_t MAX_BATTERIES = 16; using battery_mask_t = uint16_t; static_assert(MAX_BATTERIES <= sizeof(battery_mask_t) * 8, "battery mask too narrow"); +// A battery reports cells 0 to MAX_CELLS - 1; bit c of a cell_mask_t stands for cell c +static constexpr uint8_t MAX_CELLS = 15; +using cell_mask_t = uint16_t; +static_assert(MAX_CELLS <= sizeof(cell_mask_t) * 8, "cell mask too narrow"); class PylontechListener { public: @@ -27,12 +31,22 @@ class PylontechListener { char base_st[TEXT_SENSOR_MAX_LEN], volt_st[TEXT_SENSOR_MAX_LEN], curr_st[TEXT_SENSOR_MAX_LEN], temp_st[TEXT_SENSOR_MAX_LEN]; }; + struct CellContents { + int16_t tempr; // 0.01 °C + uint16_t volt; // mV + }; + using CellArray = std::array; explicit PylontechListener(uint8_t bat_num) : bat_num_(bat_num) {} uint8_t get_battery() const { return this->bat_num_; } /// Called only with records for this listener's battery. virtual void on_line_read(LineContents *line) = 0; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + /// Called with the cells of this listener's battery after a "bat N" reply; bit c of read is set when cell c + /// was in the reply. + virtual void on_cells_read(const CellArray &cells, cell_mask_t read) {} +#endif virtual void dump_config() = 0; protected: @@ -51,11 +65,22 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic void setup() override; void dump_config() override; + /// Turn the "bat N" cell readings after each "pwr" reply off or on; nothing to turn off without cell sensors. + void set_cell_polling_enabled(bool enabled) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + this->cell_polling_enabled_ = enabled; +#endif + } + #ifdef PYLONTECH_LISTENER_COUNT void register_listener(PylontechListener *listener) { this->listeners_.push_back(listener); this->batteries_ |= 1u << (listener->get_battery() - 1); } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + /// Read the cells of this battery with "bat N" after every "pwr" reply. + void add_cell_battery(uint8_t bat_num) { this->cell_batteries_ |= 1u << (bat_num - 1); } +#endif protected: void process_line_(const char *buffer); @@ -67,17 +92,41 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic } } - // The longest line, the newer firmware header, is 182 characters - LineReader<200> line_reader_; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + void parse_cell_line_(const char *buffer); + void send_next_bat_(); +#endif + + // Members are ordered by alignment, widest first, so the small ones pack without padding StaticVector listeners_; // One slot per listened battery, in battery order; parsed while the UART drains, published one per // loop once it is idle. A newer line for a battery replaces the unpublished one. static_assert(PYLONTECH_BATTERY_COUNT <= MAX_BATTERIES, "more slots than batteries"); std::array slots_; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // One cell slot per battery with cell sensors, in battery order, filled by its "bat N" reply and published + // to that battery's listeners once the reply has ended and the UART is idle + std::array cell_slots_; + uint32_t bat_command_at_{0}; // loop time the pause before the next "bat N" ends at +#endif battery_mask_t batteries_{0}; // batteries with a listener battery_mask_t pending_{0}; // bit i set: slots_[i] is waiting to be published +#ifdef PYLONTECH_CELL_BATTERY_COUNT + std::array cells_read_; // cells the current reply has filled + battery_mask_t cell_batteries_{0}; // batteries whose cells are read + battery_mask_t cells_pending_{0}; // batteries whose cell slot is waiting to be published +#endif bool has_tlow_id_{false}; - uint8_t rejected_{0}; // malformed battery lines since the last idle pass + uint8_t rejected_{0}; // malformed lines since the last idle pass +#ifdef PYLONTECH_CELL_BATTERY_COUNT + uint8_t bat_reply_{0}; // battery whose "bat N" reply is arriving, 0 while the "pwr" reply arrives + bool pwr_reply_{false}; // a "pwr" header arrived and its "$$" has not + bool bat_command_due_{false}; // a "bat N" waits for the pause after the last reply + bool late_reply_{false}; // an abandoned "bat N" may still answer; its lines are dropped until "pwr" answers + bool cell_polling_enabled_{true}; +#endif + // The longest line, the newer firmware header, is 182 characters + LineReader<200> line_reader_; #endif }; diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 51aceace3e7..490331df70f 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -28,6 +28,13 @@ CONF_TEMPERATURE_HIGH = "temperature_high" CONF_VOLTAGE_LOW = "voltage_low" CONF_VOLTAGE_HIGH = "voltage_high" CONF_MOS_TEMPERATURE = "mos_temperature" +CONF_CELLS = "cells" +CONF_CELL = "cell" + +# A battery reports cells 0 to NUM_CELLS - 1 +NUM_CELLS = 15 + +_request_cell_slot = cg.slot_counter("PYLONTECH_SENSOR_CELL_COUNT") TYPES: dict[str, cv.Schema] = { CONF_VOLTAGE: sensor.sensor_schema( @@ -86,8 +93,34 @@ TYPES: dict[str, cv.Schema] = { ), } +CELL_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_CELL): cv.int_range(min=0, max=NUM_CELLS - 1), + cv.Optional(CONF_VOLTAGE): TYPES[CONF_VOLTAGE], + cv.Optional(CONF_TEMPERATURE): TYPES[CONF_TEMPERATURE], + } + ), + cv.has_at_least_one_key(CONF_VOLTAGE, CONF_TEMPERATURE), +) + + +def _unique_cells(cells: list[ConfigType]) -> list[ConfigType]: + seen: set[int] = set() + for cell in cells: + if (number := cell[CONF_CELL]) in seen: + raise cv.Invalid(f"Cell {number} is listed more than once") + seen.add(number) + return cells + + CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( - {cv.GenerateID(): cv.declare_id(PylontechSensor)} + { + cv.GenerateID(): cv.declare_id(PylontechSensor), + cv.Optional(CONF_CELLS): cv.All( + cv.ensure_list(CELL_SCHEMA), cv.Length(min=1), _unique_cells + ), + } ).extend({cv.Optional(marker): schema for marker, schema in TYPES.items()}) @@ -99,4 +132,15 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_sensor")(sens)) - await register_listener(config, bat) + cells = config.get(CONF_CELLS, []) + for cell_config in cells: + _request_cell_slot(str(bat)) + voltage = cg.nullptr + if voltage_config := cell_config.get(CONF_VOLTAGE): + voltage = await sensor.new_sensor(voltage_config) + temperature = cg.nullptr + if temperature_config := cell_config.get(CONF_TEMPERATURE): + temperature = await sensor.new_sensor(temperature_config) + cg.add(bat.add_cell(cell_config[CONF_CELL], voltage, temperature)) + + await register_listener(config, bat, cells=bool(cells)) diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index bead0387bef..c7612f46609 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -20,6 +20,13 @@ void PylontechSensor::dump_config() { LOG_SENSOR(" ", "Voltage high", this->voltage_high_sensor_); LOG_SENSOR(" ", "Coulomb", this->coulomb_sensor_); LOG_SENSOR(" ", "MOS Temperature", this->mos_temperature_sensor_); +#ifdef PYLONTECH_SENSOR_CELL_COUNT + for (const CellSensors &c : this->cells_) { + ESP_LOGCONFIG(TAG, " Cell %u", c.cell); + LOG_SENSOR(" ", "Voltage", c.voltage); + LOG_SENSOR(" ", "Temperature", c.temperature); + } +#endif } void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { @@ -52,4 +59,20 @@ void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { } } +#ifdef PYLONTECH_SENSOR_CELL_COUNT +void PylontechSensor::on_cells_read(const CellArray &cells, cell_mask_t read) { + for (const CellSensors &c : this->cells_) { + if ((read & (1u << c.cell)) == 0) { + continue; + } + if (c.voltage != nullptr) { + c.voltage->publish_state(((float) cells[c.cell].volt) / 1000.0f); + } + if (c.temperature != nullptr) { + c.temperature->publish_state(((float) cells[c.cell].tempr) / 100.0f); + } + } +} +#endif + } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 6b466b74664..75bc9ba01df 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -17,11 +17,25 @@ class PylontechSensor final : public PylontechListener { SUB_SENSOR(temperature_high) SUB_SENSOR(voltage_low) SUB_SENSOR(voltage_high) - SUB_SENSOR(coulomb) SUB_SENSOR(mos_temperature) void on_line_read(LineContents *line) override; + +#ifdef PYLONTECH_SENSOR_CELL_COUNT + void add_cell(uint8_t cell, sensor::Sensor *voltage, sensor::Sensor *temperature) { + this->cells_.push_back({voltage, temperature, cell}); + } + void on_cells_read(const CellArray &cells, cell_mask_t read) override; + + protected: + struct CellSensors { + sensor::Sensor *voltage; + sensor::Sensor *temperature; + uint8_t cell; + }; + StaticVector cells_; +#endif }; } // namespace esphome::pylontech diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 331a637b6b3..a2480108a72 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -179,7 +179,9 @@ #define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_TRIGGER_COUNT 1 #define PYLONTECH_BATTERY_COUNT 1 +#define PYLONTECH_CELL_BATTERY_COUNT 1 #define PYLONTECH_LISTENER_COUNT 2 +#define PYLONTECH_SENSOR_CELL_COUNT 2 #define REMOTE_BASE_DUMPER_COUNT 1 #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME diff --git a/tests/components/pylontech/__init__.py b/tests/components/pylontech/__init__.py index c16210128f6..15b9ef0e2a6 100644 --- a/tests/components/pylontech/__init__.py +++ b/tests/components/pylontech/__init__.py @@ -5,9 +5,11 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - # Storage is sized by code generation; the gtests register three batteries. + # Storage is sized by code generation; the gtests register three batteries and + # read the cells of two of them. cg.add_define("PYLONTECH_LISTENER_COUNT", 3) cg.add_define("PYLONTECH_BATTERY_COUNT", 3) + cg.add_define("PYLONTECH_CELL_BATTERY_COUNT", 2) cg.add_define("USE_LINE_READER") # A MULTI_CONF component gets no entry in the host build, so its to_code would never run. diff --git a/tests/components/pylontech/common.yaml b/tests/components/pylontech/common.yaml index 537450a3b68..37a1c4514b8 100644 --- a/tests/components/pylontech/common.yaml +++ b/tests/components/pylontech/common.yaml @@ -14,6 +14,15 @@ sensor: id: pyl01_soc mos_temperature: id: pyl01_mos_temperature + cells: + - cell: 0 + voltage: + id: pyl01_cell_0_voltage + temperature: + id: pyl01_cell_0_temperature + - cell: 14 + voltage: + id: pyl01_cell_14_voltage - platform: pylontech pylontech_id: pylontech1 battery: 1 @@ -40,3 +49,15 @@ text_sensor: id: pyl0_current_state temperature_state: id: pyl0_temperature_state + +button: + - platform: template + name: Pylontech cell polling + on_press: + - pylontech.set_cell_polling: + id: pylontech0 + enabled: !lambda return false; + # pylontech1 has no cells, so this does nothing + - pylontech.set_cell_polling: + id: pylontech1 + enabled: true diff --git a/tests/components/pylontech/pylontech_test.cpp b/tests/components/pylontech/pylontech_test.cpp index 479530571c0..6041bd572b1 100644 --- a/tests/components/pylontech/pylontech_test.cpp +++ b/tests/components/pylontech/pylontech_test.cpp @@ -10,6 +10,7 @@ #include #include "esphome/components/pylontech/pylontech.h" +#include "esphome/core/application.h" #include "esphome/components/uart/uart_component.h" namespace esphome::pylontech::testing { @@ -19,6 +20,7 @@ namespace { class MockUARTComponent : public uart::UARTComponent { public: std::vector rx; + std::string tx; // everything the hub wrote, in order void push_rx(std::initializer_list lines) { for (const std::string &line : lines) { @@ -26,7 +28,9 @@ class MockUARTComponent : public uart::UARTComponent { } } - void write_array(const uint8_t *data, size_t len) override {} + void write_array(const uint8_t *data, size_t len) override { + this->tx.append(reinterpret_cast(data), len); + } bool read_array(uint8_t *data, size_t len) override { if (this->rx.size() < len) { return false; @@ -48,6 +52,14 @@ class MockUARTComponent : public uart::UARTComponent { // Every record any listener gets, in publish order std::vector published; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +struct PublishedCells { + uint8_t bat_num; + cell_mask_t read; + PylontechListener::CellArray cells; +}; +// Every cell record any listener gets, in publish order +std::vector published_cells; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + class TestListener final : public PylontechListener { public: explicit TestListener(uint8_t bat_num) : PylontechListener(bat_num) {} @@ -55,6 +67,9 @@ class TestListener final : public PylontechListener { EXPECT_EQ(line->bat_num, this->bat_num_); published.push_back(*line); } + void on_cells_read(const CellArray &cells, cell_mask_t read) override { + published_cells.push_back({this->bat_num_, read, cells}); + } void dump_config() override {} }; @@ -74,8 +89,21 @@ constexpr const char *NEW_HEADER = constexpr const char *NEW_BAT1 = "1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal " "60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n"; +constexpr const char *REPLY_END = "Command completed successfully\r\n$$\r\n"; +constexpr const char *CELL_HEADER = + "Battery Volt Curr Tempr Base State Volt. State Curr. State Temp. State SOC Coulomb BAL\r\n"; // clang-format on +// A line of the "bat N" reply for cell cell with volt (mV) and tempr (m°C); the record keeps 0.01 °C +std::string cell_line(int cell, int volt, int tempr) { + char buf[160]; + snprintf(buf, sizeof(buf), + "%d %d -914 %d Dischg Normal Normal Normal 70%% " + "34015 mAH N\r\n", + cell, volt, tempr); + return buf; +} + // An older firmware line for battery bat with pack voltage volt (mV) std::string bat_line(int bat, int volt) { char buf[192]; @@ -103,6 +131,34 @@ class PylontechTest : public ::testing::Test { TestListener listeners_[3]{TestListener(1), TestListener(2), TestListener(3)}; }; +// The cells of batteries 1 and 3 are read +class PylontechCellTest : public PylontechTest { + protected: + void SetUp() override { + PylontechTest::SetUp(); + published_cells.clear(); + this->hub_.add_cell_battery(1); + this->hub_.add_cell_battery(3); + } + + // Lets the hub drain what was pushed and publish everything pending + void drain_and_publish_() { + for (int i = 0; i < 8; i++) { + this->hub_.loop(); + } + } + + // Lets the pause before a bat command pass: moves the loop clock on and runs one idle loop, which also + // publishes one pending record + void let_pause_pass_() { + this->now_ += 50; + LoopBlockingGuard{nullptr, nullptr, this->now_}; + this->hub_.loop(); + } + + uint32_t now_{1000}; +}; + } // namespace TEST_F(PylontechTest, BurstIsPublishedAfterTheDrainOnePerLoop) { @@ -249,4 +305,206 @@ TEST_F(PylontechTest, BadLineClearsTheBatteryPendingRecord) { EXPECT_EQ(this->published_()[0].bat_num, 2); } +TEST_F(PylontechCellTest, CellsAreReadAfterThePwrReplyOneBatteryAtATime) { + // Each pause's idle loop publishes one pending record: the battery record, then one battery's cells + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + EXPECT_TRUE(this->uart_.tx.empty()); // the pack needs a pause after its reply + EXPECT_TRUE(this->published_().empty()); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + + this->uart_.push_rx({"pylon>bat 1\r\n@\r\n", CELL_HEADER, cell_line(0, 3308, 18300), cell_line(1, 3310, 18400), + cell_line(14, 3299, 17900), REPLY_END}); + this->hub_.loop(); + EXPECT_TRUE(published_cells.empty()); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\n"); + ASSERT_EQ(published_cells.size(), 1u); + + this->uart_.push_rx({"pylon>bat 3\r\n@\r\n", CELL_HEADER, cell_line(5, 3250, 16000), REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\n"); + ASSERT_EQ(published_cells.size(), 2u); + this->hub_.loop(); + ASSERT_EQ(published_cells.size(), 2u); + + const PublishedCells &b1 = published_cells[0]; + EXPECT_EQ(b1.bat_num, 1); + EXPECT_EQ(b1.read, (1u << 0) | (1u << 1) | (1u << 14)); + EXPECT_EQ(b1.cells[0].volt, 3308); + EXPECT_EQ(b1.cells[0].tempr, 1830); + EXPECT_EQ(b1.cells[1].volt, 3310); + EXPECT_EQ(b1.cells[14].volt, 3299); + EXPECT_EQ(b1.cells[14].tempr, 1790); + const PublishedCells &b3 = published_cells[1]; + EXPECT_EQ(b3.bat_num, 3); + EXPECT_EQ(b3.read, 1u << 5); + EXPECT_EQ(b3.cells[5].volt, 3250); + EXPECT_EQ(b3.cells[5].tempr, 1600); + + // The next pwr reply starts the cycle again + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\nbat 1\n"); +} + +TEST_F(PylontechCellTest, CellPollingCanBeTurnedOffAtAnyReplyEnd) { + this->hub_.set_cell_polling_enabled(false); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_EQ(this->published_().size(), 1u); + + this->hub_.set_cell_polling_enabled(true); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + + // Turned off while bat 1 is answering: its cells are still published, bat 3 is not sent + this->hub_.set_cell_polling_enabled(false); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(published_cells.size(), 1u); + EXPECT_EQ(published_cells[0].bat_num, 1); + + // Turned off during the pause before bat 1: the queued command is dropped and the next pwr lines are + // battery lines + this->hub_.set_cell_polling_enabled(true); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->hub_.set_cell_polling_enabled(false); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + this->uart_.push_rx({HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(this->published_().size(), 4u); + EXPECT_EQ(this->published_()[3].bat_num, 2); +} + +TEST_F(PylontechCellTest, UpdateAbandonsAnUnansweredBatReply) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + + // No reply ever comes; the next pwr lines are battery lines again + this->hub_.update(); + this->uart_.push_rx({HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\npwr\nbat 1\n"); + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[1].bat_num, 2); + EXPECT_TRUE(published_cells.empty()); +} + +TEST_F(PylontechCellTest, UpdateCancelsABatCommandThatWasNotSentYet) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->hub_.update(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "pwr\n"); +} + +TEST_F(PylontechCellTest, LateBatReplyAfterUpdateIsNotMistakenForThePwrReply) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(this->published_().size(), 1u); + + // Battery 1's next record is still unpublished when update() abandons bat 1; the pack answers it only + // after the next pwr went out, with cell rows that look like battery lines, then answers pwr + this->uart_.push_rx({HEADER, bat_line(1, 49100), REPLY_END}); + this->hub_.loop(); + this->hub_.update(); + this->uart_.push_rx( + {CELL_HEADER, cell_line(0, 3308, 18300), cell_line(1, 3310, 18400), cell_line(2, 3312, 18500), REPLY_END}); + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + + // The late rows are dropped without touching battery 1's record, its "$$" sends nothing, and the pwr + // reply starts the cycle once + EXPECT_EQ(this->uart_.tx, "bat 1\npwr\nbat 1\n"); + ASSERT_EQ(this->published_().size(), 3u); + EXPECT_EQ(this->published_()[1].bat_num, 1); + EXPECT_EQ(this->published_()[1].volt, 49100); + EXPECT_EQ(this->published_()[2].bat_num, 2); + EXPECT_EQ(this->published_()[2].volt, 46012); + EXPECT_TRUE(published_cells.empty()); +} + +TEST_F(PylontechCellTest, BadCellLinesAreIgnoredAndTheNextReplyStartsEmpty) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), "0 abc -914 18300 Dischg\r\n", + cell_line(15, 3300, 18000), cell_line(14, 70000, 18000), cell_line(13, 3300, 400000), + REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(5, 3250, 16000), cell_line(5, 3251, 16100), REPLY_END}); + this->drain_and_publish_(); + + // The bad line for cell 0 leaves the good one; cell 15 does not exist; 70000 mV and 400 °C are out of + // range; the second cell 5 line replaces the first + ASSERT_EQ(published_cells.size(), 2u); + EXPECT_EQ(published_cells[0].read, 1u << 0); + EXPECT_EQ(published_cells[0].cells[0].volt, 3308); + EXPECT_EQ(published_cells[1].bat_num, 3); + EXPECT_EQ(published_cells[1].read, 1u << 5); + EXPECT_EQ(published_cells[1].cells[5].volt, 3251); + + // A reply that read nothing publishes an empty record, not the old cells + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(published_cells.size(), 3u); + EXPECT_EQ(published_cells[2].bat_num, 1); + EXPECT_EQ(published_cells[2].read, 0u); +} + +TEST_F(PylontechCellTest, ANewReplyUnmarksTheUnpublishedRecordOfItsBattery) { + // A whole cycle arrives with only the idle loops of the pauses: the battery record and battery 1's + // cells are published, battery 3's cells stay pending + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(5, 3250, 16000), REPLY_END}); + this->hub_.loop(); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + ASSERT_EQ(published_cells.size(), 1u); + EXPECT_EQ(published_cells[0].bat_num, 1); + + // The next bat 1 reply is cut short by a gap: battery 3's cells go out, battery 1's wait for "$$" + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3400, 19000)}); + this->hub_.loop(); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(published_cells.size(), 2u); + EXPECT_EQ(published_cells[1].bat_num, 3); + this->uart_.push_rx({REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(published_cells.size(), 3u); + EXPECT_EQ(published_cells[2].bat_num, 1); + EXPECT_EQ(published_cells[2].cells[0].volt, 3400); +} + } // namespace esphome::pylontech::testing diff --git a/tests/integration/test_uart_mock_pylontech.py b/tests/integration/test_uart_mock_pylontech.py index fffaa902072..a434ed5bdee 100644 --- a/tests/integration/test_uart_mock_pylontech.py +++ b/tests/integration/test_uart_mock_pylontech.py @@ -79,7 +79,7 @@ async def test_uart_mock_pylontech( def line_callback(line: str) -> None: if "Line over 199 chars dropped" in line: overflow_lines.append(line) - if "malformed battery lines" in line: + if "malformed lines" in line: rejected_lines.append(line) collector = SensorStateCollector( From 792d650fb2076b64ed4a0d243fbcba22497d6787 Mon Sep 17 00:00:00 2001 From: Simonas Kazlauskas Date: Sat, 17 Oct 2026 22:44:25 +0300 Subject: [PATCH 19/50] [remote_base] Add a protocol for 433MHz A-OK remotes (#20991) --- esphome/components/remote_base/__init__.py | 54 +++++++ .../components/remote_base/aok_protocol.cpp | 80 +++++++++++ esphome/components/remote_base/aok_protocol.h | 26 ++++ esphome/core/defines.h | 1 + tests/components/remote_base/__init__.py | 1 + tests/components/remote_base/aok_test.cpp | 135 ++++++++++++++++++ .../remote_receiver/common-actions.yaml | 5 + .../remote_transmitter/common-buttons.yaml | 17 +++ 8 files changed, 319 insertions(+) create mode 100644 esphome/components/remote_base/aok_protocol.cpp create mode 100644 esphome/components/remote_base/aok_protocol.h create mode 100644 tests/components/remote_base/aok_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 0e0a63e9d94..abb216aad7d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_BUTTON, CONF_CARRIER_FREQUENCY, CONF_CHANNEL, + CONF_CHANNELS, CONF_CHECK, CONF_CODE, CONF_COMMAND, @@ -71,6 +72,7 @@ CONF_CONTROL = "control" CONF_CONTROL_IN_KEY = "control_in_key" CONF_ROLLING_CODE = "rolling_code" CONF_EXTENSION = "extension" +CONF_REMOTE_ID = "remote_id" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -2911,3 +2913,55 @@ register_protocol_action( (CONF_MODE, "mode", IRA211ModeEnum), (CONF_FAN_MODE, "fan", IRA211FanEnum), ) + + +# A-OK +( + AOKData, + AOKBinarySensor, + AOKTrigger, + _, + AOKDumper, +) = declare_protocol("AOK") + +AOK_SCHEMA = cv.Schema( + { + cv.Required(CONF_REMOTE_ID): cv.hex_int_range(0, 0xFFFFFF), + cv.Required(CONF_CHANNELS): cv.hex_uint16_t, + cv.Required(CONF_COMMAND): cv.hex_uint8_t, + } +) + + +@register_binary_sensor("aok", AOKBinarySensor, AOK_SCHEMA) +def aok_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + AOKData, + ("remote_id", config[CONF_REMOTE_ID]), + ("channels", config[CONF_CHANNELS]), + ("command", config[CONF_COMMAND]), + ) + ) + ) + + +@register_trigger("aok", AOKTrigger, AOKData) +def aok_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("aok", AOKDumper) +def aok_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "aok", + "AOK", + AOK_SCHEMA, + (CONF_REMOTE_ID, "remote_id", cg.uint32), + (CONF_CHANNELS, "channels", cg.uint16), + (CONF_COMMAND, "command", cg.uint8), +) diff --git a/esphome/components/remote_base/aok_protocol.cpp b/esphome/components/remote_base/aok_protocol.cpp new file mode 100644 index 00000000000..e0abc1eb351 --- /dev/null +++ b/esphome/components/remote_base/aok_protocol.cpp @@ -0,0 +1,80 @@ +#include "aok_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.aok"); + +// A frame is a long start mark, then 64 bits, then a long gap. A one bit is a long mark and a short space, +// a zero bit is a short mark and a long space. The bits are: header (8), remote id (24), channels (16), +// command (8) and checksum (8), most significant bit first. +static constexpr uint8_t NBITS = 64; +static constexpr uint32_t START_HIGH_US = 5000; +static constexpr uint32_t LONG_US = 600; +static constexpr uint32_t SHORT_US = 285; +static constexpr uint8_t HEADER = 0xA3; +static constexpr uint32_t REMOTE_ID_MASK = 0xFFFFFF; + +// The sum of the six bytes between the header and the checksum +static uint8_t checksum(uint64_t frame) { + uint8_t sum = 0; + for (uint8_t shift = 8; shift < 56; shift += 8) { + sum += frame >> shift; + } + return sum; +} + +void AOKProtocol::encode(RemoteTransmitData *dst, const AOKData &data) { + uint64_t frame = (uint64_t(HEADER) << 56) | (uint64_t(data.remote_id & REMOTE_ID_MASK) << 32) | + (uint64_t(data.channels) << 16) | (uint64_t(data.command) << 8); + frame |= checksum(frame); + + dst->reserve((NBITS + 2) * 2); + dst->item(START_HIGH_US, LONG_US); + for (uint64_t mask = 1ULL << (NBITS - 1); mask; mask >>= 1) { + if (frame & mask) { + dst->item(LONG_US, SHORT_US); + } else { + dst->item(SHORT_US, LONG_US); + } + } + dst->item(LONG_US, START_HIGH_US); +} + +optional AOKProtocol::decode(RemoteReceiveData src) { + // A buffer can hold noise and several repeats of a frame, so look for the first good one. After any + // failure the loop restarts at the next timing, or where the failed frame stopped. + while (src.is_valid()) { + if (!src.expect_pulse_with_gap(START_HIGH_US, LONG_US)) { + src.advance(); + continue; + } + + uint64_t frame = 0; + uint8_t bits = 0; + for (; bits < NBITS; bits++) { + frame <<= 1; + if (src.expect_item(LONG_US, SHORT_US)) { + frame |= 1; + } else if (!src.expect_item(SHORT_US, LONG_US)) { + break; + } + } + + if (bits == NBITS && uint8_t(frame >> 56) == HEADER && uint8_t(frame) == checksum(frame)) { + return AOKData{ + .remote_id = uint32_t(frame >> 32) & REMOTE_ID_MASK, + .channels = uint16_t(frame >> 16), + .command = uint8_t(frame >> 8), + }; + } + } + return {}; +} + +void AOKProtocol::dump(const AOKData &data) { + ESP_LOGI(TAG, "Received A-OK: remote_id=0x%06" PRIX32 ", channels=0x%04X, command=0x%02X", data.remote_id, + data.channels, data.command); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aok_protocol.h b/esphome/components/remote_base/aok_protocol.h new file mode 100644 index 00000000000..19967fa40b6 --- /dev/null +++ b/esphome/components/remote_base/aok_protocol.h @@ -0,0 +1,26 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct AOKData { + uint32_t remote_id; // 24 bits + uint16_t channels; + uint8_t command; + + bool operator==(const AOKData &rhs) const { + return this->remote_id == rhs.remote_id && this->channels == rhs.channels && this->command == rhs.command; + } +}; + +class AOKProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const AOKData &data); + optional decode(RemoteReceiveData src); + void dump(const AOKData &data); +}; + +DECLARE_REMOTE_PROTOCOL(AOK) + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a2480108a72..13dc3492f29 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -186,6 +186,7 @@ #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME #define USE_REMOTE_PROTOCOL_AEHA +#define USE_REMOTE_PROTOCOL_AOK #define USE_REMOTE_PROTOCOL_BEO4 #define USE_REMOTE_PROTOCOL_BRENNENSTUHL #define USE_REMOTE_PROTOCOL_BYRONSX diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index f1045572656..fed957d3378 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -12,6 +12,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "haier", "mirage", "aeha", + "aok", "keeloq", "nexus", "ira211", diff --git a/tests/components/remote_base/aok_test.cpp b/tests/components/remote_base/aok_test.cpp new file mode 100644 index 00000000000..d4fd33d0992 --- /dev/null +++ b/tests/components/remote_base/aok_test.cpp @@ -0,0 +1,135 @@ +#include + +#include "esphome/components/remote_base/aok_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint64_t FRAME = 0xA312345600A10B48ULL; +// The start mark and space, then 64 bits of a mark and a space each, then the closing mark and space +constexpr size_t TIMINGS_SIZE = (1 + 64 + 1) * 2; + +RawTimings encode(const AOKData &data) { + RemoteTransmitData tx; + AOKProtocol().encode(&tx, data); + return tx.get_data(); +} + +optional decode(const RawTimings &timings) { + return AOKProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +// Bit `n` counted from the first one sent starts at timing 2 + 2 * n +RawTimings frame_timings(uint64_t frame) { + RawTimings timings{5000, -600}; + for (int i = 63; i >= 0; i--) { + if ((frame >> i) & 1) { + timings.insert(timings.end(), {600, -285}); + } else { + timings.insert(timings.end(), {285, -600}); + } + } + timings.insert(timings.end(), {600, -5000}); + return timings; +} + +} // namespace + +TEST(AOKProtocolTest, EncodesKnownFrame) { + EXPECT_EQ(encode(AOKData{.remote_id = 0x123456, .channels = 0x00A1, .command = 0x0B}), frame_timings(FRAME)); +} + +TEST(AOKProtocolTest, DecodesKnownFrame) { + auto decoded = decode(frame_timings(FRAME)); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); + EXPECT_EQ(decoded->channels, 0x00A1); + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, RoundTrips) { + for (const AOKData &sent : + {AOKData{0x000000, 0x0000, 0x00}, AOKData{0xFFFFFF, 0xFFFF, 0xFF}, AOKData{0xABCDEF, 0x8001, 0x7E}}) { + const RawTimings timings = encode(sent); + ASSERT_EQ(timings.size(), TIMINGS_SIZE); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_TRUE(*decoded == sent); + } +} + +TEST(AOKProtocolTest, EncodingKeepsRemoteIdToItsFieldWidth) { + auto decoded = decode(encode(AOKData{.remote_id = 0xFF123456, .channels = 0x00A1, .command = 0x0B})); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); +} + +TEST(AOKProtocolTest, RejectsBadChecksum) { EXPECT_FALSE(decode(frame_timings(FRAME ^ 1)).has_value()); } + +TEST(AOKProtocolTest, RejectsBadHeader) { + // The header is not part of the checksum, so only the header check can reject this + EXPECT_FALSE(decode(frame_timings(FRAME ^ (1ULL << 56))).has_value()); +} + +TEST(AOKProtocolTest, SkipsLeadingNoise) { + RawTimings timings{300, -1200, 150, -90}; + const RawTimings frame = frame_timings(FRAME); + timings.insert(timings.end(), frame.begin(), frame.end()); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, UsesRepeatWhenFirstFrameIsCorrupt) { + RawTimings timings = frame_timings(FRAME ^ 1); + const RawTimings good = frame_timings(FRAME); + timings.insert(timings.end(), good.begin(), good.end()); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, UsesRepeatWhenFirstFrameIsCutShort) { + RawTimings timings = frame_timings(FRAME); + timings.resize(timings.size() - 40); + const RawTimings good = frame_timings(FRAME); + timings.insert(timings.end(), good.begin(), good.end()); + EXPECT_TRUE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsBadStart) { + RawTimings timings = frame_timings(FRAME); + timings[0] = 2000; + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsTruncatedFrame) { + RawTimings timings = frame_timings(FRAME); + timings.resize(timings.size() - 6); + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsBadBitTiming) { + RawTimings timings = frame_timings(FRAME); + timings[2 + 2 * 20] = 1200; + EXPECT_FALSE(decode(timings).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index da88548d2ab..833de19ff4f 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -187,6 +187,11 @@ on_toto: - logger.log: format: "on_toto: %u %u %u" args: ["x.rc_code_1", "x.rc_code_2", "x.command"] +on_aok: + then: + - logger.log: + format: "on_aok: %u %u %u" + args: ["x.remote_id", "x.channels", "x.command"] on_hob2hood: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 66ee803d4f9..f9f1beceae9 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -395,6 +395,23 @@ button: repeat: times: 3 wait_time: 10ms + - platform: template + name: A-OK + on_press: + remote_transmitter.transmit_aok: + remote_id: 0x123456 + channels: 0x00A1 + command: 0x0B + - platform: template + name: A-OK lambda with repeat + on_press: + remote_transmitter.transmit_aok: + remote_id: 0x123456 + channels: !lambda return 0x1000; + command: 0x43 + repeat: + times: 8 + wait_time: 5ms - platform: template name: Toto with repeat on_press: From a576092f2577bc2ffb362e8754b70cd6fda03c0b Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:52:01 +0200 Subject: [PATCH 20/50] [remote_base] Send the 72-bit Jarolift TDEF group frame with transmit_keeloq (#20927) Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 19 +++- .../remote_base/keeloq_protocol.cpp | 17 +++- .../components/remote_base/keeloq_protocol.h | 12 ++- .../config/keeloq_suffix.yaml | 8 ++ .../remote_transmitter/test_keeloq_action.py | 10 +- tests/components/remote_base/keeloq_test.cpp | 91 ++++++++++++++++++- .../remote_receiver/common-actions.yaml | 4 +- .../remote_transmitter/common-buttons.yaml | 11 +++ .../components/remote_base/test_keeloq.py | 19 ++++ 9 files changed, 181 insertions(+), 10 deletions(-) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index abb216aad7d..990d2b87e4d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1044,6 +1044,21 @@ KEELOQ_SCHEMA = cv.Schema( ) +def _keeloq_group_alone(config: ConfigType) -> ConfigType: + # The group bits take the places of level and the suffix. + if CONF_GROUP not in config: + return config + if CONF_SUFFIX in config or CONF_SUFFIX_BITS in config: + raise cv.Invalid( + "group cannot be combined with suffix or suffix_bits", [CONF_GROUP] + ) + if config[CONF_LEVEL] is not False: + raise cv.Invalid( + "group sets the bit that level sends; remove level", [CONF_LEVEL] + ) + return config + + def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: # A lambda is only known on the device. A plain value has to fit the width. suffix = config.get(CONF_SUFFIX, 0) @@ -1060,6 +1075,7 @@ KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( # No defaults: an absent key leaves the data at 0 and emits nothing. cv.Optional(CONF_SUFFIX): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), cv.Optional(CONF_SUFFIX_BITS): cv.int_range(min=0, max=16), + cv.Optional(CONF_GROUP): cv.hex_uint8_t, } ) @@ -1097,7 +1113,8 @@ register_protocol_action( (CONF_LEVEL, "vlow", cg.bool_), (CONF_SUFFIX, "suffix", cg.uint16), (CONF_SUFFIX_BITS, "suffix_bits", cg.uint8), - validate=_keeloq_suffix_fits, + automation.ApplyField(CONF_GROUP, "data.set_group", cg.uint8), + validate=cv.All(_keeloq_group_alone, _keeloq_suffix_fits), ) diff --git a/esphome/components/remote_base/keeloq_protocol.cpp b/esphome/components/remote_base/keeloq_protocol.cpp index fff8eb897f0..b91f70227e9 100644 --- a/esphome/components/remote_base/keeloq_protocol.cpp +++ b/esphome/components/remote_base/keeloq_protocol.cpp @@ -93,9 +93,14 @@ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { dst->space(1 * BIT_TIME_US); } - // repeat flag - always sent as a '1' - dst->mark(1 * BIT_TIME_US); - dst->space(2 * BIT_TIME_US); + // repeat flag + if (data.repeat) { + dst->mark(1 * BIT_TIME_US); + dst->space(2 * BIT_TIME_US); + } else { + dst->mark(2 * BIT_TIME_US); + dst->space(1 * BIT_TIME_US); + } const uint8_t extra = data.suffix_bits > NBITS_SUFFIX_MAX ? NBITS_SUFFIX_MAX : data.suffix_bits; for (uint8_t i = 0; i < extra; i++) { @@ -234,6 +239,12 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { } void KeeloqProtocol::dump(const KeeloqData &data) { + if (data.suffix_bits == 6) { + // Jarolift TDEF frame: show the group as transmit_keeloq takes it. + ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", group=0x%02x", + data.address, data.command, data.encrypted, data.group()); + return; + } ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", suffix=0x%04x/%u", data.address, data.command, data.encrypted, data.suffix, data.suffix_bits); } diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index e391212e42d..43675eccaa6 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -9,11 +9,21 @@ struct KeeloqData { uint32_t encrypted; // 32 bit encrypted field uint32_t address; // 28 bit serial number uint8_t command; // Button Status S2-S1-S0-S3 - bool repeat; // Repeated command bit + bool repeat{true}; // Repeated command bit bool vlow; // Battery status bit uint16_t suffix{0}; // Optional bits after the 66-bit HCS301 word uint8_t suffix_bits{0}; + // Jarolift TDEF sends 8 group bits after the 64-bit word where the HCS301 has vlow and repeat. + void set_group(uint8_t group) { + this->vlow = group & 0x01; + this->repeat = group & 0x02; + this->suffix = group >> 2; + this->suffix_bits = 6; + } + // Only a Jarolift TDEF frame (suffix_bits == 6) carries a group; check suffix_bits before using it. + uint8_t group() const { return static_cast((this->suffix << 2) | (this->repeat << 1) | this->vlow); } + bool operator==(const KeeloqData &rhs) const { // 0x10 matches any command. The suffix is not compared: the binary sensor does not take it. if (address != rhs.address) { diff --git a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml index f14bc75f305..a24a50cf8b8 100644 --- a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml +++ b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml @@ -31,3 +31,11 @@ button: code: 0xd19ef0a9 suffix: !lambda return 0x2A; suffix_bits: !lambda return 6; + - platform: template + name: Group frame + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x1234500 + command: 0x8 + code: 0xF94A09F2 + group: 0x0F diff --git a/tests/component_tests/remote_transmitter/test_keeloq_action.py b/tests/component_tests/remote_transmitter/test_keeloq_action.py index cbc1efb0a69..c5137da1b4f 100644 --- a/tests/component_tests/remote_transmitter/test_keeloq_action.py +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -12,12 +12,18 @@ def test_keeloq_suffix_is_set_only_when_configured( ) -> None: main_cpp = generate_main(component_config_path("keeloq_suffix.yaml")) bodies = protocol_call_bodies(main_cpp) - assert len(bodies) == 3 - plain, constant, lambda_ = bodies + assert len(bodies) == 4 + plain, constant, lambda_, group = bodies # The plain word fills only the HCS301 fields. assert "data.suffix" not in plain + assert "set_group" not in plain assert "apply_call.data.suffix = 0x2A;" in constant assert "apply_call.data.suffix_bits = 6;" in constant # A lambda is called inline. assert "apply_call.data.suffix = static_cast(0x2A);" in lambda_ assert "apply_call.data.suffix_bits = static_cast(6);" in lambda_ + # group fills vlow, repeat and the suffix on the device. + assert "apply_call.data.set_group(0x0F);" in group + # It comes after the default level, so the group's bit 0 is what is sent. + assert group.index("data.vlow") < group.index("set_group") + assert "data.suffix" not in group diff --git a/tests/components/remote_base/keeloq_test.cpp b/tests/components/remote_base/keeloq_test.cpp index e96d8b9d01d..c2303b23d35 100644 --- a/tests/components/remote_base/keeloq_test.cpp +++ b/tests/components/remote_base/keeloq_test.cpp @@ -1,4 +1,5 @@ #include +#include #include "esphome/components/remote_base/keeloq_protocol.h" namespace esphome::remote_base::testing { @@ -42,6 +43,40 @@ RawTimings as_received(RawTimings timings, bool drop_tail = false) { return merged; } +// Jarolift TDEF group UP for the channel mask 0x0F0F, from a published test vector. The mask's low byte is +// inside the hopping code, the high byte is the group sent after the 64-bit word. +KeeloqData tdef_frame(uint8_t group) { + KeeloqData data{}; + data.encrypted = 0xF94A09F2; + data.address = 0x1234500; + data.command = 0x8; + data.set_group(group); + return data; +} + +// The data bits after the 10-bit-time header: a 1-bit-time mark is a 1, a 2-bit-time mark a 0. +std::vector sent_bits(const RawTimings &timings) { + std::vector bits; + size_t i = 0; + while (i < timings.size() && timings[i] != -static_cast(10 * BIT_US)) { + i++; + } + for (i++; i < timings.size(); i++) { + if (timings[i] > 0) { + bits.push_back(timings[i] == static_cast(BIT_US)); + } + } + return bits; +} + +uint32_t field(const std::vector &bits, size_t first, size_t count) { + uint32_t value = 0; + for (size_t i = 0; i < count; i++) { + value |= static_cast(bits[first + i]) << i; + } + return value; +} + optional decode(const RawTimings &timings) { return KeeloqProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); } @@ -146,7 +181,7 @@ TEST(KeeloqProtocolTest, ASpaceThatIsNeitherDataNorAGapDropsTheSuffix) { } TEST(KeeloqProtocolTest, ASuffixFollowsARepeatBitOfZero) { - // The encoder always sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. + // frame() sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. for (uint8_t suffix_bits : {0, 8}) { RawTimings timings = as_received(encode(frame(suffix_bits > 0 ? 0xA5 : 0, suffix_bits))); const size_t repeat = timings.size() - 2 * suffix_bits - 2; @@ -174,4 +209,58 @@ TEST(KeeloqProtocolTest, ASuffixEndsAtAGap) { } } +TEST(KeeloqProtocolTest, HcsWordStillEndsWithRepeatOne) { + // 32 code bits, 28 address bits, 4 command bits, vlow, repeat. + std::vector bits = sent_bits(encode(frame(0, 0))); + ASSERT_EQ(bits.size(), 66u); + EXPECT_FALSE(bits[64]); + EXPECT_TRUE(bits[65]); +} + +TEST(KeeloqProtocolTest, TdefGroupFrameSendsTheGroupAfterThe64BitWord) { + std::vector bits = sent_bits(encode(tdef_frame(0x0F))); + ASSERT_EQ(bits.size(), 72u); + EXPECT_EQ(field(bits, 0, 32), 0xF94A09F2u); + EXPECT_EQ(field(bits, 32, 28), 0x1234500u); + EXPECT_EQ(field(bits, 60, 4), 0x8u); + EXPECT_EQ(field(bits, 64, 8), 0x0Fu); +} + +TEST(KeeloqProtocolTest, TdefChannelOneSendsNoChannelTenBit) { + // Channels 1 to 8 have no bit in the group. Bit 65 is the HCS301 repeat bit, which is always 1 + // without a group; a Jarolift receiver reads it as channel 10. + std::vector bits = sent_bits(encode(tdef_frame(0x00))); + ASSERT_EQ(bits.size(), 72u); + EXPECT_EQ(field(bits, 64, 8), 0x00u); +} + +TEST(KeeloqProtocolTest, TdefGroupRoundTrips) { + // 0x0F is channels 9 to 12, 0x80 is channel 16, the last bit of the frame. + for (uint8_t group : {0x0F, 0x80}) { + auto decoded = decode(as_received(encode(tdef_frame(group)))); + ASSERT_TRUE(decoded.has_value()) << int(group); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x1234500u); + EXPECT_EQ(decoded->command, 0x8); + EXPECT_EQ(decoded->encrypted, 0xF94A09F2u); + EXPECT_EQ(decoded->suffix_bits, 6); + EXPECT_EQ(decoded->group(), group); + } + } +} + +TEST(KeeloqProtocolTest, AReceivedRepeatBitOfZeroIsSentAgainAsZero) { + KeeloqData received = frame(0, 0); + received.repeat = false; + std::vector bits = sent_bits(encode(received)); + ASSERT_EQ(bits.size(), 66u); + EXPECT_FALSE(bits[65]); + + auto decoded = decode(as_received(encode(received))); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_FALSE(decoded->repeat); + } +} + } // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 833de19ff4f..b37f838b811 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -71,8 +71,8 @@ on_jvc: on_keeloq: then: - logger.log: - format: "on_keeloq: %lu %lu %u %u %u" - args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits"] + format: "on_keeloq: %lu %lu %u %u %u %u" + args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits", "x.suffix_bits == 6 ? x.group() : 0"] on_haier: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index f9f1beceae9..cc3ff4d4e1a 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -385,6 +385,17 @@ button: code: 0xd19ef0a9 suffix: 0x00 suffix_bits: 8 + - platform: template + name: KeeLoq TDEF group + on_press: + remote_transmitter.transmit_keeloq: + address: 0x1234500 + command: 0x8 + code: 0xF94A09F2 + group: 0x0F + repeat: + times: 2 + wait_time: 0ms - platform: template name: KeeLoq lambda with repeat on_press: diff --git a/tests/unit_tests/components/remote_base/test_keeloq.py b/tests/unit_tests/components/remote_base/test_keeloq.py index bd0f817dd1f..57204d17d84 100644 --- a/tests/unit_tests/components/remote_base/test_keeloq.py +++ b/tests/unit_tests/components/remote_base/test_keeloq.py @@ -12,6 +12,7 @@ from esphome.components.remote_base import ( _keeloq_suffix_fits, ) import esphome.config_validation as cv +from esphome.const import CONF_GROUP from esphome.core import Lambda @@ -43,3 +44,21 @@ def test_transmit_action_checks_the_width() -> None: ) with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): schema({**base, CONF_SUFFIX: 0x100}) + + +def test_group_takes_the_place_of_level_and_suffix() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + base = {"address": 0x1234500, "code": 0xF94A09F2, CONF_GROUP: 0x0F} + assert schema(base)[CONF_GROUP] == 0x0F + with pytest.raises(cv.Invalid, match="group cannot be combined with suffix"): + schema({**base, CONF_SUFFIX_BITS: 6}) + with pytest.raises(cv.Invalid, match="group cannot be combined with suffix"): + schema({**base, CONF_SUFFIX: 0}) + with pytest.raises(cv.Invalid, match="remove level"): + schema({**base, "level": True}) + + +def test_group_is_one_byte() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + with pytest.raises(cv.Invalid): + schema({"address": 0x1, "code": 0x2, CONF_GROUP: 0x100}) From d02da3c8dede540dba7415b2d08ea90df9b9e849 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:54:55 -0500 Subject: [PATCH 21/50] [wifi] Support hidden option in single-network shorthand (#18157) Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 11 +++- tests/component_tests/wifi/test_wifi.py | 51 +++++++++++++++++++ .../validate-hidden-shorthand.esp32-idf.yaml | 5 ++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/wifi/test_wifi.py create mode 100644 tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5fceb891573..5660b1189b8 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -515,10 +515,16 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_PASSWORD in config and CONF_SSID not in config: raise cv.Invalid("Cannot have WiFi password without SSID!") + if CONF_HIDDEN in config and CONF_SSID not in config: + raise cv.Invalid( + "Cannot have 'hidden' without 'ssid'. If you are using the 'networks' " + "list, put 'hidden' inside the network entry instead." + ) + if CONF_SSID in config: # Automatically move single network to 'networks' section config = config.copy() @@ -527,6 +533,8 @@ def _validate(config): network[CONF_PASSWORD] = config.pop(CONF_PASSWORD) if CONF_EAP in config: network[CONF_EAP] = config.pop(CONF_EAP) + if CONF_HIDDEN in config: + network[CONF_HIDDEN] = config.pop(CONF_HIDDEN) if CONF_NETWORKS in config: # In testing mode, merged component tests may have both ssid and networks # Just use the networks list and ignore the single ssid @@ -643,6 +651,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), + cv.Optional(CONF_HIDDEN): cv.boolean, cv.Optional( CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED ): STA_MANUAL_IP_SCHEMA, diff --git a/tests/component_tests/wifi/test_wifi.py b/tests/component_tests/wifi/test_wifi.py new file mode 100644 index 00000000000..3513ee88a69 --- /dev/null +++ b/tests/component_tests/wifi/test_wifi.py @@ -0,0 +1,51 @@ +"""Config-validation tests for the wifi component.""" + +import pytest +from voluptuous import Invalid + +from esphome.components.wifi import _validate +from esphome.const import ( + CONF_HIDDEN, + CONF_NETWORKS, + CONF_PASSWORD, + CONF_SSID, + CONF_USE_ADDRESS, +) + + +def test_hidden_folded_into_networks() -> None: + """The shorthand 'hidden' option is moved into the generated networks entry.""" + config = _validate( + { + CONF_SSID: "MyHomeNetwork", + CONF_PASSWORD: "password1", + CONF_HIDDEN: True, + CONF_USE_ADDRESS: "test.local", + } + ) + assert CONF_SSID not in config + assert CONF_PASSWORD not in config + assert CONF_HIDDEN not in config + networks = config[CONF_NETWORKS] + assert len(networks) == 1 + assert networks[0][CONF_SSID] == "MyHomeNetwork" + assert networks[0][CONF_PASSWORD] == "password1" + assert networks[0][CONF_HIDDEN] is True + + +def test_shorthand_without_hidden_has_no_hidden_key() -> None: + """No 'hidden' key is set on the networks entry unless explicitly configured.""" + config = _validate( + { + CONF_SSID: "MyHomeNetwork", + CONF_PASSWORD: "password1", + CONF_USE_ADDRESS: "test.local", + } + ) + assert CONF_HIDDEN not in config[CONF_NETWORKS][0] + + +def test_hidden_without_ssid_rejected() -> None: + """A 'hidden' option without an SSID has nothing to apply to and is rejected.""" + with pytest.raises(Invalid, match="hidden"): + _validate({CONF_HIDDEN: True, CONF_USE_ADDRESS: "test.local"}) diff --git a/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml b/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml new file mode 100644 index 00000000000..8482c4e2022 --- /dev/null +++ b/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml @@ -0,0 +1,5 @@ +# Exercises the hidden option in the single-network ssid/password shorthand. +wifi: + ssid: MyHiddenNetwork + password: password1 + hidden: true From c9ba6c355c92693e5790bdbc6d2461c094db499c Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:00:31 +0200 Subject: [PATCH 22/50] [hoermann_hcp] Add an actuator error binary sensor (#20299) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../hoermann_hcp/binary_sensor/__init__.py | 19 +++-- .../components/hoermann_hcp/hoermann_hcp.cpp | 20 +++++- .../components/hoermann_hcp/hoermann_hcp.h | 8 +++ .../hoermann_hcp_binary_sensor_test.cpp | 70 +++++++++++++++++++ tests/components/hoermann_hcp/common.yaml | 2 + 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/esphome/components/hoermann_hcp/binary_sensor/__init__.py b/esphome/components/hoermann_hcp/binary_sensor/__init__.py index 3de6a161e65..ec03e3256f6 100644 --- a/esphome/components/hoermann_hcp/binary_sensor/__init__.py +++ b/esphome/components/hoermann_hcp/binary_sensor/__init__.py @@ -1,13 +1,18 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.const import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_PROBLEM, + ENTITY_CATEGORY_DIAGNOSTIC, +) from esphome.types import ConfigType from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns DEPENDENCIES = ["hoermann_hcp"] +CONF_ACTUATOR_ERROR = "actuator_error" CONF_IS_CONNECTED = "is_connected" HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_( @@ -23,14 +28,20 @@ CONFIG_SCHEMA = cv.All( device_class=DEVICE_CLASS_CONNECTIVITY, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend(cv.COMPONENT_SCHEMA), + cv.Optional(CONF_ACTUATOR_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), } ), - cv.has_at_least_one_key(CONF_IS_CONNECTED), + cv.has_at_least_one_key(CONF_IS_CONNECTED, CONF_ACTUATOR_ERROR), ) async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) if (conf := config.get(CONF_IS_CONNECTED)) is not None: - parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) - var = await binary_sensor.new_binary_sensor(conf, parent) + var = await binary_sensor.new_binary_sensor(conf, hub) await cg.register_component(var, conf) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_ACTUATOR_ERROR, hub.set_actuator_error_binary_sensor) diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index bbf1380d178..6c573f0a047 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -187,6 +187,9 @@ void HoermannHcp::dump_config() { "Hoermann HCP bridge:\n" " Modbus server address: 0x%02X", this->get_address()); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Actuator Error", this->actuator_error_binary_sensor_); +#endif #ifdef USE_HOERMANN_HCP_IDENTITY LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_text_sensor_); log_identity_value(this->serial_number_text_sensor_); @@ -551,10 +554,25 @@ void HoermannHcp::on_state_reg_(uint16_t value) { } // Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records -// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. +// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. In the high byte, Hoermann's own bus accessory +// treats either bit of 0x30 as an actuator error; what sets them is not known. void HoermannHcp::on_light_reg_(uint16_t value) { this->set_light_seen_(true); this->set_light_on_((value & 0x0010) != 0); + const bool actuator_error = (value & 0x3000) != 0; +#ifdef USE_BINARY_SENSOR + // Unknown until first reported, then kept like the other entities here while the bus is quiet. + if (this->actuator_error_binary_sensor_ != nullptr) + this->actuator_error_binary_sensor_->publish_state(actuator_error); +#endif + if (this->actuator_error_ == actuator_error) + return; + this->actuator_error_ = actuator_error; + if (actuator_error) { + ESP_LOGW(TAG, "Motor reports an actuator error (0x%04X)", value); + } else { + ESP_LOGI(TAG, "Actuator error cleared"); + } } bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index c16a0890ef9..2fca12f11b4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -6,6 +6,9 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_HOERMANN_HCP_IDENTITY #include "esphome/components/text_sensor/text_sensor.h" #endif @@ -55,6 +58,9 @@ struct HoermannHcpCommand { }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(actuator_error) +#endif #ifdef USE_HOERMANN_HCP_IDENTITY // The motor is asked for these only when one of them is configured. SUB_TEXT_SENSOR(serial_number) @@ -186,6 +192,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool light_requested_{false}; bool light_command_sent_{false}; bool light_target_{false}; + // Last reported, kept across a quiet bus so a reconnect logs only a real change. + bool actuator_error_{false}; bool door_state_seen_{false}; bool short_broadcast_logged_{false}; // Only the read half right after a 0x17 write carries a command, so a second read without a new write does not. diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp index 6e9b5670805..d22d030770d 100644 --- a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -1,3 +1,5 @@ +#include + #include #include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" @@ -49,4 +51,72 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { EXPECT_EQ(publishes, 1); } +// Nothing has reported the flag yet, so the sensor stays unknown rather than claiming the motor is fine. +TEST(HoermannHcpActuatorErrorTest, UnknownUntilReported) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + connect_controller(door); + door.update(); + EXPECT_FALSE(sensor.has_state()); + // A broadcast without register 6 does not report it either. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + door.update(); + EXPECT_FALSE(sensor.has_state()); +} + +// Either bit of 0x30 in the high byte of register 6 is an error. Nothing else in the register is: 0x0200 is set on +// some installations, and the bits above 0x30 are not part of the flag. +TEST(HoermannHcpActuatorErrorTest, FollowsTheErrorBits) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + const std::pair cases[] = { + {0x0000, false}, {0x1000, true}, {0x0010, false}, {0x2000, true}, {0x0200, false}, + {0x3014, true}, {0x0214, false}, {0x4000, false}, {0x8000, false}, {0xC0FF, false}, + }; + for (const auto &[reg, error] : cases) { + door.on_write_registers(BROADCAST_REG, lamp_broadcast(reg)); + ASSERT_TRUE(sensor.has_state()) << std::hex << reg; + EXPECT_EQ(sensor.state, error) << std::hex << reg; + } +} + +// A quiet bus or a broadcast without register 6 keeps the last value, which the next report replaces. +TEST(HoermannHcpActuatorErrorTest, KeepsTheLastValueWhileNotReported) { + TestableHoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1000)); + ASSERT_TRUE(sensor.state); + door.set_valid_(false); + door.update(); + EXPECT_TRUE(sensor.has_state()); + EXPECT_TRUE(sensor.state); + + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + door.update(); + EXPECT_TRUE(sensor.state); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + EXPECT_FALSE(sensor.state); +} + +// An unchanged flag is not reported again while the lamp next to it switches. +TEST(HoermannHcpActuatorErrorTest, LampChangeDoesNotRepublish) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + int publishes = 0; + sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1000)); + ASSERT_EQ(publishes, 1); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1010)); + EXPECT_EQ(publishes, 1); +} + } // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 21d3d2b8485..e663d5d3e45 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -11,6 +11,8 @@ binary_sensor: - platform: hoermann_hcp is_connected: name: Garage Connected + actuator_error: + name: Garage Actuator Error button: - platform: hoermann_hcp From 5759c6b8e6cce4a5542c27dc320abe56f407b8b0 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:25:40 -0500 Subject: [PATCH 23/50] [deep_sleep] Honor nested wakeup_pin_mode on ESP32 single-pin configs (#16618) Co-authored-by: J. Nick Koston --- esphome/components/deep_sleep/__init__.py | 17 ++++++++-- .../deep_sleep/test_deep_sleep.py | 32 +++++++++++++++++++ ...est_deep_sleep_nested_wakeup_pin_mode.yaml | 13 ++++++++ .../deep_sleep/validate.esp32-idf.yaml | 14 ++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml create mode 100644 tests/components/deep_sleep/validate.esp32-idf.yaml diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index d60a51c3cf7..c9a0ec2196f 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -155,6 +155,12 @@ def validate_wakeup_pin( return processed_pins +_WAKEUP_PIN_MODE_BOTH_LEVELS = ( + "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " + "or under the pin entry, not both" +) + + def validate_config(config: ConfigType) -> ConfigType: wakeup_pins = config.get(CONF_WAKEUP_PIN) has_pin_mode = CONF_WAKEUP_PIN_MODE in config @@ -170,6 +176,14 @@ def validate_config(config: ConfigType) -> ConfigType: "ESP32 does not support multiple entries for 'wakeup_pin'", path=[CONF_WAKEUP_PIN], ) + if wakeup_pins and CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): + if has_pin_mode: + raise cv.Invalid( + _WAKEUP_PIN_MODE_BOTH_LEVELS, + path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], + ) + # ESP32 has one global mode, which to_code() reads from the top level + config[CONF_WAKEUP_PIN_MODE] = pin.pop(CONF_WAKEUP_PIN_MODE) elif has_pin_mode: if multiple_pins: raise cv.Invalid( @@ -178,8 +192,7 @@ def validate_config(config: ConfigType) -> ConfigType: ) if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): raise cv.Invalid( - "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " - "or under the pin entry, not both", + _WAKEUP_PIN_MODE_BOTH_LEVELS, path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], ) # Move the global mode into the single pin entry, which to_code() reads diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index e05109f246a..4671efc7b9f 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -1,5 +1,8 @@ """Tests for the deep sleep component.""" +from collections.abc import Callable +from pathlib import Path + import pytest from esphome import config_validation as cv @@ -186,3 +189,32 @@ def test_deep_sleep_bk72xx_global_wakeup_pin_mode_with_multiple_pins_rejected( with pytest.raises(cv.Invalid, match="define it per pin") as exc: deep_sleep.validate_config(config) assert exc.value.path == [deep_sleep.CONF_WAKEUP_PIN_MODE] + + +def test_deep_sleep_esp32_nested_wakeup_pin_mode( + generate_main: Callable[[str | Path], str], +) -> None: + """On ESP32, a wakeup_pin_mode nested under the pin entry is applied, not dropped.""" + main_cpp = generate_main( + "tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml" + ) + + assert "deepsleep->set_wakeup_pin(" in main_cpp + assert "deepsleep->set_wakeup_pin_mode(" in main_cpp + assert "WAKEUP_PIN_MODE_KEEP_AWAKE" in main_cpp + + +def test_deep_sleep_esp32_wakeup_pin_mode_at_both_levels_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On ESP32, wakeup_pin_mode at the top level and under the pin entry is an error.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = { + CONF_WAKEUP_PIN: [ + {"pin": "GPIO4", deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + ], + deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP", + } + with pytest.raises(cv.Invalid, match="not both") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [CONF_WAKEUP_PIN, 0, deep_sleep.CONF_WAKEUP_PIN_MODE] diff --git a/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml b/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml new file mode 100644 index 00000000000..4bab75ea40f --- /dev/null +++ b/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +deep_sleep: + id: deepsleep + sleep_duration: 1min + wakeup_pin: + - pin: GPIO4 + wakeup_pin_mode: KEEP_AWAKE diff --git a/tests/components/deep_sleep/validate.esp32-idf.yaml b/tests/components/deep_sleep/validate.esp32-idf.yaml new file mode 100644 index 00000000000..3488914c074 --- /dev/null +++ b/tests/components/deep_sleep/validate.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + wakeup_pin: GPIO4 + +packages: + deep_sleep: !include common.yaml + +# Nested per-pin wakeup_pin_mode on ESP32 is hoisted to the top level +deep_sleep: + id: deep_sleep_1 + run_duration: 10s + sleep_duration: 50s + wakeup_pin: + - pin: ${wakeup_pin} + wakeup_pin_mode: KEEP_AWAKE From 325c747dcd19fad67b2bf62fd74bec7e1356f82b Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:41:13 -0500 Subject: [PATCH 24/50] [sen6x] Add CO2 configuration options (#18780) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 143 +++++++++++++++++++++++------ esphome/components/sen6x/sen6x.h | 29 ++++++ esphome/components/sen6x/sensor.py | 32 +++++++ tests/components/sen6x/common.yaml | 8 ++ 4 files changed, 183 insertions(+), 29 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 5087b112a8d..dabf16d760b 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -22,6 +22,9 @@ static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400; static constexpr uint32_t FAN_CLEANING_DELAY = 10000; static constexpr uint32_t SHT_HEATER_DELAY = 20000; static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; +// Accepted range of a runtime pressure source, the same as the schema's ambient_pressure_compensation +static constexpr float AMBIENT_PRESSURE_MIN_HPA = 700.0f; +static constexpr float AMBIENT_PRESSURE_MAX_HPA = 1200.0f; static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; @@ -42,6 +45,9 @@ static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; +static constexpr uint16_t SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL = 0x6711; +static constexpr uint16_t SEN6X_CMD_AMBIENT_PRESSURE = 0x6720; +static constexpr uint16_t SEN6X_CMD_SENSOR_ALTITUDE = 0x6736; static inline void set_read_command_and_words(SEN6XComponent::Sen6xType type, uint16_t &read_cmd, uint8_t &read_words) { read_cmd = SEN6X_CMD_READ_MEASUREMENT; @@ -168,32 +174,54 @@ void SEN6XComponent::setup() { }); } -// One configuration write per invocation, spaced by CMD_EXEC_DELAY. Cases without a -// configured value fall through; each taken case must advance setup_step_index_ so the -// next invocation resumes at the following step. These writes are optional, so a failure -// only warns and the chain continues to the mandatory start-measurements write. +// One configuration write per invocation, spaced by CMD_EXEC_DELAY, then the start command. +// These writes are optional, so a failure only warns and the chain carries on. void SEN6XComponent::run_next_setup_step_() { - switch (this->setup_step_index_) { - // Tuning writes are skipped when setup() disabled the sensor for this variant - case 0: - this->setup_step_index_++; - if (this->voc_sensor_ != nullptr && this->voc_tuning_params_.has_value()) { - this->write_tuning_parameters_(SEN6X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); - break; - } - [[fallthrough]]; - case 1: - this->setup_step_index_++; - if (this->nox_sensor_ != nullptr && this->nox_tuning_params_.has_value()) { - this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); - break; - } - [[fallthrough]]; - default: - this->finish_setup_(); + while (this->setup_step_index_ < SETUP_STEP_COUNT) { + if (this->write_setup_step_(static_cast(this->setup_step_index_++))) { + this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); return; + } } - this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); + this->finish_setup_(); +} + +// Returns false when the step has nothing to write: the setting is not configured, or setup() +// disabled its sensor for this variant +bool SEN6XComponent::write_setup_step_(SetupStep step) { + switch (step) { + case SETUP_STEP_VOC_TUNING: + if (this->voc_sensor_ == nullptr || !this->voc_tuning_params_.has_value()) + return false; + this->write_tuning_parameters_(SEN6X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); + return true; + case SETUP_STEP_NOX_TUNING: + if (this->nox_sensor_ == nullptr || !this->nox_tuning_params_.has_value()) + return false; + this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); + return true; + case SETUP_STEP_CO2_ASC: + if (this->co2_sensor_ == nullptr || !this->co2_asc_.has_value()) + return false; + this->write_setup_register_(SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL, this->co2_asc_.value() ? 1 : 0); + return true; + case SETUP_STEP_ALTITUDE: + if (this->co2_sensor_ == nullptr || this->altitude_compensation_ == ALTITUDE_UNSET) + return false; + this->write_setup_register_(SEN6X_CMD_SENSOR_ALTITUDE, this->altitude_compensation_); + return true; + case SETUP_STEP_AMBIENT_PRESSURE: + if (this->co2_sensor_ == nullptr || this->ambient_pressure_ == PRESSURE_UNSET) + return false; + this->write_ambient_pressure_(this->ambient_pressure_); + return true; + default: + return false; + } +} + +bool SEN6XComponent::write_setup_register_(uint16_t i2c_command, uint16_t value) { + return this->write_config_words_(i2c_command, &value, 1); } void SEN6XComponent::finish_setup_() { @@ -208,14 +236,15 @@ void SEN6XComponent::finish_setup_() { ESP_LOGD(TAG, "Initialized"); } -// Writes one optional configuration command. A failure warns and returns false, but does -// not stop setup: the sensor still measures with that setting left at its default. +// Writes one optional configuration command and reserves its execution time. A failure warns and +// returns false, but does not stop setup: the sensor still measures with that setting left at its default. bool SEN6XComponent::write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len) { if (!this->write_command(i2c_command, data, len)) { ESP_LOGE(TAG, "Write 0x%04X failed, error %d", i2c_command, this->last_error_); this->status_set_warning(); return false; } + this->set_command_wait_(CMD_EXEC_DELAY); return true; } @@ -239,6 +268,22 @@ void SEN6XComponent::dump_config() { this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_, this->firmware_version_minor_, this->address_); LOG_UPDATE_INTERVAL(this); + // setup() clears co2_sensor_ once it detects a variant without CO2 + if (this->co2_sensor_ != nullptr) { + if (this->co2_asc_.has_value()) { + ESP_LOGCONFIG(TAG, " CO2 automatic self-calibration: %s", ONOFF(this->co2_asc_.value())); + } + if (this->altitude_compensation_ != ALTITUDE_UNSET) { + ESP_LOGCONFIG(TAG, " Altitude compensation: %u m", this->altitude_compensation_); + } + if (this->ambient_pressure_ != PRESSURE_UNSET) { + ESP_LOGCONFIG(TAG, " Ambient pressure compensation: %u hPa", this->ambient_pressure_); + } + if (this->ambient_pressure_source_ != nullptr) { + ESP_LOGCONFIG(TAG, " Ambient pressure compensation source: %s", + this->ambient_pressure_source_->get_name().c_str()); + } + } LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM 4.0", this->pm_4_0_sensor_); @@ -255,9 +300,17 @@ void SEN6XComponent::update() { if (!this->initialized_ || !this->measuring_) { return; } - - // Cancel any in-flight polling from a previous update() cycle. + // Cancel any in-flight polling from a previous update() cycle before touching the bus. this->cancel_timeout(TIMEOUT_POLL); + // A command is still executing (an action moments ago): run this cycle once its window has passed + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->update(); }); + return; + } + + if (this->ambient_pressure_source_ != nullptr && this->co2_sensor_ != nullptr) { + this->update_ambient_pressure_compensation_(this->ambient_pressure_source_->state); + } set_read_command_and_words(this->sen6x_type_, this->read_cmd_, this->read_words_); @@ -286,6 +339,11 @@ void SEN6XComponent::poll_data_ready_() { ESP_LOGD(TAG, "Data not ready"); return; } + // A set command (the pressure write above all) gets its execution time before the chain writes again + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->poll_data_ready_(); }); + return; + } ESP_LOGV(TAG, "Data ready polling attempt %u", static_cast(POLL_RETRIES - this->poll_retries_remaining_ + 1)); this->poll_retries_remaining_--; @@ -454,8 +512,35 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } -// True while the previous command's execution or settling time has not elapsed -bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; } +void SEN6XComponent::write_ambient_pressure_(uint16_t pressure_hpa) { + if (this->write_setup_register_(SEN6X_CMD_AMBIENT_PRESSURE, pressure_hpa)) + this->last_ambient_pressure_ = pressure_hpa; +} + +void SEN6XComponent::update_ambient_pressure_compensation_(float pressure_hpa) { + if (std::isnan(pressure_hpa)) + return; + // Range-check before narrowing so out-of-unit sources (e.g. Pa) can't wrap into range + if (pressure_hpa < AMBIENT_PRESSURE_MIN_HPA || pressure_hpa > AMBIENT_PRESSURE_MAX_HPA) { + if (!this->pressure_range_warned_) { + ESP_LOGW(TAG, "Ambient pressure out of range: %.0f hPa", pressure_hpa); + this->pressure_range_warned_ = true; + } + return; + } + this->pressure_range_warned_ = false; + const auto value = static_cast(lroundf(pressure_hpa)); + if (value != this->last_ambient_pressure_) + this->write_ambient_pressure_(value); +} + +// Time left in the previous command's execution or settling window, 0 once it has passed +uint32_t SEN6XComponent::command_wait_remaining_() const { + const uint32_t elapsed = millis() - this->command_started_at_; + return elapsed < this->command_wait_ ? this->command_wait_ - elapsed : 0; +} + +bool SEN6XComponent::command_blocked_() const { return this->command_wait_remaining_() != 0; } void SEN6XComponent::set_command_wait_(uint32_t wait_ms) { this->command_started_at_ = millis(); diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 588a466d714..43d0ce00120 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -20,6 +20,10 @@ struct GasTuning { uint16_t gain_factor; }; +// The CO2 settings a config may leave out: altitude is 0 to 3000 m, pressure 700 to 1200 hPa +inline constexpr uint16_t ALTITUDE_UNSET = 0xFFFF; +inline constexpr uint16_t PRESSURE_UNSET = 0; + class SEN6XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { SUB_SENSOR(pm_1_0) SUB_SENSOR(pm_2_5) @@ -58,17 +62,35 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } + void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } + void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } + void set_ambient_pressure_source(sensor::Sensor *pressure) { this->ambient_pressure_source_ = pressure; } void start_measurement(); void stop_measurement(); void start_fan_cleaning(); void activate_sht_heater(); protected: + enum SetupStep : uint8_t { + SETUP_STEP_VOC_TUNING, + SETUP_STEP_NOX_TUNING, + SETUP_STEP_CO2_ASC, + SETUP_STEP_ALTITUDE, + SETUP_STEP_AMBIENT_PRESSURE, + SETUP_STEP_COUNT, + }; + Sen6xType infer_type_from_product_name_(const std::string &product_name); void run_next_setup_step_(); + bool write_setup_step_(SetupStep step); void finish_setup_(); bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool write_setup_register_(uint16_t i2c_command, uint16_t value); + void write_ambient_pressure_(uint16_t pressure_hpa); + void update_ambient_pressure_compensation_(float pressure_hpa); + uint32_t command_wait_remaining_() const; bool command_blocked_() const; void set_command_wait_(uint32_t wait_ms); void on_measurement_started_(); @@ -80,6 +102,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string serial_number_; optional voc_tuning_params_; optional nox_tuning_params_; + sensor::Sensor *ambient_pressure_source_{nullptr}; Sen6xType sen6x_type_{UNKNOWN}; // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap uint32_t command_started_at_{0}; @@ -87,12 +110,18 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S uint32_t co2_started_at_{0}; uint32_t co2_wait_{0}; uint16_t read_cmd_{0}; + uint16_t altitude_compensation_{ALTITUDE_UNSET}; + uint16_t ambient_pressure_{PRESSURE_UNSET}; + // Last pressure written to the device, used to skip redundant writes + uint16_t last_ambient_pressure_{PRESSURE_UNSET}; + optional co2_asc_; uint8_t setup_step_index_{0}; uint8_t firmware_version_major_{0}; uint8_t firmware_version_minor_{0}; uint8_t poll_retries_remaining_{0}; uint8_t read_words_{0}; bool initialized_{false}; + bool pressure_range_warned_{false}; bool measuring_{false}; bool startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 9893ef80233..b219caafa09 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -6,6 +6,10 @@ from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, + CONF_ALTITUDE_COMPENSATION, + CONF_AMBIENT_PRESSURE_COMPENSATION, + CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE, + CONF_AUTOMATIC_SELF_CALIBRATION, CONF_CO2, CONF_FORMALDEHYDE, CONF_GAIN_FACTOR, @@ -155,6 +159,22 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, device_class=DEVICE_CLASS_CARBON_DIOXIDE, state_class=STATE_CLASS_MEASUREMENT, + ).extend( + { + cv.Optional(CONF_AUTOMATIC_SELF_CALIBRATION): cv.boolean, + cv.Optional(CONF_ALTITUDE_COMPENSATION): cv.All( + cv.float_with_unit( + "altitude", "(m|m a.s.l.|MAMSL|MASL)", optional_unit=True + ), + cv.int_range(min=0, max=3000), + ), + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.int_range( + min=700, max=1200 + ), + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id( + sensor.Sensor + ), + } ), cv.Optional(CONF_FORMALDEHYDE): sensor.sensor_schema( unit_of_measurement="ppb", @@ -209,6 +229,18 @@ async def to_code(config: ConfigType) -> None: args.append(std_initial) args.append(tuning[CONF_GAIN_FACTOR]) cg.add(getattr(var, setter)(*args)) + if co2_cfg := config.get(CONF_CO2): + if (asc := co2_cfg.get(CONF_AUTOMATIC_SELF_CALIBRATION)) is not None: + cg.add(var.set_automatic_self_calibration(asc)) + if (altitude := co2_cfg.get(CONF_ALTITUDE_COMPENSATION)) is not None: + cg.add(var.set_altitude_compensation(altitude)) + if (pressure := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION)) is not None: + cg.add(var.set_ambient_pressure_compensation(pressure)) + if ( + source := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE) + ) is not None: + sens = await cg.get_variable(source) + cg.add(var.set_ambient_pressure_source(sens)) SEN6X_ACTION_SCHEMA = maybe_simple_id( diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 6ab80a76b21..eed0dd51d67 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -1,4 +1,8 @@ sensor: + - platform: template + id: pressure_source + name: Pressure Source + unit_of_measurement: hPa # Test with explicit type parameter - platform: sen6x id: sen6x_sensor @@ -45,6 +49,10 @@ sensor: gain_factor: 180 co2: name: Carbon Dioxide + automatic_self_calibration: true + altitude_compensation: 100 + ambient_pressure_compensation: 1013 + ambient_pressure_compensation_source: pressure_source formaldehyde: name: Formaldehyde address: 0x6B From ae6296019303e7492a0f584cf5c011241e62cf2e Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 17 Oct 2026 15:51:07 -0500 Subject: [PATCH 25/50] [epaper_spi] Add the UC8279 controller and the Xteink X4 Pro (#20545) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/display.py | 14 +- esphome/components/epaper_spi/epaper_spi.cpp | 9 +- esphome/components/epaper_spi/epaper_spi.h | 8 + .../epaper_spi/epaper_spi_uc8179.cpp | 37 +-- .../components/epaper_spi/epaper_spi_uc8179.h | 10 +- .../epaper_spi/epaper_spi_uc8279.cpp | 144 +++++++++++ .../components/epaper_spi/epaper_spi_uc8279.h | 46 ++++ .../epaper_spi/epaper_spi_ultrachip.cpp | 33 +++ .../epaper_spi/epaper_spi_ultrachip.h | 43 ++++ .../components/epaper_spi/models/__init__.py | 14 +- .../components/epaper_spi/models/uc8179.py | 10 +- .../components/epaper_spi/models/uc8279.py | 74 ++++++ .../epaper_spi/config/uc8279_x4pro_test.yaml | 15 ++ tests/component_tests/epaper_spi/test_init.py | 141 ++++++++++- .../display/test_uc8279_transfer.cpp | 237 ++++++++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 24 ++ 16 files changed, 811 insertions(+), 48 deletions(-) create mode 100644 esphome/components/epaper_spi/epaper_spi_uc8279.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_uc8279.h create mode 100644 esphome/components/epaper_spi/epaper_spi_ultrachip.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_ultrachip.h create mode 100644 esphome/components/epaper_spi/models/uc8279.py create mode 100644 tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml create mode 100644 tests/components/epaper_spi/display/test_uc8279_transfer.cpp diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index d04ffd33be8..6a391811112 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -30,6 +30,7 @@ from esphome.const import ( CONF_MIRROR_X, CONF_MIRROR_Y, CONF_MODEL, + CONF_OFFSET_HEIGHT, CONF_PAGES, CONF_RESET_DURATION, CONF_RESET_PIN, @@ -73,12 +74,17 @@ for module_info in pkgutil.iter_modules(models.__path__): MODELS = models.EpaperModel.models -DIMENSION_SCHEMA = cv.Schema( - { + +def dimension_schema(model: models.EpaperModel) -> cv.Schema: + schema = { cv.Required(CONF_WIDTH): cv.int_, cv.Required(CONF_HEIGHT): cv.int_, } -) + # Only models with an offset_height default use it, and the default must survive explicit dimensions + if (offset_height := model.get_default(CONF_OFFSET_HEIGHT, None)) is not None: + schema[cv.Optional(CONF_OFFSET_HEIGHT, default=offset_height)] = cv.positive_int + return cv.Schema(schema) + TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} @@ -135,7 +141,7 @@ def model_schema(config): model.option(CONF_RESET_PIN): pins.gpio_output_pin_schema, cv.GenerateID(): cv.declare_id(class_name), cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8), - cv_dimensions(CONF_DIMENSIONS): DIMENSION_SCHEMA, + cv_dimensions(CONF_DIMENSIONS): dimension_schema(model), model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema), model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list( map_sequence diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index e95d2fd2869..71e87e038b6 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -227,10 +227,12 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::IDLE); return; } + // A full update resets update_count_ to zero, so a non-zero count marks a partial one + this->partial_ = this->update_count_ != 0; this->set_state_(EPaperState::RESET); break; case EPaperState::INITIALISE: - if (!this->initialise(this->update_count_ != 0)) { + if (!this->initialise(this->partial_)) { return; // Not done yet, come back next loop } this->set_state_(EPaperState::TRANSFER_DATA); @@ -250,11 +252,14 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::REFRESH_SCREEN); break; case EPaperState::REFRESH_SCREEN: - this->refresh_screen(this->update_count_ != 0); + this->refresh_screen(this->partial_); this->update_count_ = (this->update_count_ + 1) % this->full_update_every_; this->set_state_(EPaperState::POWER_OFF); break; case EPaperState::POWER_OFF: + if (!this->after_refresh()) { + return; // Not done yet, come back next loop + } this->power_off(); this->set_state_(EPaperState::DEEP_SLEEP); break; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 046df97cccd..d953bf3df81 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -167,6 +167,13 @@ class EPaperBase : public Display, */ virtual void power_off() = 0; + /** + * Called after the refresh, before power off, repeatedly until it returns true. + * Lets a driver do extra work spread over several loops, such as rewriting the previous image. + * @return true when done, false to be called again next loop + */ + virtual bool after_refresh() { return true; } + /** * Place the display into deep sleep */ @@ -201,6 +208,7 @@ class EPaperBase : public Display, uint8_t effective_transform_{}; uint8_t update_count_{}; bool full_update_requested_{}; + bool partial_{}; // the update in progress is a partial one; set when UPDATE moves to RESET // these values represent the bounds of the updated buffer. Note that x_high and y_high // point to the pixel past the last one updated, i.e. may range up to width/height. uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp index 328cd833299..60cc80e91bd 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp @@ -10,12 +10,11 @@ ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8179"); bool EPaperUC8179::initialise(bool partial) { EPaperBase::initialise(partial); // send the model init sequence - this->partial_ = partial; ESP_LOGV(TAG, "Power on"); // POWER ON must precede the waveform/mode registers and the data transfer // (the original driver powers on and busy-waits before writing them). // The state machine busy-waits before entering TRANSFER_DATA. - this->command(0x04); + this->command(CMD_POWER_ON); // Give the busy line time to assert before the state machine polls it this->next_delay_ = 100; return true; @@ -27,21 +26,17 @@ void EPaperUC8179::set_refresh_mode_() { return; // plain full refresh uses the mode set by the init sequence } // Fast and partial refresh use flipped data polarity and a floating border - this->cmd_data(0x50, {0xA9, 0x07}); + this->cmd_data(CMD_CDI, {0xA9, 0x07}); // Force the waveform via the temperature registers: 0x5A selects the fast // full-refresh waveform, 0x6E the partial-refresh waveform - this->cmd_data(0xE0, {0x02}); + this->cmd_data(CMD_CCSET, {0x02}); if (this->partial_) { - this->cmd_data(0xE5, {0x6E}); - this->command(0x91); // enter partial mode - // Set the partial window to the full screen - const uint16_t x_end = this->width_ - 1; - const uint16_t y_end = this->height_ - 1; - this->cmd_data(0x90, {0x00, 0x00, static_cast(x_end >> 8), static_cast(x_end & 0xFF), 0x00, 0x00, - static_cast(y_end >> 8), static_cast(y_end & 0xFF), 0x01}); + this->cmd_data(CMD_TSSET, {0x6E}); + // Partial mode, with the window set to the full screen + this->set_partial_window_(0, this->width_ - 1, 0, this->height_ - 1); } else { - this->cmd_data(0xE5, {0x5A}); - this->command(0x92); // exit partial mode + this->cmd_data(CMD_TSSET, {0x5A}); + this->command(CMD_PARTIAL_OUT); // exit partial mode } } @@ -63,7 +58,7 @@ bool HOT EPaperUC8179::transfer_data() { // Phase 1 (fast full refresh only): previous image via 0x10 (DTM1), inverse of the new image if (two_pass && this->current_data_index_ < buffer_length) { if (this->current_data_index_ == 0) { - this->command(0x10); // DATA START TRANSMISSION 1 (previous image) + this->command(CMD_DTM1); // DATA START TRANSMISSION 1 (previous image) } this->start_data_(); while (this->current_data_index_ < buffer_length) { @@ -86,7 +81,7 @@ bool HOT EPaperUC8179::transfer_data() { const size_t total = offset + buffer_length; if (this->current_data_index_ < total) { if (this->current_data_index_ == offset) { - this->command(0x13); // DATA START TRANSMISSION 2 (new image) + this->command(CMD_DTM2); // DATA START TRANSMISSION 2 (new image) } this->start_data_(); while (this->current_data_index_ < total) { @@ -117,7 +112,7 @@ void EPaperUC8179::power_on() { void EPaperUC8179::refresh_screen(bool /*partial*/) { ESP_LOGV(TAG, "Refresh"); - this->command(0x12); // DISPLAY REFRESH + this->command(CMD_REFRESH); // DISPLAY REFRESH // Delay the next busy poll: the busy line takes a short time to assert after // the refresh command, and polling too early would read it as already idle this->next_delay_ = 100; @@ -125,15 +120,7 @@ void EPaperUC8179::refresh_screen(bool /*partial*/) { void EPaperUC8179::power_off() { ESP_LOGV(TAG, "Power off"); - this->command(0x02); // POWER OFF -} - -void EPaperUC8179::deep_sleep() { - // Deep sleep loses the previous-image RAM that partial refresh compares against - if (!this->is_using_partial_update_()) { - ESP_LOGV(TAG, "Deep sleep"); - this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code - } + this->command(CMD_POWER_OFF); // POWER OFF } } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index fda4297802b..5d85c6bd04a 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -1,6 +1,6 @@ #pragma once -#include "epaper_spi.h" +#include "epaper_spi_ultrachip.h" namespace esphome::epaper_spi { @@ -28,11 +28,11 @@ namespace esphome::epaper_spi { * waveform is forced, partial mode is entered with a full-screen window and * only DTM2 is sent; the controller compares against its previous-image RAM. */ -class EPaperUC8179 final : public EPaperBase { +class EPaperUC8179 final : public EPaperUltraChip { public: EPaperUC8179(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {} + : EPaperUltraChip(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {} protected: bool initialise(bool partial) override; @@ -40,11 +40,7 @@ class EPaperUC8179 final : public EPaperBase { void refresh_screen(bool partial) override; void power_on() override; void power_off() override; - void deep_sleep() override; void set_refresh_mode_(); - - // Set by initialise() so transfer_data() knows which planes to send - bool partial_{}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8279.cpp b/esphome/components/epaper_spi/epaper_spi_uc8279.cpp new file mode 100644 index 00000000000..096b2e54ae7 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8279.cpp @@ -0,0 +1,144 @@ +#include "epaper_spi_uc8279.h" + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8279"); + +static constexpr uint8_t CMD_PSR = 0x00; +static constexpr uint8_t CMD_PFS = 0x03; +static constexpr uint8_t CMD_GATE_SCAN = 0xE1; + +static constexpr uint8_t PSR0_OTP_LUT = 0x17; +static constexpr uint8_t PSR1 = 0x4D; +static constexpr uint8_t PFS = 0x20; +static constexpr uint8_t GATE_SCAN = 0x02; +static constexpr uint8_t CCSET = 0x02; +static constexpr uint8_t TSSET_FULL = 0x1E; +static constexpr uint8_t TSSET_PARTIAL = 0x5A; +static constexpr uint8_t CDI_FULL = 0x97; +static constexpr uint8_t CDI_PARTIAL = 0xD7; + +static constexpr uint16_t RESET_SETTLE_MS = 50; +static constexpr uint16_t BUSY_ASSERT_MS = 10; +static constexpr uint16_t REFRESH_START_MS = 50; + +bool EPaperUC8279::reset() { + // A partial update follows one that left the controller configured, in standby, with DTM1 holding the image + if (this->partial_) + return true; + if (!EPaperBase::reset()) + return false; + this->next_delay_ = RESET_SETTLE_MS; + return true; +} + +bool EPaperUC8279::initialise(bool partial) { + if (partial) + return true; + return EPaperBase::initialise(partial); +} + +void EPaperUC8279::write_gate_(size_t gate, bool white) { + const size_t row_width = this->row_width_; + if (white || gate < this->offset_height_) { + uint8_t white_bytes[MAX_TRANSFER_SIZE]; + memset(white_bytes, 0xFF, sizeof(white_bytes)); + for (size_t written = 0; written != row_width;) { + const size_t length = std::min(row_width - written, MAX_TRANSFER_SIZE); + this->write_array(white_bytes, length); + written += length; + } + return; + } + size_t index = (gate - this->offset_height_) * row_width; + for (size_t remaining = row_width; remaining != 0;) { + size_t length; + const uint8_t *data = this->buffer_.get_span(index, length); + length = std::min(length, remaining); + this->write_array(data, length); + index += length; + remaining -= length; + } +} + +// One plane of one gate's row per step, time-sliced; `base` is the value of current_data_index_ at its first gate +bool HOT EPaperUC8279::write_plane_(uint8_t command, bool white, size_t base) { + const uint32_t start_time = millis(); + const size_t gates = this->gate_count_(); + if (this->current_data_index_ == base) + this->command(command); + this->start_data_(); + while (this->current_data_index_ - base != gates) { + this->write_gate_(this->current_data_index_ - base, white); + ++this->current_data_index_; + if (this->current_data_index_ - base != gates && millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + return true; +} + +// DTM2 gets the new image; a full refresh also writes a white DTM1, so every pixel transitions +bool HOT EPaperUC8279::transfer_data() { + const size_t gates = this->gate_count_(); + if (this->current_data_index_ < gates) { + if (!this->write_plane_(CMD_DTM2, false, 0)) + return false; + // Start DTM1 on the next pass, so this one stays within its time slice + if (!this->partial_) + return false; + } + if (!this->partial_ && !this->write_plane_(CMD_DTM1, true, gates)) + return false; + this->current_data_index_ = 0; + return true; +} + +void EPaperUC8279::power_on() { + ESP_LOGV(TAG, "Power on"); + this->cmd_data(CMD_CDI, {this->partial_ ? CDI_PARTIAL : CDI_FULL}); + this->cmd_data(CMD_CCSET, {CCSET}); + this->cmd_data(CMD_TSSET, {this->partial_ ? TSSET_PARTIAL : TSSET_FULL}); + if (this->partial_) { + this->cmd_data(CMD_PFS, {PFS}); + this->cmd_data(CMD_GATE_SCAN, {GATE_SCAN}); + } + this->command(CMD_POWER_ON); + this->next_delay_ = BUSY_ASSERT_MS; +} + +void EPaperUC8279::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen (%s)", partial ? LOG_STR_LITERAL("partial") : LOG_STR_LITERAL("full")); + if (partial) + this->set_partial_window_(0, this->width_ - 1, this->offset_height_, this->gate_count_() - 1); + // Power-on reloads the panel's stored settings, so the panel setting only takes when written after it + this->cmd_data(CMD_PSR, {PSR0_OTP_LUT, PSR1}); + this->command(CMD_REFRESH); + this->next_delay_ = REFRESH_START_MS; +} + +bool HOT EPaperUC8279::after_refresh() { + // While partial updates are in use, DTM1 holds the image on the panel for the next partial comparison + if (!this->is_using_partial_update_()) + return true; + if (this->current_data_index_ == 0 && this->partial_) + this->command(CMD_PARTIAL_OUT); + if (!this->write_plane_(CMD_DTM1, false, 0)) + return false; + this->current_data_index_ = 0; + return true; +} + +void EPaperUC8279::power_off() { + this->command(CMD_POWER_OFF); + this->next_delay_ = BUSY_ASSERT_MS; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8279.h b/esphome/components/epaper_spi/epaper_spi_uc8279.h new file mode 100644 index 00000000000..fb430855516 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8279.h @@ -0,0 +1,46 @@ +#pragma once + +#include "epaper_spi_ultrachip.h" + +namespace esphome::epaper_spi { + +/** + * Monochrome e-paper displays using the UltraChip UC8279 controller, such as the Xteink X4 Pro. + * + * Buffer layout: 1 bit per pixel, 1=white, 0=black (the base class default). + * + * The controller scans offset_height + height gates; the panel is bonded to the last height of them, + * so every image plane is written for all gates, white outside the panel. + * + * A full refresh writes the new image to DTM2 and white to DTM1. A partial refresh writes only DTM2 + * and drives the pixels that differ from DTM1, which therefore has to hold the image on the panel: + * while partial updates are in use it is rewritten after every refresh, in time slices like the + * image transfer. The panel loses both planes in its own deep sleep, so it then waits in standby + * between updates instead. + */ +class EPaperUC8279 : public EPaperUltraChip { + public: + EPaperUC8279(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length, uint16_t offset_height) + : EPaperUltraChip(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY), + offset_height_(offset_height) {} + + protected: + bool reset() override; + bool initialise(bool partial) override; + bool transfer_data() override; + void power_on() override; + void refresh_screen(bool partial) override; + void power_off() override; + bool after_refresh() override; + + uint16_t gate_count_() const { return this->offset_height_ + this->height_; } + // Writes one gate's row of the image, or white for a gate outside the panel or when white is true + void write_gate_(size_t gate, bool white); + // Writes one plane in time slices, sending `command` at its first gate; true once all gates are written + bool write_plane_(uint8_t command, bool white, size_t base); + + uint16_t offset_height_; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp b/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp new file mode 100644 index 00000000000..d34e022380e --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp @@ -0,0 +1,33 @@ +#include "epaper_spi_ultrachip.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +ESPHOME_LOG_TAG(TAG, "epaper_spi.ultrachip"); + +static constexpr uint8_t hi(uint16_t v) { return static_cast(v >> 8); } +static constexpr uint8_t lo(uint16_t v) { return static_cast(v & 0xFF); } + +void EPaperUltraChip::deep_sleep() { + // Partial updates compare against the previous image, which deep sleep would lose; standby keeps it + if (this->is_using_partial_update_()) + return; + this->send_deep_sleep_(); +} + +void EPaperUltraChip::send_deep_sleep_() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(CMD_DEEP_SLEEP, {DEEP_SLEEP_CHECK}); +} + +void EPaperUltraChip::on_safe_shutdown() { this->send_deep_sleep_(); } + +void EPaperUltraChip::set_partial_window_(uint16_t x_start, uint16_t x_end, uint16_t y_start, uint16_t y_end) { + this->command(CMD_PARTIAL_IN); + this->cmd_data(CMD_PARTIAL_WINDOW, + {hi(x_start), static_cast(lo(x_start) & 0xF8), hi(x_end), + static_cast(lo(x_end) | 0x07), hi(y_start), lo(y_start), hi(y_end), lo(y_end), 0x01}); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ultrachip.h b/esphome/components/epaper_spi/epaper_spi_ultrachip.h new file mode 100644 index 00000000000..4dfd732fa24 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ultrachip.h @@ -0,0 +1,43 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Shared base for the UltraChip UC81xx mono controllers (UC8179, UC8279). + * + * They use the same command set and the same deep-sleep and partial-window handling; the + * concrete drivers differ only in how each refresh is configured and which planes are sent. + */ +class EPaperUltraChip : public EPaperBase { + public: + using EPaperBase::EPaperBase; + + // Nothing carries the image across a restart or the controller's deep sleep, so sleep the panel rather than + // leaving it waiting in standby + void on_safe_shutdown() override; + + protected: + static constexpr uint8_t CMD_POWER_OFF = 0x02; + static constexpr uint8_t CMD_POWER_ON = 0x04; + static constexpr uint8_t CMD_DEEP_SLEEP = 0x07; + static constexpr uint8_t CMD_DTM1 = 0x10; + static constexpr uint8_t CMD_REFRESH = 0x12; + static constexpr uint8_t CMD_DTM2 = 0x13; + static constexpr uint8_t CMD_CDI = 0x50; + static constexpr uint8_t CMD_PARTIAL_WINDOW = 0x90; + static constexpr uint8_t CMD_PARTIAL_IN = 0x91; + static constexpr uint8_t CMD_PARTIAL_OUT = 0x92; + static constexpr uint8_t CMD_CCSET = 0xE0; + static constexpr uint8_t CMD_TSSET = 0xE5; + static constexpr uint8_t DEEP_SLEEP_CHECK = 0xA5; + + void deep_sleep() override; + // Sends the deep-sleep command unconditionally; deep_sleep() skips it while partial updates are in use + void send_deep_sleep_(); + // Enters partial mode and sets the refresh window; x is rounded out to whole bytes + void set_partial_window_(uint16_t x_start, uint16_t x_end, uint16_t y_start, uint16_t y_end); +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index fbedd6371d1..ba8ebb6c873 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -1,12 +1,17 @@ from typing import Any, Self import esphome.config_validation as cv -from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType +def ultrachip_resolution(width: int, gates: int) -> tuple: + """The UC81xx resolution command (0x61): source (width) and gate counts, each big-endian.""" + return (0x61, width >> 8, width & 0xFF, gates >> 8, gates & 0xFF) + + class EpaperModel: models: dict[str, Self] = {} @@ -86,6 +91,13 @@ class EpaperModel: height = self.get_default(CONF_HEIGHT) return width, height + def get_offset_height(self, config) -> int: + # The panel's first row within the controller's addressable area; from explicit dimensions or the model default + dimensions = config.get(CONF_DIMENSIONS, {}) + return dimensions.get( + CONF_OFFSET_HEIGHT, self.get_default(CONF_OFFSET_HEIGHT, 0) + ) + def extend(self, name, **kwargs) -> "EpaperModel": """ Extend the current model with additional parameters or a modified init sequence. diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py index 91c649808a8..84b736081fb 100644 --- a/esphome/components/epaper_spi/models/uc8179.py +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -18,7 +18,7 @@ from typing import Any from esphome.const import CONF_DATA_RATE -from . import EpaperModel +from . import EpaperModel, ultrachip_resolution class UC8179(EpaperModel): @@ -51,13 +51,7 @@ class UC8179(EpaperModel): # PANEL SETTING (black/white mode, LUT from OTP) (0x00, 0x1F), # RESOLUTION SETTING (width x height) - ( - 0x61, - (width >> 8) & 0xFF, - width & 0xFF, - (height >> 8) & 0xFF, - height & 0xFF, - ), + ultrachip_resolution(width, height), # DUAL SPI MODE (disabled) (0x15, 0x00), # VCOM AND DATA INTERVAL SETTING diff --git a/esphome/components/epaper_spi/models/uc8279.py b/esphome/components/epaper_spi/models/uc8279.py new file mode 100644 index 00000000000..deb487883de --- /dev/null +++ b/esphome/components/epaper_spi/models/uc8279.py @@ -0,0 +1,74 @@ +"""Monochrome e-paper displays using the UltraChip UC8279 controller. + +Supported models: +- uc8279: the controller on its own; dimensions and pins must be configured +- xteink-x4pro-uc8279: the Xteink X4 Pro e-reader (ESP32-S3) with its 800x480 UC8279 panel; + the X4 Pro also ships with other panel controllers + +The controller scans offset_height + height gates, and the panel is bonded to the last +height of them; offset_height (under dimensions) is a property of the panel. +""" + +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DATA_RATE, CONF_DIMENSIONS, CONF_OFFSET_HEIGHT +from esphome.types import ConfigType + +from . import EpaperModel, ultrachip_resolution + + +class UC8279(EpaperModel): + def __init__( + self, + name: str, + class_name: str = "EPaperUC8279", + data_rate: str = "10MHz", + **defaults: Any, + ) -> None: + defaults.setdefault(CONF_DATA_RATE, data_rate) + defaults.setdefault("partial_update", True) + defaults.setdefault(CONF_OFFSET_HEIGHT, 0) + defaults.setdefault("width_multiple", 8) + super().__init__(name, class_name=class_name, **defaults) + + def validate_config(self, config: ConfigType) -> ConfigType: + _width, height = self.get_dimensions(config) + if self.get_offset_height(config) + height > 0xFFFF: + raise cv.Invalid( + f"{CONF_OFFSET_HEIGHT} plus the panel height must not exceed {0xFFFF} gates", + path=[CONF_DIMENSIONS, CONF_OFFSET_HEIGHT], + ) + return config + + def get_constructor_args(self, config) -> tuple: + return (self.get_offset_height(config),) + + # fmt: off + def get_init_sequence(self, config: dict) -> tuple: + width, height = self.get_dimensions(config) + gates = self.get_offset_height(config) + height + return ( + (0x00, 0x37, 0x4D), # panel setting; the driver switches it to the OTP LUT before each refresh + ultrachip_resolution(width, gates), # resolution: sources, gates + (0x65, 0x00, 0x00, 0x00, 0x00), # gate and source start + (0x03, 0x20), # power off sequence + (0x30, 0x0E), # PLL + (0xE1, 0x02), # gate scan + ) + # fmt: on + + +uc8279 = UC8279("uc8279") + +uc8279.extend( + "xteink-x4pro-uc8279", + width=800, + height=480, + offset_height=120, + cs_pin=13, + dc_pin=18, + reset_pin=14, + busy_pin={"number": 6, "inverted": True}, + reset_duration="50ms", +) diff --git a/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml b/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml new file mode 100644 index 00000000000..34b55ab4456 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + +spi: + clk_pin: GPIO12 + mosi_pin: GPIO11 + +display: + - platform: epaper_spi + id: epaper_display + model: xteink-x4pro-uc8279 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 81e197abf81..644a505bf82 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -13,7 +13,12 @@ from esphome.components.epaper_spi.display import ( FINAL_VALIDATE_SCHEMA, MODELS, ) -from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, @@ -516,6 +521,140 @@ def test_uc8179_e1001_code_generation( assert "97, 4, 3, 32, 1, 224" in main_cpp +def test_uc8279_width_must_be_a_multiple_of_8( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The UC8279 takes whole bytes of sources, so the width is a multiple of 8.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="multiple of 8"): + run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 804, "height": 480}, + } + ) + + +def test_uc8279_x4pro_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The Xteink X4 Pro generates the UC8279 driver with the panel's gate offset and gate count.""" + main_cpp = generate_main(component_config_path("uc8279_x4pro_test.yaml")) + + assert "epaper_spi::EPaperUC8279" in main_cpp + # 800 sources, 480 rows, and the panel on the last 480 of 600 gates + assert re.search( + r'"XTEINK-X4PRO-UC8279",\s*800,\s*480,.*?,\s*120\)', main_cpp, re.DOTALL + ) + resolution = next( + cmd + for cmd in MODELS["XTEINK-X4PRO-UC8279"].get_init_sequence({}) + if cmd[0] == 0x61 + ) + assert resolution == (0x61, 0x03, 0x20, 0x02, 0x58) + + +def test_uc8279_offset_height_configurable( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The generic uc8279 model takes offset_height under dimensions, reaching the constructor and gate count.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 16, "height": 2, "offset_height": 8}, + } + ) + model = MODELS["UC8279"] + assert model.get_constructor_args(config) == (8,) + resolution = next(cmd for cmd in model.get_init_sequence(config) if cmd[0] == 0x61) + # 16 sources, and 8 + 2 = 10 gates + assert resolution == (0x61, 0x00, 0x10, 0x00, 0x0A) + + +def test_uc8279_offset_height_plus_height_must_fit_16_bits( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """offset_height plus the panel height is the gate count, which the controller holds in 16 bits.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="must not exceed"): + run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 16, "height": 2, "offset_height": 0xFFFF}, + } + ) + + +def test_uc8279_x4pro_explicit_dimensions_keep_the_model_offset( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Writing out the X4 Pro's dimensions without offset_height keeps the panel's offset of 120.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 12, "mosi_pin": 11}) + + config = run_schema_validation( + { + "id": "test_display", + "model": "xteink-x4pro-uc8279", + "dimensions": {"width": 800, "height": 480}, + } + ) + assert MODELS["XTEINK-X4PRO-UC8279"].get_constructor_args(config) == (120,) + + +def test_offset_height_rejected_by_models_without_it( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A model that does not use offset_height rejects it rather than ignoring it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="offset_height"): + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "dimensions": {"width": 200, "height": 200, "offset_height": 8}, + } + ) + + def test_enable_pin_code_generation( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/epaper_spi/display/test_uc8279_transfer.cpp b/tests/components/epaper_spi/display/test_uc8279_transfer.cpp new file mode 100644 index 00000000000..a0a1e1258c5 --- /dev/null +++ b/tests/components/epaper_spi/display/test_uc8279_transfer.cpp @@ -0,0 +1,237 @@ +#include + +#include +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_uc8279.h" + +namespace esphome::epaper_spi::testing { + +/// A reset pin that counts how often it is driven low. +class CountingPin : public RecordingPin { + public: + void digital_write(bool value) override { + if (!value) + this->pulses++; + RecordingPin::digital_write(value); + } + int pulses{0}; +}; + +/// A 16x2 panel bonded to gates 1 and 2 of three, so gate 0 is outside the panel. +class TestableUC8279 : public EPaperUC8279 { + public: + TestableUC8279() : EPaperUC8279("test", 16, 2, nullptr, 0, 1) {} + + void install(spi::SPIDelegate *delegate, uint8_t full_update_every) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_reset_pin(&this->reset_pin); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->set_full_update_every(full_update_every); + } + + void set_frame(std::initializer_list bytes) { + size_t i = 0; + for (const uint8_t byte : bytes) + this->buffer_[i++] = byte; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + /// A whole update with the frame already drawn, run through loop() as the main loop would. + void run_update() { + this->set_auto_clear(false); + this->state_ = EPaperState::UPDATE; + const uint32_t deadline = millis() + 2000; + while (this->state_ != EPaperState::IDLE) { + ASSERT_LT(millis(), deadline) << "update did not finish"; + this->loop(); + } + } + + bool transfer() { return this->transfer_data(); } + + using EPaperUC8279::on_safe_shutdown; + + RecordingPin dc; + CountingPin reset_pin; +}; + +using Bytes = std::vector; + +static size_t count(const Bytes &commands, uint8_t command) { + return std::count(commands.begin(), commands.end(), command); +} + +/// A full refresh writes the new image to DTM2 and white to DTM1, for every gate the controller scans: +/// white on the gate outside the panel, then the panel's rows. +TEST(EPaperUC8279, FullRefreshWritesTheImageAndAWhitePreviousImage) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_TRUE(display.reset_pin.level) << "reset pin left low"; + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.data[0x10].size(), 12u) << "DTM1 is not the white previous image plus the image written after"; + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); + EXPECT_EQ(count(bus.commands, 0x91), 0u) << "a full refresh entered partial mode"; + EXPECT_EQ(bus.data[0x00], (Bytes{0x17, 0x4D})) << "the OTP LUT was not selected before the refresh"; +} + +/// While partial updates are in use, DTM1 is rewritten with the image after every refresh, so the next +/// partial one compares against the image on the panel. The panel then waits in standby, since its own +/// deep sleep loses both planes. +TEST(EPaperUC8279, PreviousImageIsRewrittenAfterEveryRefresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.commands.back(), 0x02) << "the panel was not powered off after DTM1 was written"; + EXPECT_EQ(count(bus.commands, 0x07), 0u) << "the panel was put into deep sleep"; +} + +/// A partial refresh writes only DTM2, without a reset or the configuration, inside a partial window +/// covering the panel's gates. +TEST(EPaperUC8279, PartialRefreshWritesOnlyTheNewImage) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + + EXPECT_EQ(display.reset_pin.pulses, pulses) << "the partial refresh reset the panel"; + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})) + << "DTM1 was written before the refresh, or not rewritten after it"; + EXPECT_EQ(bus.data[0x90], (Bytes{0x00, 0x00, 0x00, 0x0F, 0x00, 0x01, 0x00, 0x02, 0x01})) + << "the partial window does not cover the panel's gates"; + const auto refresh = std::find(bus.commands.begin(), bus.commands.end(), 0x12); + const auto dtm1 = std::find(bus.commands.begin(), bus.commands.end(), 0x10); + EXPECT_LT(refresh, dtm1) << "DTM1 was written before the refresh"; +} + +/// Without partial updates the panel goes into its deep sleep after every update, and the next update +/// resets it and writes both planes again. +TEST(EPaperUC8279, EveryUpdateFullSleepsAndResetsEachTime) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + EXPECT_EQ(bus.data[0x07], (Bytes{0xA5})); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))) << "DTM1 was rewritten without partial updates"; + bus.clear(); + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))); + EXPECT_EQ(count(bus.commands, 0x91), 0u); +} + +/// The rewrite of DTM1 is spread over loop iterations like the image transfer, and the state machine +/// waits for it: the panel is powered off and the update finishes only once all of it is written. +TEST(EPaperUC8279, PreviousImageRewriteIsTimeSliced) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); // every row overruns the time slice + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.commands.back(), 0x02); +} + +/// In a full update the white DTM1 plane starts on the pass after DTM2 ends, so one pass never writes both planes. +TEST(EPaperUC8279, FullTransferWritesOnePlanePerPass) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + + EXPECT_FALSE(display.transfer()); + EXPECT_EQ(count(bus.commands, 0x13), 1u); + EXPECT_EQ(count(bus.commands, 0x10), 0u) << "DTM1 started in the same pass as DTM2"; + + EXPECT_TRUE(display.transfer()); + EXPECT_EQ(count(bus.commands, 0x13), 1u); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))); +} + +/// The full refresh that comes round after partial ones resets the controller, configures it again and writes a white +/// DTM1 over the image it held, as the first full refresh does. +TEST(EPaperUC8279, FullRefreshAfterPartialOnesStartsAfresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 2); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.set_frame({0x0F, 0xF0, 0x00, 0x00}); + display.run_update(); + EXPECT_EQ(display.reset_pin.pulses, pulses + 1) << "the full refresh did not reset the controller"; + EXPECT_EQ(count(bus.commands, 0x91), 0u) << "the full refresh entered partial mode"; + ASSERT_EQ(bus.data[0x10].size(), 12u); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x00, 0x00})); +} + +/// A requested full update, between partial ones, is a full refresh from scratch too. +TEST(EPaperUC8279, RequestedFullUpdateStartsAfresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.request_full_update(); + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + EXPECT_EQ(display.reset_pin.pulses, pulses + 1); + EXPECT_EQ(count(bus.commands, 0x91), 0u); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); +} + +/// Before a restart or the controller's deep sleep the panel goes into its own deep sleep, even while partial updates +/// would otherwise leave it in standby. +TEST(EPaperUC8279, ShutdownPutsThePanelToSleep) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + display.on_safe_shutdown(); + EXPECT_EQ(bus.data[0x07], (Bytes{0xA5})); +} + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 6a487064cbc..144c60dd2ff 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -435,3 +435,27 @@ display: allow_other_uses: true number: GPIO4 inverted: true + + # Xteink X4 Pro (800x480, UC8279) + # Pins overridden to avoid conflicts with the other display configs + # full_update_every > 1 exercises the partial refresh path + - platform: epaper_spi + spi_id: spi_bus + model: xteink-x4pro-uc8279 + full_update_every: 10 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); From 0f6fc503bd0b62cace4ebce4fe202f62462fc2b1 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:01:14 -0500 Subject: [PATCH 26/50] [sen6x] Add temperature compensation, acceleration and startup delay (#18781) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 43 +++++++++++-- esphome/components/sen6x/sen6x.h | 31 ++++++++- esphome/components/sen6x/sensor.py | 63 +++++++++++++++++++ tests/components/sen6x/common.yaml | 10 +++ .../components/sen6x/validate.esp32-idf.yaml | 8 ++- 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index dabf16d760b..42bc3aea6a6 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -1,6 +1,7 @@ #include "sen6x.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include #include namespace esphome::sen6x { @@ -25,7 +26,6 @@ static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; // Accepted range of a runtime pressure source, the same as the schema's ambient_pressure_compensation static constexpr float AMBIENT_PRESSURE_MIN_HPA = 700.0f; static constexpr float AMBIENT_PRESSURE_MAX_HPA = 1200.0f; -static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -45,6 +45,8 @@ static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; +static constexpr uint16_t SEN6X_CMD_TEMPERATURE_COMPENSATION = 0x60B2; +static constexpr uint16_t SEN6X_CMD_TEMPERATURE_ACCELERATION = 0x6100; static constexpr uint16_t SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL = 0x6711; static constexpr uint16_t SEN6X_CMD_AMBIENT_PRESSURE = 0x6720; static constexpr uint16_t SEN6X_CMD_SENSOR_ALTITUDE = 0x6736; @@ -200,6 +202,16 @@ bool SEN6XComponent::write_setup_step_(SetupStep step) { return false; this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); return true; + case SETUP_STEP_TEMPERATURE_COMPENSATION: + if (!this->temperature_compensation_.has_value()) + return false; + this->write_temperature_compensation_(this->temperature_compensation_.value()); + return true; + case SETUP_STEP_TEMPERATURE_ACCELERATION: + if (!this->temperature_acceleration_.has_value()) + return false; + this->write_temperature_acceleration_(this->temperature_acceleration_.value()); + return true; case SETUP_STEP_CO2_ASC: if (this->co2_sensor_ == nullptr || !this->co2_asc_.has_value()) return false; @@ -220,6 +232,18 @@ bool SEN6XComponent::write_setup_step_(SetupStep step) { } } +bool SEN6XComponent::write_temperature_compensation_(const TemperatureCompensation &compensation) { + // Word 4 selects compensation slot 0; other slots are not exposed + uint16_t params[4] = {static_cast(compensation.offset), + static_cast(compensation.normalized_offset_slope), compensation.time_constant, 0}; + return this->write_config_words_(SEN6X_CMD_TEMPERATURE_COMPENSATION, params, 4); +} + +bool SEN6XComponent::write_temperature_acceleration_(const TemperatureAcceleration &acceleration) { + uint16_t params[4] = {acceleration.k, acceleration.p, acceleration.t1, acceleration.t2}; + return this->write_config_words_(SEN6X_CMD_TEMPERATURE_ACCELERATION, params, 4); +} + bool SEN6XComponent::write_setup_register_(uint16_t i2c_command, uint16_t value) { return this->write_config_words_(i2c_command, &value, 1); } @@ -264,10 +288,21 @@ void SEN6XComponent::dump_config() { " Product: %s\n" " Serial: %s\n" " Firmware: %u.%u\n" - " Address: 0x%02X", + " Address: 0x%02X\n" + " Startup delay: %" PRIu32 " ms", this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_, - this->firmware_version_minor_, this->address_); + this->firmware_version_minor_, this->address_, this->startup_delay_ms_); LOG_UPDATE_INTERVAL(this); + if (this->temperature_compensation_.has_value()) { + const auto &comp = this->temperature_compensation_.value(); + ESP_LOGCONFIG(TAG, " Temperature compensation: offset=%.2f slope=%.4f time_constant=%us", comp.offset / 200.0f, + comp.normalized_offset_slope / 10000.0f, comp.time_constant); + } + if (this->temperature_acceleration_.has_value()) { + const auto &accel = this->temperature_acceleration_.value(); + ESP_LOGCONFIG(TAG, " Temperature acceleration: K=%.1f P=%.1f T1=%.1f T2=%.1f", accel.k / 10.0f, accel.p / 10.0f, + accel.t1 / 10.0f, accel.t2 / 10.0f); + } // setup() clears co2_sensor_ once it detects a variant without CO2 if (this->co2_sensor_ != nullptr) { if (this->co2_asc_.has_value()) { @@ -557,7 +592,7 @@ void SEN6XComponent::on_measurement_started_() { } // Values need the warm-up period again after every start this->startup_complete_ = false; - this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; }); + this->set_timeout(TIMEOUT_STARTUP, this->startup_delay_ms_, [this]() { this->startup_complete_ = true; }); } void SEN6XComponent::start_measurement() { diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 43d0ce00120..8cc187ebf2b 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -20,6 +20,21 @@ struct GasTuning { uint16_t gain_factor; }; +// Raw values in the device's fixed-point encoding (offset x200, slope x10000) +struct TemperatureCompensation { + int16_t offset; + int16_t normalized_offset_slope; + uint16_t time_constant; +}; + +// Raw values in the device's fixed-point encoding (all x10) +struct TemperatureAcceleration { + uint16_t k; + uint16_t p; + uint16_t t1; + uint16_t t2; +}; + // The CO2 settings a config may leave out: altitude is 0 to 3000 m, pressure 700 to 1200 hPa inline constexpr uint16_t ALTITUDE_UNSET = 0xFFFF; inline constexpr uint16_t PRESSURE_UNSET = 0; @@ -62,6 +77,13 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void set_startup_delay(uint32_t delay_ms) { this->startup_delay_ms_ = delay_ms; } + void set_temperature_compensation(int16_t offset, int16_t normalized_offset_slope, uint16_t time_constant) { + this->temperature_compensation_ = TemperatureCompensation{offset, normalized_offset_slope, time_constant}; + } + void set_temperature_acceleration(uint16_t k, uint16_t p, uint16_t t1, uint16_t t2) { + this->temperature_acceleration_ = TemperatureAcceleration{k, p, t1, t2}; + } void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } @@ -75,6 +97,8 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S enum SetupStep : uint8_t { SETUP_STEP_VOC_TUNING, SETUP_STEP_NOX_TUNING, + SETUP_STEP_TEMPERATURE_COMPENSATION, + SETUP_STEP_TEMPERATURE_ACCELERATION, SETUP_STEP_CO2_ASC, SETUP_STEP_ALTITUDE, SETUP_STEP_AMBIENT_PRESSURE, @@ -88,6 +112,8 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); bool write_setup_register_(uint16_t i2c_command, uint16_t value); + bool write_temperature_compensation_(const TemperatureCompensation &compensation); + bool write_temperature_acceleration_(const TemperatureAcceleration &acceleration); void write_ambient_pressure_(uint16_t pressure_hpa); void update_ambient_pressure_compensation_(float pressure_hpa); uint32_t command_wait_remaining_() const; @@ -102,14 +128,17 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string serial_number_; optional voc_tuning_params_; optional nox_tuning_params_; + optional temperature_acceleration_; + optional temperature_compensation_; + uint16_t read_cmd_{0}; sensor::Sensor *ambient_pressure_source_{nullptr}; + uint32_t startup_delay_ms_{60000}; Sen6xType sen6x_type_{UNKNOWN}; // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap uint32_t command_started_at_{0}; uint32_t command_wait_{0}; uint32_t co2_started_at_{0}; uint32_t co2_wait_{0}; - uint16_t read_cmd_{0}; uint16_t altitude_compensation_{ALTITUDE_UNSET}; uint16_t ambient_pressure_{PRESSURE_UNSET}; // Last pressure written to the device, used to skip redundant writes diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index b219caafa09..c62aa5af4cd 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -19,13 +19,18 @@ from esphome.const import ( CONF_INDEX_OFFSET, CONF_LEARNING_TIME_GAIN_HOURS, CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_NORMALIZED_OFFSET_SLOPE, CONF_NOX, + CONF_OFFSET, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_STARTUP_DELAY, CONF_STD_INITIAL, CONF_TEMPERATURE, + CONF_TEMPERATURE_COMPENSATION, + CONF_TIME_CONSTANT, CONF_TYPE, CONF_VOC, DEVICE_CLASS_CARBON_DIOXIDE, @@ -47,6 +52,17 @@ from esphome.const import ( ) from esphome.types import ConfigType +CONF_TEMPERATURE_ACCELERATION = "temperature_acceleration" +CONF_K = "k" +CONF_P = "p" +CONF_T1 = "t1" +CONF_T2 = "t2" + +# The device takes these as fixed-point words: offset x200, slope x10000, acceleration x10 +TEMPERATURE_OFFSET_SCALE = 200 +TEMPERATURE_SLOPE_SCALE = 10000 +TEMPERATURE_ACCELERATION_SCALE = 10 + CODEOWNERS = ["@martgras", "@mebner86", "@tuct"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] @@ -93,6 +109,8 @@ def _gas_index_schema( ).extend({cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema(tuning_schema)}) +_acceleration_parameter = cv.float_range(min=0.1, max=6553.5) + CONFIG_SCHEMA = cv.All( cv.rename_key(CONF_VOC, CONF_VOC_INDEX, removed_in="2027.2.0", component="sen6x"), cv.rename_key(CONF_NOX, CONF_NOX_INDEX, removed_in="2027.2.0", component="sen6x"), @@ -182,6 +200,31 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_STARTUP_DELAY, default="60s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(hours=1)), + ), + cv.Optional(CONF_TEMPERATURE_COMPENSATION): cv.Schema( + { + cv.Optional(CONF_OFFSET, default=0): cv.float_range( + min=-163.84, max=163.835 + ), + cv.Optional( + CONF_NORMALIZED_OFFSET_SLOPE, default=0 + ): cv.float_range(min=-3.2768, max=3.2767), + cv.Optional(CONF_TIME_CONSTANT, default=0): cv.int_range( + min=0, max=65535 + ), + } + ), + cv.Optional(CONF_TEMPERATURE_ACCELERATION): cv.Schema( + { + cv.Optional(CONF_K, default=20.0): _acceleration_parameter, + cv.Optional(CONF_P, default=20.0): _acceleration_parameter, + cv.Optional(CONF_T1, default=100.0): _acceleration_parameter, + cv.Optional(CONF_T2, default=300.0): _acceleration_parameter, + } + ), } ) .extend(cv.polling_component_schema("60s")) @@ -208,6 +251,26 @@ async def to_code(config: ConfigType) -> None: if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) + cg.add(var.set_startup_delay(config[CONF_STARTUP_DELAY])) + + if (comp := config.get(CONF_TEMPERATURE_COMPENSATION)) is not None: + cg.add( + var.set_temperature_compensation( + round(comp[CONF_OFFSET] * TEMPERATURE_OFFSET_SCALE), + round(comp[CONF_NORMALIZED_OFFSET_SLOPE] * TEMPERATURE_SLOPE_SCALE), + comp[CONF_TIME_CONSTANT], + ) + ) + if (accel := config.get(CONF_TEMPERATURE_ACCELERATION)) is not None: + cg.add( + var.set_temperature_acceleration( + *( + round(accel[key] * TEMPERATURE_ACCELERATION_SCALE) + for key in (CONF_K, CONF_P, CONF_T1, CONF_T2) + ) + ) + ) + for key, func_name in SENSOR_MAP.items(): if cfg := config.get(key): sens = await sensor.new_sensor(cfg) diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index eed0dd51d67..d276cecbc7c 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -8,6 +8,16 @@ sensor: id: sen6x_sensor type: SEN69C i2c_id: i2c_bus + startup_delay: 30s + temperature_compensation: + offset: -2.5 + normalized_offset_slope: 0.01 + time_constant: 60 + temperature_acceleration: + k: 25.0 + p: 15.0 + t1: 120.0 + t2: 240.0 temperature: name: Temperature accuracy_decimals: 1 diff --git a/tests/components/sen6x/validate.esp32-idf.yaml b/tests/components/sen6x/validate.esp32-idf.yaml index 3ae23af4ac4..927a8d478a9 100644 --- a/tests/components/sen6x/validate.esp32-idf.yaml +++ b/tests/components/sen6x/validate.esp32-idf.yaml @@ -1,5 +1,5 @@ -# Config-only: partial algorithm_tuning blocks, so the schema defaults fill in the -# keys that are left out. +# Config-only: partial algorithm_tuning, temperature_compensation and +# temperature_acceleration blocks, so the schema defaults fill in the keys that are left out. packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml @@ -8,6 +8,10 @@ sensor: id: sen6x_partial_tuning type: SEN65 i2c_id: i2c_bus + temperature_compensation: + offset: 1.5 + temperature_acceleration: + k: 30 voc_index: name: VOC Index algorithm_tuning: From 022733c46b6b41c21e35e35b52c6e6518fbdef97 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:16:45 -0500 Subject: [PATCH 27/50] [sen6x] Add PM number concentration sensors (#18782) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 57 ++++++++++++++++++++++++++++++ esphome/components/sen6x/sen6x.h | 12 +++++++ esphome/components/sen6x/sensor.py | 29 +++++++++++++++ esphome/core/defines.h | 1 + tests/components/sen6x/common.yaml | 10 ++++++ 5 files changed, 109 insertions(+) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 42bc3aea6a6..aaad70dd940 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include #include +#include namespace esphome::sen6x { @@ -38,6 +39,9 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN65 = 0x0446; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; +#ifdef USE_SEN6X_NUMBER_CONCENTRATION +static constexpr uint16_t SEN6X_CMD_READ_NUMBER_CONCENTRATION = 0x0316; +#endif static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; static constexpr uint16_t SEN6X_CMD_STOP_MEASUREMENTS = 0x0104; static constexpr uint16_t SEN6X_CMD_START_FAN_CLEANING = 0x5607; @@ -323,6 +327,13 @@ void SEN6XComponent::dump_config() { LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM 4.0", this->pm_4_0_sensor_); LOG_SENSOR(" ", "PM 10.0", this->pm_10_0_sensor_); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + LOG_SENSOR(" ", "PMC 0.5", this->pmc_0_5_sensor_); + LOG_SENSOR(" ", "PMC 1.0", this->pmc_1_0_sensor_); + LOG_SENSOR(" ", "PMC 2.5", this->pmc_2_5_sensor_); + LOG_SENSOR(" ", "PMC 4.0", this->pmc_4_0_sensor_); + LOG_SENSOR(" ", "PMC 10.0", this->pmc_10_0_sensor_); +#endif LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); LOG_SENSOR(" ", "VOC", this->voc_sensor_); @@ -361,6 +372,10 @@ void SEN6XComponent::update() { // -> write_command (read measurement) // -> timeout I2C_READ_DELAY // -> parse_and_publish_measurements_() + // -> with pmc_* sensors: read_number_concentration_() + // -> write_command (read number concentration) + // -> timeout I2C_READ_DELAY + // -> parse_and_publish_number_concentration_() // // All timeouts share a single ID (TIMEOUT_POLL) since only one is active // at a time. cancel_timeout in update() stops any in-flight chain. @@ -544,9 +559,51 @@ void SEN6XComponent::parse_and_publish_measurements_() { if (this->co2_sensor_ != nullptr) this->co2_sensor_->publish_state(co2); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + // The define says some device in the build has pmc sensors; this one may not + if (this->has_number_concentration_sensor_()) { + this->read_number_concentration_(); // the chain ends there, with the warning cleared + return; + } +#endif this->status_clear_warning(); } +#ifdef USE_SEN6X_NUMBER_CONCENTRATION +bool SEN6XComponent::has_number_concentration_sensor_() const { + return this->pmc_0_5_sensor_ != nullptr || this->pmc_1_0_sensor_ != nullptr || this->pmc_2_5_sensor_ != nullptr || + this->pmc_4_0_sensor_ != nullptr || this->pmc_10_0_sensor_ != nullptr; +} + +void SEN6XComponent::read_number_concentration_() { + if (!this->write_command(SEN6X_CMD_READ_NUMBER_CONCENTRATION)) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read number concentration failed (%d)", this->last_error_); + return; + } + + this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { this->parse_and_publish_number_concentration_(); }); +} + +void SEN6XComponent::parse_and_publish_number_concentration_() { + sensor::Sensor *sensors[] = {this->pmc_0_5_sensor_, this->pmc_1_0_sensor_, this->pmc_2_5_sensor_, + this->pmc_4_0_sensor_, this->pmc_10_0_sensor_}; + uint16_t measurements[std::size(sensors)]; + + if (!this->read_data(measurements, std::size(measurements))) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_); + return; + } + + for (size_t i = 0; i < std::size(sensors); i++) { + if (sensors[i] != nullptr) + sensors[i]->publish_state(measurements[i] == 0xFFFF ? NAN : measurements[i] / 10.0f); + } + this->status_clear_warning(); +} +#endif + void SEN6XComponent::write_ambient_pressure_(uint16_t pressure_hpa) { if (this->write_setup_register_(SEN6X_CMD_AMBIENT_PRESSURE, pressure_hpa)) this->last_ambient_pressure_ = pressure_hpa; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 8cc187ebf2b..f3054a8ffbd 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -44,6 +44,13 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S SUB_SENSOR(pm_2_5) SUB_SENSOR(pm_4_0) SUB_SENSOR(pm_10_0) +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + SUB_SENSOR(pmc_0_5) + SUB_SENSOR(pmc_1_0) + SUB_SENSOR(pmc_2_5) + SUB_SENSOR(pmc_4_0) + SUB_SENSOR(pmc_10_0) +#endif SUB_SENSOR(temperature) SUB_SENSOR(humidity) SUB_SENSOR(voc) @@ -123,6 +130,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + bool has_number_concentration_sensor_() const; + void read_number_concentration_(); + void parse_and_publish_number_concentration_(); +#endif std::string product_name_; std::string serial_number_; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index c62aa5af4cd..953b6529ef6 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -26,6 +26,11 @@ from esphome.const import ( CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_PMC_0_5, + CONF_PMC_1_0, + CONF_PMC_2_5, + CONF_PMC_4_0, + CONF_PMC_10_0, CONF_STARTUP_DELAY, CONF_STD_INITIAL, CONF_TEMPERATURE, @@ -40,12 +45,14 @@ from esphome.const import ( DEVICE_CLASS_PM25, DEVICE_CLASS_TEMPERATURE, ICON_CHEMICAL_WEAPON, + ICON_COUNTER, ICON_MOLECULE_CO2, ICON_RADIATOR, ICON_THERMOMETER, ICON_WATER_PERCENT, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, + UNIT_COUNTS_PER_CUBIC_CENTIMETER, UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PARTS_PER_MILLION, UNIT_PERCENT, @@ -73,6 +80,15 @@ SEN6XComponent = sen6x_ns.class_( ) +_PMC_KEYS = (CONF_PMC_0_5, CONF_PMC_1_0, CONF_PMC_2_5, CONF_PMC_4_0, CONF_PMC_10_0) +_PMC_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS_PER_CUBIC_CENTIMETER, + icon=ICON_COUNTER, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, +) + + def _gas_index_schema( *, index_offset: int, @@ -147,6 +163,11 @@ CONFIG_SCHEMA = cv.All( device_class=DEVICE_CLASS_PM10, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_PMC_0_5): _PMC_SCHEMA, + cv.Optional(CONF_PMC_1_0): _PMC_SCHEMA, + cv.Optional(CONF_PMC_2_5): _PMC_SCHEMA, + cv.Optional(CONF_PMC_4_0): _PMC_SCHEMA, + cv.Optional(CONF_PMC_10_0): _PMC_SCHEMA, cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, icon=ICON_THERMOMETER, @@ -236,6 +257,11 @@ SENSOR_MAP = { CONF_PM_2_5: "set_pm_2_5_sensor", CONF_PM_4_0: "set_pm_4_0_sensor", CONF_PM_10_0: "set_pm_10_0_sensor", + CONF_PMC_0_5: "set_pmc_0_5_sensor", + CONF_PMC_1_0: "set_pmc_1_0_sensor", + CONF_PMC_2_5: "set_pmc_2_5_sensor", + CONF_PMC_4_0: "set_pmc_4_0_sensor", + CONF_PMC_10_0: "set_pmc_10_0_sensor", CONF_TEMPERATURE: "set_temperature_sensor", CONF_HUMIDITY: "set_humidity_sensor", CONF_VOC_INDEX: "set_voc_sensor", @@ -250,6 +276,9 @@ async def to_code(config: ConfigType) -> None: if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) + # The number concentration read and its sensors compile in only when one is configured + if any(key in config for key in _PMC_KEYS): + cg.add_define("USE_SEN6X_NUMBER_CONCENTRATION") cg.add(var.set_startup_delay(config[CONF_STARTUP_DELAY])) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 13dc3492f29..1fa6f7822a5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -275,6 +275,7 @@ #define USE_SCRIPT_ON_STOP #define USE_SELECT #define USE_SELEC_METER_EM4M +#define USE_SEN6X_NUMBER_CONCENTRATION #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index d276cecbc7c..6e39256d159 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -40,6 +40,16 @@ sensor: name: PM <10µm Weight concentration id: sen6x_pm_10_0 accuracy_decimals: 1 + pmc_0_5: + name: PM <0.5µm Number concentration + pmc_1_0: + name: PM <1µm Number concentration + pmc_2_5: + name: PM <2.5µm Number concentration + pmc_4_0: + name: PM <4µm Number concentration + pmc_10_0: + name: PM <10µm Number concentration nox_index: name: NOx Index algorithm_tuning: From 1a28a9220ba1f77f8d9576562adb0baacba3ed0c Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 18 Oct 2026 00:35:46 +0200 Subject: [PATCH 28/50] [uart] Add a binary sensor that fires on a byte sequence (#14032) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/uart/binary_sensor/__init__.py | 101 ++++++++++++++++++ .../uart/binary_sensor/uart_binary_sensor.cpp | 50 +++++++++ .../uart/binary_sensor/uart_binary_sensor.h | 37 +++++++ esphome/core/defines.h | 1 + esphome/core/helpers.h | 8 ++ tests/components/uart/test.esp32-idf.yaml | 10 ++ tests/components/uart/test.esp8266-ard.yaml | 10 ++ 7 files changed, 217 insertions(+) create mode 100644 esphome/components/uart/binary_sensor/__init__.py create mode 100644 esphome/components/uart/binary_sensor/uart_binary_sensor.cpp create mode 100644 esphome/components/uart/binary_sensor/uart_binary_sensor.h diff --git a/esphome/components/uart/binary_sensor/__init__.py b/esphome/components/uart/binary_sensor/__init__.py new file mode 100644 index 00000000000..07ab31ab19c --- /dev/null +++ b/esphome/components/uart/binary_sensor/__init__.py @@ -0,0 +1,101 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor, uart +import esphome.config_validation as cv +from esphome.const import CONF_DATA, CONF_UART_ID +from esphome.core import CORE, ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import uart_ns, validate_raw_data + +DEPENDENCIES = ["uart"] +DOMAIN = "uart_binary_sensor" + +CONF_HUB_ID = "hub_id" + +UARTBinarySensorHub = uart_ns.class_( + "UARTBinarySensorHub", uart.UARTDevice, cg.Component +) + +# Every sensor on a bus is one matcher of that bus's hub; the define is the largest hub +_request_matcher_slot = cg.slot_counter("UART_BINARY_SENSOR_COUNT") + +CONFIG_SCHEMA = ( + # Initially off, so the first sequence after boot is an OFF to ON edge for on_press + binary_sensor.binary_sensor_schema(binary_sensor.BinarySensorInitiallyOff) + .extend( + { + cv.GenerateID(CONF_HUB_ID): cv.declare_id(UARTBinarySensorHub), + # An empty pattern would match every byte; the hub's buffer index is a byte + cv.Required(CONF_DATA): cv.All( + validate_raw_data, cv.Length(min=1, max=255) + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +_validate_bus = uart.final_validate_device_schema("uart binary_sensor", require_rx=True) + + +def _one_hub_per_bus(config: ConfigType) -> ConfigType: + """The first matching sensor takes the bytes and clears them, so a sequence that contains + another on the same bus would leave one of them dead; and the sensors of a bus share the hub + the first one creates, so only that one can name it and claim the bus's receive side.""" + data = bytes(config[CONF_DATA]) + first = True + for other in fv.full_config.get().get("binary_sensor", []): + if ( + other.get("platform") != "uart" + or other[CONF_UART_ID] != config[CONF_UART_ID] + ): + continue + if other is config: + break # Every sensor before this one on the bus has been seen + first = False + other_data = bytes(other[CONF_DATA]) + if other_data in data or data in other_data: + raise cv.Invalid( + f"Another uart binary_sensor on '{config[CONF_UART_ID]}' has data this pattern " + "would match as well" + ) + if config[CONF_HUB_ID].is_manual: + raise cv.Invalid( + f"Set hub_id on the first uart binary_sensor of '{config[CONF_UART_ID]}'; " + "the others share its hub" + ) + if first: + _validate_bus(config) + return config + + +FINAL_VALIDATE_SCHEMA = _one_hub_per_bus + + +def _hubs() -> dict[ID, cg.MockObj]: + """The hub of each bus, keyed by the bus id.""" + return CORE.data.setdefault(DOMAIN, {}) + + +async def _hub_for(config: ConfigType) -> cg.MockObj: + """The one hub that reads the bus for every sensor on it, created by the first sensor.""" + hubs = _hubs() + if (hub := hubs.get(config[CONF_UART_ID])) is None: + hub = hubs[config[CONF_UART_ID]] = cg.new_Pvariable(config[CONF_HUB_ID]) + await cg.register_component(hub, config) + await uart.register_uart_device(hub, config) + return hub + + +async def to_code(config: ConfigType) -> None: + hub = await _hub_for(config) + var = await binary_sensor.new_binary_sensor(config) + data = config[CONF_DATA] + _request_matcher_slot(str(hub)) + cg.add( + hub.add_event_matcher( + var, automation.progmem_bytes("uart_binary_sensor_data", data), len(data) + ) + ) diff --git a/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp new file mode 100644 index 00000000000..9bf9e726561 --- /dev/null +++ b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp @@ -0,0 +1,50 @@ +#include "uart_binary_sensor.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::uart { + +ESPHOME_LOG_TAG(TAG, "uart.binary_sensor"); + +void UARTBinarySensorHub::dump_config() { + ESP_LOGCONFIG(TAG, "UART Binary Sensor:"); + for (const auto &matcher : this->matchers_) { + LOG_BINARY_SENSOR(" ", "Pattern", matcher.sensor); + } +} + +void UARTBinarySensorHub::loop() { + while (this->available()) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + this->buffer_.push_overwrite(byte); + for (const auto &matcher : this->matchers_) { + // The byte just read has to be the pattern's last one before the rest is compared + if (progmem_read_byte(&matcher.data[matcher.data_len - 1]) != byte || !this->matches_(matcher)) { + continue; + } + // A momentary press, as the remote receiver binary sensors publish theirs + matcher.sensor->publish_state(true); + matcher.sensor->publish_state(false); + this->buffer_.clear(); + break; + } + } +} + +// Whether the pattern, apart from its last byte, ends the buffer +bool UARTBinarySensorHub::matches_(const EventMatcher &matcher) const { + if (this->buffer_.size() < matcher.data_len) { + return false; + } + const size_t start = this->buffer_.size() - matcher.data_len; + for (size_t i = 0; i + 1 < matcher.data_len; i++) { + if (this->buffer_[start + i] != progmem_read_byte(&matcher.data[i])) { + return false; + } + } + return true; +} + +} // namespace esphome::uart diff --git a/esphome/components/uart/binary_sensor/uart_binary_sensor.h b/esphome/components/uart/binary_sensor/uart_binary_sensor.h new file mode 100644 index 00000000000..71ccda9ef21 --- /dev/null +++ b/esphome/components/uart/binary_sensor/uart_binary_sensor.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +// One per bus: reads the bytes once and presses each sensor whose pattern ends the stream +class UARTBinarySensorHub : public UARTDevice, public Component { + public: + void setup() override { this->buffer_.init(this->max_len_); } + void loop() override; + void dump_config() override; + + // The pattern lives in flash; called once per sensor by the generated code + void add_event_matcher(binary_sensor::BinarySensor *sensor, const uint8_t *data, uint8_t data_len) { + this->matchers_.push_back({sensor, data, data_len}); + this->max_len_ = std::max(this->max_len_, data_len); + } + + protected: + struct EventMatcher { + binary_sensor::BinarySensor *sensor; + const uint8_t *data; + uint8_t data_len; + }; + + bool matches_(const EventMatcher &matcher) const; + + StaticVector matchers_; + FixedRingBuffer buffer_; + uint8_t max_len_{0}; +}; + +} // namespace esphome::uart diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1fa6f7822a5..24f66a87518 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -167,6 +167,7 @@ #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 +#define UART_BINARY_SENSOR_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define MICS_5524_GAS_COUNT 5 #define USE_MICS_5524_DATASHEET diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 72bd3a0c6ba..2989655a29a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -576,6 +576,14 @@ template::max()> ConstIterator begin() const { return ConstIterator(this, 0); } ConstIterator end() const { return ConstIterator(this, this->count_); } + /// The element `pos` places after the oldest one; the sum is widened so a small index type cannot truncate it + const T &operator[](index_type pos) const { + size_t index = static_cast(this->head_) + pos; + if (index >= this->capacity_) + index -= this->capacity_; + return this->data_[index]; + } + protected: T *data_{nullptr}; index_type head_{0}; diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index 3bb87aa3cc7..02b53a89e44 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -120,3 +120,13 @@ event: event_types: - "string_event_A": "*A#" - "bytes_event_B": [0x2A, 0x42, 0x23] + +binary_sensor: + - platform: uart + id: up + uart_id: uart_id + data: [0x1B, 0x5B, 0x41] + - platform: uart + id: down + uart_id: uart_id + data: [0x1B, 0x5B, 0x42] diff --git a/tests/components/uart/test.esp8266-ard.yaml b/tests/components/uart/test.esp8266-ard.yaml index 09d23e2925e..b8f07fa9d62 100644 --- a/tests/components/uart/test.esp8266-ard.yaml +++ b/tests/components/uart/test.esp8266-ard.yaml @@ -55,3 +55,13 @@ event: event_types: - "string_event_A": "*A#" - "bytes_event_B": [0x2A, 0x42, 0x23] + +binary_sensor: + - platform: uart + id: up + uart_id: uart_id + data: [0x1B, 0x5B, 0x41] + - platform: uart + id: down + uart_id: uart_id + data: [0x1B, 0x5B, 0x42] From 818d43f742e3fc18c22bb6d2e41f46d1e0debb73 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 17 Oct 2026 18:25:36 -0500 Subject: [PATCH 29/50] [epaper_spi] Run the loop continuously between the steps of an update (#20554) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/epaper_spi.cpp | 27 ++- esphome/components/epaper_spi/epaper_spi.h | 4 + tests/components/epaper_spi/common.h | 8 + .../epaper_spi/display/test_busy_timeout.cpp | 11 +- .../epaper_spi/display/test_fast_loop.cpp | 212 ++++++++++++++++++ 5 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_fast_loop.cpp diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 71e87e038b6..fccb5325a7f 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -133,7 +133,9 @@ void EPaperBase::update() { } void EPaperBase::wait_for_idle_(bool should_wait) { - this->waiting_for_idle_start_ = millis(); + // The wait starts when the step's delay ends, or now for a wait in the middle of a step + const uint32_t now = millis(); + this->waiting_for_idle_start_ = static_cast(this->delay_until_ - now) > 0 ? this->delay_until_ : now; this->waiting_for_idle_ = should_wait; } @@ -147,19 +149,30 @@ void EPaperBase::loop() { auto now = millis(); // using modulus arithmetic to handle wrap-around int diff = now - this->delay_until_; - if (diff < 0) + if (diff < 0) { + // Running the loop continuously cannot shorten a delay longer than one loop interval + if (-diff > static_cast(App.get_loop_interval())) { + this->high_frequency_loop_.stop(); + } else { + this->high_frequency_loop_.start(); + } return; + } if (this->waiting_for_idle_) { if (this->is_idle_()) { this->waiting_for_idle_ = false; + this->high_frequency_loop_.start(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); #endif } else { - if (now - this->waiting_for_idle_start_ > this->busy_timeout_seconds_ * 1000u) { + const uint32_t busy_for = now - this->waiting_for_idle_start_; + if (busy_for > this->busy_timeout_seconds_ * 1000u) { this->abandon_update_(); return; } + if (busy_for > FAST_BUSY_POLL_MS) + this->high_frequency_loop_.stop(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE if (now - this->waiting_for_idle_last_print_ >= 1000) { ESP_LOGV(TAG, "Waiting for idle in state %s", this->epaper_state_to_string_()); @@ -170,6 +183,8 @@ void EPaperBase::loop() { } } this->process_state_(); + if (this->is_failed()) + this->high_frequency_loop_.stop(); } // The busy pin never cleared: give up on this update so later ones are not refused, and make the next one full @@ -275,16 +290,20 @@ void EPaperBase::process_state_() { void EPaperBase::set_state_(EPaperState state, uint16_t delay) { ESP_LOGV(TAG, "Exit state %s", this->epaper_state_to_string_()); this->state_ = state; - this->wait_for_idle_(state > EPaperState::SHOULD_WAIT); // allow subclasses to nominate delays if (delay == 0) delay = this->next_delay_; this->next_delay_ = 0; this->delay_until_ = millis() + delay; + this->wait_for_idle_(state > EPaperState::SHOULD_WAIT); ESP_LOGV(TAG, "Enter state %s, delay %u, wait_for_idle=%s", this->epaper_state_to_string_(), delay, TRUEFALSE(this->waiting_for_idle_)); + // Each step of an update otherwise waits for the next loop iteration; loop() slows down again for long waits if (state == EPaperState::IDLE) { + this->high_frequency_loop_.stop(); this->disable_loop(); + } else { + this->high_frequency_loop_.start(); } } diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index d953bf3df81..d6fe4238800 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -5,6 +5,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/split_buffer/split_buffer.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome::epaper_spi { using namespace display; @@ -31,6 +32,8 @@ static constexpr uint8_t SWAP_XY = 4; static constexpr uint32_t MAX_TRANSFER_TIME = 10; // Transfer in 10ms blocks to allow the loop to run static constexpr size_t MAX_TRANSFER_SIZE = 128; +// Longer busy waits are the waveform or a stuck pin, and are polled at the normal loop pace +static constexpr uint32_t FAST_BUSY_POLL_MS = 50; static constexpr uint8_t DELAY_FLAG = 0xFF; class EPaperBase : public Display, @@ -202,6 +205,7 @@ class EPaperBase : public Display, GPIOPin *reset_pin_{}; std::vector enable_pins_{}; bool waiting_for_idle_{}; + HighFrequencyLoopRequester high_frequency_loop_; uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state uint8_t transform_{}; diff --git a/tests/components/epaper_spi/common.h b/tests/components/epaper_spi/common.h index f21e674fde2..9631f1e0776 100644 --- a/tests/components/epaper_spi/common.h +++ b/tests/components/epaper_spi/common.h @@ -50,6 +50,14 @@ class RecordingPin : public GPIOPin { bool level{true}; }; +/// A busy pin the test can hold at "busy" and release. +class SwitchableBusyPin : public RecordingPin { + public: + bool digital_read() override { return this->busy; } + + bool busy{false}; +}; + /// SPI delegate that records what reaches the bus, filing each payload under the command it /// followed; commands are the bytes written while D/C is low. Optionally burns wall-clock time on /// each data row, so a transfer can be driven past its yield deadline. diff --git a/tests/components/epaper_spi/display/test_busy_timeout.cpp b/tests/components/epaper_spi/display/test_busy_timeout.cpp index 7080619b2ff..533c795f272 100644 --- a/tests/components/epaper_spi/display/test_busy_timeout.cpp +++ b/tests/components/epaper_spi/display/test_busy_timeout.cpp @@ -7,14 +7,8 @@ #include "esphome/components/epaper_spi/epaper_spi_t133a01.h" namespace esphome::epaper_spi::testing { - -/// A busy pin the test can hold at "busy" and release. -class SwitchableBusyPin : public RecordingPin { - public: - bool digital_read() override { return this->busy; } - - bool busy{false}; -}; +// Other test files define fixtures with the same names; keep these to this file +namespace { template void loop_until_idle(T &display) { const uint32_t deadline = millis() + 1000; @@ -142,4 +136,5 @@ TEST(EPaperBusyTimeout, AbandonedUpdatePowersThePanelOff) { << "the panel was not powered off (POF) when the update was abandoned"; } +} // namespace } // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_fast_loop.cpp b/tests/components/epaper_spi/display/test_fast_loop.cpp new file mode 100644 index 00000000000..3a2a905281e --- /dev/null +++ b/tests/components/epaper_spi/display/test_fast_loop.cpp @@ -0,0 +1,212 @@ +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" + +namespace esphome::epaper_spi::testing { +// Other test files define fixtures with the same names; keep these to this file +namespace { + +constexpr uint8_t MALFORMED_INIT_SEQUENCE[] = {0x01}; + +class TestableSSD1683 : public EPaperSSD1683 { + public: + explicit TestableSSD1683(const uint8_t *init_sequence = nullptr, size_t init_sequence_length = 0) + : EPaperSSD1683("test", 16, 2, init_sequence, init_sequence_length) {} + // The request count is shared by the whole test binary, so never leave it raised + ~TestableSSD1683() { this->high_frequency_loop_.stop(); } + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_busy_pin(&this->busy); + this->set_auto_clear(false); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void draw_whole_panel() { + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + void draw_nothing() { + this->x_low_ = this->width_; + this->y_low_ = this->height_; + this->x_high_ = 0; + this->y_high_ = 0; + } + + /// Runs the loop for the given time, or until the display is idle. + void loop_for(uint32_t ms) { + const uint32_t until = millis() + ms; + while (this->state_ != EPaperState::IDLE && millis() < until) + this->loop(); + } + + EPaperState state() const { return this->state_; } + + RecordingPin dc, reset; + SwitchableBusyPin busy; +}; + +/// With no long waits the loop runs continuously from the start of an update to its end, then goes back to its +/// normal pace. +TEST(EPaperFastLoop, LoopRunsContinuouslyForShortStepsAndStopsAfterward) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + ASSERT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.draw_whole_panel(); + display.update(); + bool always_fast = true; + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::IDLE && millis() < deadline) { + always_fast &= HighFrequencyLoopRequester::is_high_frequency(); + display.loop(); + } + + ASSERT_EQ(display.state(), EPaperState::IDLE); + EXPECT_TRUE(always_fast); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()) << "the loop was left running continuously"; +} + +/// An update with nothing drawn ends straight away and does not leave the loop running continuously. +TEST(EPaperFastLoop, UpdateWithNothingDrawnStopsTheFastLoop) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.draw_nothing(); + display.update(); + display.loop(); + + ASSERT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A display marked failed part way through an update is no longer looped, so it must stop the fast loop itself. +TEST(EPaperFastLoop, FailedDisplayStopsTheFastLoop) { + TestableSSD1683 display(MALFORMED_INIT_SEQUENCE, sizeof(MALFORMED_INIT_SEQUENCE)); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (!display.is_failed() && millis() < deadline) + display.loop(); + + ASSERT_TRUE(display.is_failed()); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A long busy wait, such as the waveform or a stuck busy pin, is polled at the normal pace. Once the pin clears, +/// the next steps run continuously again. +TEST(EPaperFastLoop, LongBusyWaitUsesTheNormalPace) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.busy.busy = true; + display.draw_whole_panel(); + display.update(); + display.loop_for(FAST_BUSY_POLL_MS + 20); + + ASSERT_NE(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.busy.busy = false; + display.loop(); + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); + display.loop_for(1000); + EXPECT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A delay longer than a loop interval, such as a long reset pulse, is waited out at the normal pace. +TEST(EPaperFastLoop, LongDelayUsesTheNormalPace) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_reset_pin(&display.reset); + display.set_reset_duration(200); + + display.draw_whole_panel(); + display.update(); + display.loop_for(50); + + ASSERT_EQ(display.state(), EPaperState::RESET_END); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.loop_for(1000); + EXPECT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A step that takes several loop passes gets the fast loop back once a long busy wait before it has ended. +TEST(EPaperFastLoop, FastLoopResumesWhenALongBusyWaitEnds) { + TestableSSD1683 display; + // every row of data overruns a transfer slice, so the transfer takes a loop pass per row + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); + display.install(&bus); + + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::TRANSFER_DATA && millis() < deadline) + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + display.busy.busy = true; + display.loop_for(FAST_BUSY_POLL_MS + 20); + ASSERT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.busy.busy = false; + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA) << "the transfer should take more than one pass"; + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); +} + +class TestableT133A01 : public EPaperT133A01 { + public: + TestableT133A01() : EPaperT133A01("test", 8, 2, nullptr, 0) {} + ~TestableT133A01() { this->high_frequency_loop_.stop(); } + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_cs_pins(&this->cs, &this->cs1); + this->set_busy_pin(&this->busy); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void start_transfer() { this->set_state_(EPaperState::TRANSFER_DATA); } + + RecordingPin dc, cs, cs1; + SwitchableBusyPin busy; +}; + +/// A busy wait that starts in the middle of a step is polled continuously for its first moments, however long +/// the step has already run. +TEST(EPaperFastLoop, WaitInTheMiddleOfAStepIsPolledFast) { + TestableT133A01 display; + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + display.start_transfer(); + const uint32_t until = millis() + FAST_BUSY_POLL_MS + 20; + while (millis() < until) { + } + // the transfer waits for the busy pin after its first command, then sends a row and yields + display.loop(); + display.busy.busy = true; + display.loop(); + + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); +} + +} // namespace +} // namespace esphome::epaper_spi::testing From d1ec52e95a7506bbb4365d1f8db3aa37234e6df6 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:34:15 -0500 Subject: [PATCH 30/50] [sen6x] Add device status binary sensors (#18784) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/binary_sensor.py | 53 +++++++++++++ esphome/components/sen6x/sen6x.cpp | 90 ++++++++++++++++++++++- esphome/components/sen6x/sen6x.h | 35 +++++++++ esphome/core/defines.h | 1 + tests/components/sen6x/common.yaml | 18 +++++ 5 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sen6x/binary_sensor.py diff --git a/esphome/components/sen6x/binary_sensor.py b/esphome/components/sen6x/binary_sensor.py new file mode 100644 index 00000000000..9662e65a378 --- /dev/null +++ b/esphome/components/sen6x/binary_sensor.py @@ -0,0 +1,53 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .sensor import SEN6XComponent, sen6x_ns + +CONF_SEN6X_ID = "sen6x_id" +CONF_FAN_ERROR = "fan_error" +CONF_FAN_SPEED_WARNING = "fan_speed_warning" +CONF_RHT_ERROR = "rht_error" +CONF_GAS_ERROR = "gas_error" +CONF_CO2_ERROR = "co2_error" +CONF_HCHO_ERROR = "hcho_error" +CONF_PM_ERROR = "pm_error" + +Sen6xStatus = sen6x_ns.enum("Sen6xStatus", is_class=True) + +# Each key watches one bit of the device status register +_STATUS_KINDS = ( + (CONF_FAN_ERROR, Sen6xStatus.SEN6X_STATUS_FAN_ERROR), + (CONF_FAN_SPEED_WARNING, Sen6xStatus.SEN6X_STATUS_FAN_SPEED_WARNING), + (CONF_RHT_ERROR, Sen6xStatus.SEN6X_STATUS_RHT_ERROR), + (CONF_GAS_ERROR, Sen6xStatus.SEN6X_STATUS_GAS_ERROR), + (CONF_CO2_ERROR, Sen6xStatus.SEN6X_STATUS_CO2_ERROR), + (CONF_HCHO_ERROR, Sen6xStatus.SEN6X_STATUS_HCHO_ERROR), + (CONF_PM_ERROR, Sen6xStatus.SEN6X_STATUS_PM_ERROR), +) + +_request_status_slot = cg.slot_counter("SEN6X_STATUS_SENSOR_COUNT") + +_STATUS_SCHEMA = binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_SEN6X_ID): cv.use_id(SEN6XComponent), + **{cv.Optional(key): _STATUS_SCHEMA for key, _ in _STATUS_KINDS}, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_SEN6X_ID]) + for key, status in _STATUS_KINDS: + if (conf := config.get(key)) is None: + continue + sens = await binary_sensor.new_binary_sensor(conf) + _request_status_slot(str(hub)) + cg.add(hub.add_status_binary_sensor(sens, status)) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index aaad70dd940..9f95ed192c0 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -9,6 +9,29 @@ namespace esphome::sen6x { ESPHOME_LOG_TAG(TAG, "sen6x"); +#ifdef SEN6X_STATUS_SENSOR_COUNT +// Bit of the status register behind each kind; the CO2 error bit is 9 on SEN66 and 12 on SEN63C/SEN69C +static uint8_t status_bit(Sen6xStatus status, SEN6XComponent::Sen6xType type) { + switch (status) { + case Sen6xStatus::SEN6X_STATUS_FAN_ERROR: + return 4; + case Sen6xStatus::SEN6X_STATUS_FAN_SPEED_WARNING: + return 21; + case Sen6xStatus::SEN6X_STATUS_RHT_ERROR: + return 6; + case Sen6xStatus::SEN6X_STATUS_GAS_ERROR: + return 7; + case Sen6xStatus::SEN6X_STATUS_CO2_ERROR: + return type == SEN6XComponent::SEN66 ? 9 : 12; + case Sen6xStatus::SEN6X_STATUS_HCHO_ERROR: + return 10; + case Sen6xStatus::SEN6X_STATUS_PM_ERROR: + return 11; + } + return 0; +} +#endif + static constexpr uint8_t POLL_RETRIES = 24; // 24 attempts static constexpr uint32_t I2C_READ_DELAY = 20; // 20 ms to wait for I2C read to complete static constexpr uint32_t CMD_EXEC_DELAY = 20; // execution time of set commands (datasheet section 4.8) @@ -31,6 +54,7 @@ static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; static constexpr uint16_t SEN6X_CMD_GET_SERIAL_NUMBER = 0xD033; +static constexpr uint16_t SEN6X_CMD_READ_AND_CLEAR_DEVICE_STATUS = 0xD210; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT = 0x0300; // SEN66 only! static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN62 = 0x04A3; @@ -158,6 +182,22 @@ void SEN6XComponent::setup() { ESP_LOGE(TAG, "Formaldehyde requires SEN68 or SEN69C"); this->hcho_sensor_ = nullptr; } +#ifdef SEN6X_STATUS_SENSOR_COUNT + // A status sensor for a feature this variant lacks is dropped like the measurement sensors above, so + // an empty table skips the status read; the kept ones learn their register bit here + size_t kept = 0; + for (auto entry : this->status_sensors_) { + if ((entry.status == Sen6xStatus::SEN6X_STATUS_GAS_ERROR && !has_voc_nox) || + (entry.status == Sen6xStatus::SEN6X_STATUS_CO2_ERROR && !has_co2) || + (entry.status == Sen6xStatus::SEN6X_STATUS_HCHO_ERROR && !has_hcho)) { + ESP_LOGE(TAG, "'%s' needs a variant with that feature", entry.sensor->get_name().c_str()); + continue; + } + entry.bit = status_bit(entry.status, this->sen6x_type_); + this->status_sensors_[kept++] = entry; + } + this->status_sensors_.resize(kept); +#endif // Step 3: Read firmware version and start measurements in next loop iteration this->set_timeout(0, [this]() { @@ -340,6 +380,11 @@ void SEN6XComponent::dump_config() { LOG_SENSOR(" ", "NOx", this->nox_sensor_); LOG_SENSOR(" ", "HCHO", this->hcho_sensor_); LOG_SENSOR(" ", "CO2", this->co2_sensor_); +#ifdef SEN6X_STATUS_SENSOR_COUNT + for (const auto &entry : this->status_sensors_) { + LOG_BINARY_SENSOR(" ", "Device status", entry.sensor); + } +#endif } void SEN6XComponent::update() { @@ -363,6 +408,11 @@ void SEN6XComponent::update() { // Polling uses chained timeouts to guarantee each I2C operation completes // before the next begins. The flow is: // + // read_device_status_() + // with status sensors: write_command (read and clear device status) + // -> timeout I2C_READ_DELAY -> parse_and_publish_device_status_() + // -> poll_data_ready_() + // // poll_data_ready_() // -> write_command (data ready status) // -> timeout I2C_READ_DELAY @@ -380,10 +430,13 @@ void SEN6XComponent::update() { // All timeouts share a single ID (TIMEOUT_POLL) since only one is active // at a time. cancel_timeout in update() stops any in-flight chain. this->poll_retries_remaining_ = POLL_RETRIES; - this->poll_data_ready_(); + this->read_device_status_(); } void SEN6XComponent::poll_data_ready_() { + // stop_measurement() may have run from a publish callback while the chain was in flight + if (!this->measuring_) + return; if (this->poll_retries_remaining_ == 0) { this->status_set_warning(); ESP_LOGD(TAG, "Data not ready"); @@ -569,6 +622,41 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } +// Reads the status register first, so the error sensors update even when the measurement read fails, then runs +// the measurement poll chain. Read And Clear (0xD210) is used because error flags are sticky on the device; +// clearing each cycle makes the sensors report whether a problem occurred since the last update. +void SEN6XComponent::read_device_status_() { +#ifdef SEN6X_STATUS_SENSOR_COUNT + if (!this->status_sensors_.empty()) { + // The pressure write in update() may still be executing; the device rejects commands until it is done + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->read_device_status_(); }); + return; + } + if (this->write_command(SEN6X_CMD_READ_AND_CLEAR_DEVICE_STATUS)) { + this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { this->parse_and_publish_device_status_(); }); + return; + } + ESP_LOGD(TAG, "Read device status failed (%d)", this->last_error_); + } +#endif + this->poll_data_ready_(); +} + +#ifdef SEN6X_STATUS_SENSOR_COUNT +void SEN6XComponent::parse_and_publish_device_status_() { + uint16_t words[2]; + if (this->read_data(words, 2)) { + const uint32_t status = (static_cast(words[0]) << 16) | words[1]; + for (const auto &entry : this->status_sensors_) + entry.sensor->publish_state(((status >> entry.bit) & 1) != 0); + } else { + ESP_LOGD(TAG, "Read device status failed (%d)", this->last_error_); + } + this->poll_data_ready_(); +} +#endif + #ifdef USE_SEN6X_NUMBER_CONCENTRATION bool SEN6XComponent::has_number_concentration_sensor_() const { return this->pmc_0_5_sensor_ != nullptr || this->pmc_1_0_sensor_ != nullptr || this->pmc_2_5_sensor_ != nullptr || diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index f3054a8ffbd..e73f93f5a4c 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -4,9 +4,32 @@ #include "esphome/core/optional.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" +#ifdef SEN6X_STATUS_SENSOR_COUNT +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/helpers.h" +#endif namespace esphome::sen6x { +#ifdef SEN6X_STATUS_SENSOR_COUNT +// Bits of the device status register (datasheet section 4.3) a binary sensor can watch +enum class Sen6xStatus : uint8_t { + SEN6X_STATUS_FAN_ERROR, + SEN6X_STATUS_FAN_SPEED_WARNING, + SEN6X_STATUS_RHT_ERROR, + SEN6X_STATUS_GAS_ERROR, + SEN6X_STATUS_CO2_ERROR, + SEN6X_STATUS_HCHO_ERROR, + SEN6X_STATUS_PM_ERROR, +}; + +struct StatusSensor { + binary_sensor::BinarySensor *sensor; + Sen6xStatus status; + uint8_t bit; // of the status register, set at setup once the variant is known +}; +#endif + // The NOx algorithm requires std_initial to stay at 50 (Sensirion datasheet) static constexpr uint16_t NOX_STD_INITIAL = 50; @@ -91,6 +114,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void set_temperature_acceleration(uint16_t k, uint16_t p, uint16_t t1, uint16_t t2) { this->temperature_acceleration_ = TemperatureAcceleration{k, p, t1, t2}; } +#ifdef SEN6X_STATUS_SENSOR_COUNT + void add_status_binary_sensor(binary_sensor::BinarySensor *sensor, Sen6xStatus status) { + this->status_sensors_.push_back({sensor, status, 0}); + } +#endif void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } @@ -130,6 +158,10 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); + void read_device_status_(); +#ifdef SEN6X_STATUS_SENSOR_COUNT + void parse_and_publish_device_status_(); +#endif #ifdef USE_SEN6X_NUMBER_CONCENTRATION bool has_number_concentration_sensor_() const; void read_number_concentration_(); @@ -138,6 +170,9 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string product_name_; std::string serial_number_; +#ifdef SEN6X_STATUS_SENSOR_COUNT + StaticVector status_sensors_; +#endif optional voc_tuning_params_; optional nox_tuning_params_; optional temperature_acceleration_; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 24f66a87518..935952d3b31 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -167,6 +167,7 @@ #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 +#define SEN6X_STATUS_SENSOR_COUNT 1 #define UART_BINARY_SENSOR_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define MICS_5524_GAS_COUNT 5 diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 6e39256d159..f6fee2cdf58 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -77,6 +77,24 @@ sensor: name: Formaldehyde address: 0x6B +binary_sensor: + - platform: sen6x + sen6x_id: sen6x_sensor + fan_error: + name: Fan Error + fan_speed_warning: + name: Fan Speed Warning + rht_error: + name: RHT Error + gas_error: + name: Gas Error + co2_error: + name: CO2 Error + hcho_error: + name: HCHO Error + pm_error: + name: PM Error + button: - platform: template name: Fan Cleaning From 89afb111ad86c087a7d60a0171068d610acaf318 Mon Sep 17 00:00:00 2001 From: C H R I S T I A N Date: Sun, 18 Oct 2026 02:06:46 +0200 Subject: [PATCH 31/50] [mt6701] Add MT6701 (MagnTek magnetic encoder) component (#17643) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/mt6701/__init__.py | 9 + esphome/components/mt6701/mt6701.cpp | 28 +++ esphome/components/mt6701/mt6701.h | 33 +++ esphome/components/mt6701/sensor/__init__.py | 51 +++++ .../mt6701/sensor/mt6701_sensor.cpp | 23 ++ .../components/mt6701/sensor/mt6701_sensor.h | 19 ++ esphome/components/mt6701_i2c/__init__.py | 202 ++++++++++++++++++ esphome/components/mt6701_i2c/mt6701_i2c.cpp | 184 ++++++++++++++++ esphome/components/mt6701_i2c/mt6701_i2c.h | 79 +++++++ esphome/components/mt6701_spi/__init__.py | 47 ++++ .../mt6701_spi/binary_sensor/__init__.py | 46 ++++ .../mt6701_spi_binary_sensors.cpp | 29 +++ .../binary_sensor/mt6701_spi_binary_sensors.h | 25 +++ esphome/components/mt6701_spi/mt6701_spi.cpp | 67 ++++++ esphome/components/mt6701_spi/mt6701_spi.h | 41 ++++ .../mt6701_spi/text_sensor/__init__.py | 36 ++++ .../text_sensor/mt6701_spi_field_status.cpp | 28 +++ .../text_sensor/mt6701_spi_field_status.h | 19 ++ tests/component_tests/mt6701/__init__.py | 0 .../mt6701/config/mt6701_i2c_modes.yaml | 19 ++ .../config/mt6701_i2c_modes_uvw_pwm.yaml | 21 ++ tests/component_tests/mt6701/test_mt6701.py | 95 ++++++++ tests/components/mt6701_i2c/common.yaml | 33 +++ .../components/mt6701_i2c/test.esp32-ard.yaml | 3 + .../mt6701_i2c/test.esp32-c3-idf.yaml | 3 + .../components/mt6701_i2c/test.esp32-idf.yaml | 3 + .../mt6701_i2c/test.esp8266-ard.yaml | 3 + tests/components/mt6701_i2c/test.host.yaml | 3 + .../mt6701_i2c/test.rp2040-ard.yaml | 3 + tests/components/mt6701_spi/__init__.py | 6 + tests/components/mt6701_spi/common.yaml | 27 +++ .../mt6701_spi/mt6701_spi_frame_test.cpp | 96 +++++++++ .../components/mt6701_spi/test.esp32-ard.yaml | 6 + .../mt6701_spi/test.esp32-c3-idf.yaml | 6 + .../components/mt6701_spi/test.esp32-idf.yaml | 6 + .../mt6701_spi/test.esp8266-ard.yaml | 6 + .../mt6701_spi/test.rp2040-ard.yaml | 6 + 38 files changed, 1314 insertions(+) create mode 100644 esphome/components/mt6701/__init__.py create mode 100644 esphome/components/mt6701/mt6701.cpp create mode 100644 esphome/components/mt6701/mt6701.h create mode 100644 esphome/components/mt6701/sensor/__init__.py create mode 100644 esphome/components/mt6701/sensor/mt6701_sensor.cpp create mode 100644 esphome/components/mt6701/sensor/mt6701_sensor.h create mode 100644 esphome/components/mt6701_i2c/__init__.py create mode 100644 esphome/components/mt6701_i2c/mt6701_i2c.cpp create mode 100644 esphome/components/mt6701_i2c/mt6701_i2c.h create mode 100644 esphome/components/mt6701_spi/__init__.py create mode 100644 esphome/components/mt6701_spi/binary_sensor/__init__.py create mode 100644 esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp create mode 100644 esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h create mode 100644 esphome/components/mt6701_spi/mt6701_spi.cpp create mode 100644 esphome/components/mt6701_spi/mt6701_spi.h create mode 100644 esphome/components/mt6701_spi/text_sensor/__init__.py create mode 100644 esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp create mode 100644 esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h create mode 100644 tests/component_tests/mt6701/__init__.py create mode 100644 tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml create mode 100644 tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml create mode 100644 tests/component_tests/mt6701/test_mt6701.py create mode 100644 tests/components/mt6701_i2c/common.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-ard.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-c3-idf.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-idf.yaml create mode 100644 tests/components/mt6701_i2c/test.esp8266-ard.yaml create mode 100644 tests/components/mt6701_i2c/test.host.yaml create mode 100644 tests/components/mt6701_i2c/test.rp2040-ard.yaml create mode 100644 tests/components/mt6701_spi/__init__.py create mode 100644 tests/components/mt6701_spi/common.yaml create mode 100644 tests/components/mt6701_spi/mt6701_spi_frame_test.cpp create mode 100644 tests/components/mt6701_spi/test.esp32-ard.yaml create mode 100644 tests/components/mt6701_spi/test.esp32-c3-idf.yaml create mode 100644 tests/components/mt6701_spi/test.esp32-idf.yaml create mode 100644 tests/components/mt6701_spi/test.esp8266-ard.yaml create mode 100644 tests/components/mt6701_spi/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 539730a4a67..0def07d0237 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -428,6 +428,9 @@ esphome/components/mpl3115a2/* @kbickar esphome/components/mpu6886/* @fabaff esphome/components/ms8607/* @e28eta esphome/components/msa3xx/* @latonita +esphome/components/mt6701/* @slimcdk +esphome/components/mt6701_i2c/* @slimcdk +esphome/components/mt6701_spi/* @slimcdk esphome/components/nau7802/* @cujomalainey esphome/components/network/* @esphome/core esphome/components/nextion/* @edwardtfn @senexcrenshaw diff --git a/esphome/components/mt6701/__init__.py b/esphome/components/mt6701/__init__.py new file mode 100644 index 00000000000..9325b8299f1 --- /dev/null +++ b/esphome/components/mt6701/__init__.py @@ -0,0 +1,9 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@slimcdk"] +DOMAIN = "mt6701" + +mt6701_ns = cg.esphome_ns.namespace("mt6701") +MT6701Component = mt6701_ns.class_("MT6701Component", cg.Component) + +CONF_MT6701_ID = "mt6701_id" diff --git a/esphome/components/mt6701/mt6701.cpp b/esphome/components/mt6701/mt6701.cpp new file mode 100644 index 00000000000..46316b8e551 --- /dev/null +++ b/esphome/components/mt6701/mt6701.cpp @@ -0,0 +1,28 @@ +#include "mt6701.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701 { + +bool MT6701Component::read_encoder() { + if (this->is_failed() || this->suspend_sampling_) + return false; + + uint16_t count; + if (!this->read_count(count)) { + if (this->consecutive_errors_ < MAX_CONSECUTIVE_ERRORS) { + this->consecutive_errors_++; + if (this->consecutive_errors_ == MAX_CONSECUTIVE_ERRORS) + this->status_set_warning(LOG_STR("repeated read/CRC errors")); + } + return false; + } + if (this->consecutive_errors_ != 0) { + this->consecutive_errors_ = 0; + this->status_clear_warning(); + } + + this->count_ = count; + return true; +} + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/mt6701.h b/esphome/components/mt6701/mt6701.h new file mode 100644 index 00000000000..4795441f167 --- /dev/null +++ b/esphome/components/mt6701/mt6701.h @@ -0,0 +1,33 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::mt6701 { + +// A 14-bit absolute angle: one revolution is 16384 counts +static constexpr float COUNTS_TO_DEGREES = 360.0f / 16384; +// Consecutive read or CRC failures tolerated before the component reports a warning +static constexpr uint8_t MAX_CONSECUTIVE_ERRORS = 5; + +/// Bus-independent core; the I2C and SSI hubs provide read_count(), the entities poll it +class MT6701Component : public Component { + public: + /// Read a fresh angle; false on a bus or CRC error, while suspended, or when setup failed + bool read_encoder(); + + /// Most recent absolute angle as a raw count in the range [0, 16383]. + uint16_t get_count() const { return this->count_; } + /// Most recent absolute shaft angle in degrees, in the range [0, 360). + float get_angle_degrees() const { return this->count_ * COUNTS_TO_DEGREES; } + + protected: + /// The current 14-bit count from the bus; false on a bus or CRC error + virtual bool read_count(uint16_t &count) = 0; + + uint16_t count_{0}; + uint8_t consecutive_errors_{0}; + // Set by the I2C hub while the EEPROM is programmed; no bus access until it clears + bool suspend_sampling_{false}; +}; + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/sensor/__init__.py b/esphome/components/mt6701/sensor/__init__.py new file mode 100644 index 00000000000..617524b1917 --- /dev/null +++ b/esphome/components/mt6701/sensor/__init__.py @@ -0,0 +1,51 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ICON_ROTATE_RIGHT, + STATE_CLASS_MEASUREMENT, + UNIT_DEGREES, + UNIT_EMPTY, +) +from esphome.types import ConfigType + +from .. import CONF_MT6701_ID, MT6701Component, mt6701_ns + +AUTO_LOAD = ["mt6701"] + +MT6701Sensor = mt6701_ns.class_("MT6701Sensor", sensor.Sensor, cg.PollingComponent) + +CONF_RAW_COUNT = "raw_count" + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + MT6701Sensor, + unit_of_measurement=UNIT_DEGREES, + accuracy_decimals=2, + icon=ICON_ROTATE_RIGHT, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.GenerateID(CONF_MT6701_ID): cv.use_id(MT6701Component), + cv.Optional(CONF_RAW_COUNT): sensor.sensor_schema( + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + icon=ICON_ROTATE_RIGHT, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_MT6701_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_RAW_COUNT, var.set_raw_count_sensor) diff --git a/esphome/components/mt6701/sensor/mt6701_sensor.cpp b/esphome/components/mt6701/sensor/mt6701_sensor.cpp new file mode 100644 index 00000000000..c849ef91397 --- /dev/null +++ b/esphome/components/mt6701/sensor/mt6701_sensor.cpp @@ -0,0 +1,23 @@ +#include "mt6701_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701 { + +ESPHOME_LOG_TAG(TAG, "mt6701.sensor"); + +void MT6701Sensor::update() { + if (!this->parent_->read_encoder()) + return; + + this->publish_state(this->parent_->get_angle_degrees()); + if (this->raw_count_sensor_ != nullptr) + this->raw_count_sensor_->publish_state(this->parent_->get_count()); +} + +void MT6701Sensor::dump_config() { + LOG_SENSOR("", "MT6701 Sensor", this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Raw Count", this->raw_count_sensor_); +} + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/sensor/mt6701_sensor.h b/esphome/components/mt6701/sensor/mt6701_sensor.h new file mode 100644 index 00000000000..2bf03dd7225 --- /dev/null +++ b/esphome/components/mt6701/sensor/mt6701_sensor.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/mt6701/mt6701.h" + +namespace esphome::mt6701 { + +/// The shaft angle in degrees, with the raw count as an optional sub-sensor +class MT6701Sensor final : public PollingComponent, public Parented, public sensor::Sensor { + SUB_SENSOR(raw_count) + + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701_i2c/__init__.py b/esphome/components/mt6701_i2c/__init__.py new file mode 100644 index 00000000000..17734e00459 --- /dev/null +++ b/esphome/components/mt6701_i2c/__init__.py @@ -0,0 +1,202 @@ +from typing import Any + +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_DIRECTION, CONF_HYSTERESIS, CONF_ID, CONF_MODE +from esphome.types import ConfigType + +from ..mt6701 import MT6701Component + +CODEOWNERS = ["@slimcdk"] +AUTO_LOAD = ["mt6701"] +DEPENDENCIES = ["i2c"] +DOMAIN = "mt6701_i2c" +MULTI_CONF = True + +mt6701_i2c_ns = cg.esphome_ns.namespace("mt6701_i2c") +MT6701I2CComponent = mt6701_i2c_ns.class_( + "MT6701I2CComponent", MT6701Component, i2c.I2CDevice +) + +CONF_ZERO_OFFSET = "zero_offset" +CONF_OUTPUT_MODE = "output_mode" +CONF_ABZ = "abz" +CONF_PULSES_PER_REVOLUTION = "pulses_per_revolution" +CONF_Z_PULSE_WIDTH = "z_pulse_width" +CONF_UVW = "uvw" +CONF_POLE_PAIRS = "pole_pairs" +CONF_OUT_PIN = "out_pin" +CONF_PWM_FREQUENCY = "pwm_frequency" +CONF_PWM_POLARITY = "pwm_polarity" +CONF_ANALOG_START = "analog_start" +CONF_ANALOG_STOP = "analog_stop" + +# DIR bit: 1 counts clockwise +DIRECTION = { + "CLOCKWISE": 1, + "COUNTERCLOCKWISE": 0, +} + +# Output LSBs to the HYST register code +HYSTERESIS = { + 0: 4, + 0.25: 5, + 0.5: 6, + 1: 0, + 2: 1, + 4: 2, + 8: 3, +} + +Z_PULSE_WIDTH = { + "1LSB": 0, + "2LSB": 1, + "4LSB": 2, + "8LSB": 3, + "12LSB": 4, + "16LSB": 5, + "180DEG": 6, +} + +OUTPUT_MODE = { + "ABZ": 0, + "UVW": 1, +} + +OUT_PIN_MODE = { + "ANALOG": 0, + "PWM": 1, +} + +PWM_FREQUENCY = { + "994.4HZ": 0, + "497.2HZ": 1, +} + +PWM_POLARITY = { + "HIGH": 0, + "LOW": 1, +} + +# Zero offset and analog start/stop are 12-bit positions over one revolution +RESOLUTION_12BIT = 4096 +MAX_POSITION_12BIT = RESOLUTION_12BIT - 1 +ANGLE_TO_POSITION_12 = RESOLUTION_12BIT / 360 + + +def _angle_to_position_12(value: Any) -> int: + value = cv.float_range(min=0, max=360)( + cv.float_with_unit("angle", "(°|deg)")(value) + ) + # 360° is not representable; clamp instead of wrapping to 0 + return min(round(value * ANGLE_TO_POSITION_12), MAX_POSITION_12BIT) + + +def _percent_to_position_12(value: Any) -> int: + value = cv.percentage(value) + return min(round(value * RESOLUTION_12BIT), MAX_POSITION_12BIT) + + +def position12(value: Any) -> int: + """A 12-bit position: a raw count, an angle (``45deg``, ``90°``) or a share of a turn (``25%``).""" + if isinstance(value, str) and value.endswith("%"): + return _percent_to_position_12(value) + if isinstance(value, str) and value.endswith(("°", "deg")): + return _angle_to_position_12(value) + return cv.int_range(min=0, max=MAX_POSITION_12BIT)(value) + + +def validate_hysteresis(value: Any) -> int: + value = cv.float_(value) + if value not in HYSTERESIS: + raise cv.Invalid( + f"Hysteresis must be one of {sorted(HYSTERESIS)} (LSBs), got {value}" + ) + return HYSTERESIS[value] + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MT6701I2CComponent), + cv.Optional(CONF_DIRECTION): cv.enum(DIRECTION, upper=True), + cv.Optional(CONF_ZERO_OFFSET): position12, + cv.Optional(CONF_HYSTERESIS): validate_hysteresis, + cv.Optional(CONF_OUTPUT_MODE): cv.enum(OUTPUT_MODE, upper=True), + cv.Optional(CONF_ABZ): cv.Schema( + { + cv.Optional(CONF_PULSES_PER_REVOLUTION): cv.int_range( + min=1, max=1024 + ), + cv.Optional(CONF_Z_PULSE_WIDTH): cv.enum(Z_PULSE_WIDTH, upper=True), + } + ), + cv.Optional(CONF_UVW): cv.Schema( + { + cv.Required(CONF_POLE_PAIRS): cv.int_range(min=1, max=16), + } + ), + cv.Optional(CONF_OUT_PIN): cv.Schema( + { + cv.Required(CONF_MODE): cv.enum(OUT_PIN_MODE, upper=True), + cv.Optional(CONF_PWM_FREQUENCY): cv.enum(PWM_FREQUENCY, upper=True), + cv.Optional(CONF_PWM_POLARITY): cv.enum(PWM_POLARITY, upper=True), + cv.Optional(CONF_ANALOG_START): position12, + cv.Optional(CONF_ANALOG_STOP): position12, + } + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x06)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + if (direction := config.get(CONF_DIRECTION)) is not None: + cg.add(var.set_direction(direction)) + if (zero_offset := config.get(CONF_ZERO_OFFSET)) is not None: + cg.add(var.set_zero_offset(zero_offset)) + if (hysteresis := config.get(CONF_HYSTERESIS)) is not None: + cg.add(var.set_hysteresis(hysteresis)) + if (output_mode := config.get(CONF_OUTPUT_MODE)) is not None: + cg.add(var.set_output_mode(output_mode)) + + if abz := config.get(CONF_ABZ): + if (ppr := abz.get(CONF_PULSES_PER_REVOLUTION)) is not None: + cg.add(var.set_abz_pulses_per_revolution(ppr)) + if (width := abz.get(CONF_Z_PULSE_WIDTH)) is not None: + cg.add(var.set_z_pulse_width(width)) + + if uvw := config.get(CONF_UVW): + cg.add(var.set_uvw_pole_pairs(uvw[CONF_POLE_PAIRS])) + + if out_pin := config.get(CONF_OUT_PIN): + cg.add(var.set_out_pin_mode(out_pin[CONF_MODE])) + if (freq := out_pin.get(CONF_PWM_FREQUENCY)) is not None: + cg.add(var.set_pwm_frequency(freq)) + if (pol := out_pin.get(CONF_PWM_POLARITY)) is not None: + cg.add(var.set_pwm_polarity(pol)) + if (start := out_pin.get(CONF_ANALOG_START)) is not None: + cg.add(var.set_analog_start(start)) + if (stop := out_pin.get(CONF_ANALOG_STOP)) is not None: + cg.add(var.set_analog_stop(stop)) + + +MT6701_SAVE_EEPROM_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(MT6701I2CComponent), + } +) + +automation.register_apply_action( + "mt6701_i2c.save_eeprom", + MT6701_SAVE_EEPROM_ACTION_SCHEMA, + automation.ApplyCall("save_eeprom()"), +) diff --git a/esphome/components/mt6701_i2c/mt6701_i2c.cpp b/esphome/components/mt6701_i2c/mt6701_i2c.cpp new file mode 100644 index 00000000000..cf1094ee225 --- /dev/null +++ b/esphome/components/mt6701_i2c/mt6701_i2c.cpp @@ -0,0 +1,184 @@ +#include "mt6701_i2c.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_i2c { + +ESPHOME_LOG_TAG(TAG, "mt6701_i2c"); + +// Registers apply_config_() may write, each read-modify-written once +static const uint8_t CONFIG_REGS[] PROGMEM = { + REG_ABZ_MUX_DIR, REG_RES_H, REG_ABZ_RES_L, REG_CONFIG_H, REG_ZERO_L, + REG_HYST_L, REG_OUT, REG_A_HIGH, REG_A_START_L, REG_A_STOP_L, +}; +static constexpr uint8_t CONFIG_REG_COUNT = sizeof(CONFIG_REGS); + +void MT6701I2CComponent::setup() { + uint16_t count; + if (!this->read_count(count)) { + this->mark_failed(LOG_STR("not responding on I2C")); + return; + } + this->apply_config_(); +} + +void MT6701I2CComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 (I2C):"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + if (this->direction_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Direction bit: %u", this->direction_); + } + if (this->zero_offset_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Zero offset: %u", this->zero_offset_); + } + if (this->hysteresis_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Hysteresis code: %u", this->hysteresis_); + } + if (this->output_mode_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Output mode: %s", this->output_mode_ != 0 ? LOG_STR_LITERAL("UVW") : LOG_STR_LITERAL("ABZ")); + } + if (this->abz_pulses_per_revolution_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " ABZ pulses/rev: %u", this->abz_pulses_per_revolution_); + } + if (this->z_pulse_width_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Z pulse width code: %u", this->z_pulse_width_); + } + if (this->uvw_pole_pairs_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " UVW pole pairs: %u", this->uvw_pole_pairs_); + } + if (this->out_pin_mode_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Output pin mode: %s", + this->out_pin_mode_ != 0 ? LOG_STR_LITERAL("PWM") : LOG_STR_LITERAL("analog")); + } + if (this->pwm_frequency_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " PWM frequency code: %u", this->pwm_frequency_); + } + if (this->pwm_polarity_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " PWM polarity code: %u", this->pwm_polarity_); + } + if (this->analog_start_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Analog start: %u", this->analog_start_); + } + if (this->analog_stop_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Analog stop: %u", this->analog_stop_); + } +} + +bool MT6701I2CComponent::read_count(uint16_t &count) { + // Single-byte reads, 0x03 first; datasheet 1.9 documents no burst read + uint8_t high; + uint8_t low; + if (this->read_register(REG_ANGLE_H, &high, 1) != i2c::ERROR_OK || + this->read_register(REG_ANGLE_L, &low, 1) != i2c::ERROR_OK) + return false; + count = encode_uint16(high, low) >> 2; + return true; +} + +bool MT6701I2CComponent::update_register_(uint8_t reg, uint8_t mask, uint8_t value) { + uint8_t current; + if (this->read_register(reg, ¤t, 1) != i2c::ERROR_OK) + return false; + uint8_t updated = (current & ~mask) | (value & mask); + if (updated == current) + return true; + return this->write_register(reg, &updated, 1) == i2c::ERROR_OK; +} + +void MT6701I2CComponent::apply_config_() { + // Collect the bits per register first; several options share one register + uint8_t masks[CONFIG_REG_COUNT] = {}; + uint8_t values[CONFIG_REG_COUNT] = {}; + auto add = [&masks, &values](uint8_t reg, uint8_t mask, uint8_t value) { + for (uint8_t i = 0; i < CONFIG_REG_COUNT; i++) { + if (progmem_read_byte(&CONFIG_REGS[i]) == reg) { + masks[i] |= mask; + values[i] |= value & mask; + return; + } + } + }; + + if (this->direction_ != UNSET_U8) + add(REG_ABZ_MUX_DIR, 0x02, this->direction_ << 1); + if (this->output_mode_ != UNSET_U8) + add(REG_ABZ_MUX_DIR, 0x40, (this->output_mode_ & 0x01) << 6); + if (this->zero_offset_ != UNSET_U16) { + add(REG_CONFIG_H, 0x0F, this->zero_offset_ >> 8); + add(REG_ZERO_L, 0xFF, this->zero_offset_ & 0xFF); + } + if (this->hysteresis_ != UNSET_U8) { + add(REG_CONFIG_H, 0x80, (this->hysteresis_ & 0x04) << 5); // HYST[2] -> bit7 + add(REG_HYST_L, 0xC0, (this->hysteresis_ & 0x03) << 6); // HYST[1:0] -> bits7:6 + } + if (this->abz_pulses_per_revolution_ != UNSET_U16) { + // ABZ_RES holds pulses per revolution minus 1 + uint16_t res = this->abz_pulses_per_revolution_ - 1; + add(REG_RES_H, 0x03, res >> 8); + add(REG_ABZ_RES_L, 0xFF, res & 0xFF); + } + if (this->z_pulse_width_ != UNSET_U8) + add(REG_CONFIG_H, 0x70, (this->z_pulse_width_ & 0x07) << 4); + if (this->uvw_pole_pairs_ != UNSET_U8) { + // UVW_RES holds pole pairs minus 1 + add(REG_RES_H, 0xF0, (this->uvw_pole_pairs_ - 1) << 4); + } + if (this->out_pin_mode_ != UNSET_U8) + add(REG_OUT, 0x20, (this->out_pin_mode_ & 0x01) << 5); + if (this->pwm_frequency_ != UNSET_U8) + add(REG_OUT, 0x80, (this->pwm_frequency_ & 0x01) << 7); + if (this->pwm_polarity_ != UNSET_U8) + add(REG_OUT, 0x40, (this->pwm_polarity_ & 0x01) << 6); + if (this->analog_start_ != UNSET_U16) { + add(REG_A_HIGH, 0x0F, this->analog_start_ >> 8); + add(REG_A_START_L, 0xFF, this->analog_start_ & 0xFF); + } + if (this->analog_stop_ != UNSET_U16) { + add(REG_A_HIGH, 0xF0, (this->analog_stop_ >> 8) << 4); + add(REG_A_STOP_L, 0xFF, this->analog_stop_ & 0xFF); + } + + bool ok = true; + for (uint8_t i = 0; i < CONFIG_REG_COUNT; i++) { + if (masks[i] != 0) + ok &= this->update_register_(progmem_read_byte(&CONFIG_REGS[i]), masks[i], values[i]); + } + if (!ok) { + // A half-configured chip must not be sampled or have its EEPROM saved + this->mark_failed(LOG_STR("configuration register write failed")); + } +} + +void MT6701I2CComponent::save_eeprom() { + if (this->is_failed()) { + // A failed component gets no timeouts, so the bus would stay suspended + ESP_LOGW(TAG, "Not programming EEPROM: component is marked failed"); + return; + } + if (this->suspend_sampling_) { + ESP_LOGW(TAG, "EEPROM programming already in progress"); + return; + } + ESP_LOGW(TAG, "Programming MT6701 EEPROM. The EEPROM has a limited number of write " + "cycles, so avoid doing this repeatedly. Supply must be 4.5-5.5 V."); + this->suspend_sampling_ = true; + + bool ok = this->write_byte(REG_EEPROM_KEY, EEPROM_KEY_VALUE) && this->write_byte(REG_EEPROM_CMD, EEPROM_CMD_VALUE); + if (!ok) { + ESP_LOGE(TAG, "Failed to start EEPROM programming"); + this->suspend_sampling_ = false; + return; + } + + // suspend_sampling_ keeps a second save out, so the timer needs no name + this->set_timeout(EEPROM_PROGRAM_DELAY_MS, [this]() { + this->suspend_sampling_ = false; + ESP_LOGI(TAG, "EEPROM programming finished. Power-cycle the MT6701 to verify the stored configuration"); + }); +} + +} // namespace esphome::mt6701_i2c diff --git a/esphome/components/mt6701_i2c/mt6701_i2c.h b/esphome/components/mt6701_i2c/mt6701_i2c.h new file mode 100644 index 00000000000..7d2d91d6986 --- /dev/null +++ b/esphome/components/mt6701_i2c/mt6701_i2c.h @@ -0,0 +1,79 @@ +#pragma once + +#include "esphome/components/mt6701/mt6701.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::mt6701_i2c { + +// Angle: D[13:6] at 0x03, D[5:0] in bits 7:2 of 0x04 +static constexpr uint8_t REG_ANGLE_H = 0x03; +static constexpr uint8_t REG_ANGLE_L = 0x04; + +// EEPROM programming (datasheet 8.2) needs more than 600 ms left alone afterwards +static constexpr uint8_t REG_EEPROM_KEY = 0x09; +static constexpr uint8_t REG_EEPROM_CMD = 0x0A; +static constexpr uint8_t EEPROM_KEY_VALUE = 0xB3; +static constexpr uint8_t EEPROM_CMD_VALUE = 0x05; +static constexpr uint32_t EEPROM_PROGRAM_DELAY_MS = 700; + +// Configuration registers; reserved bits make every write a read-modify-write +static constexpr uint8_t REG_ABZ_MUX_DIR = 0x29; // bit6 ABZ_MUX, bit1 DIR +static constexpr uint8_t REG_RES_H = 0x30; // bits7:4 UVW_RES, bits1:0 ABZ_RES[9:8] +static constexpr uint8_t REG_ABZ_RES_L = 0x31; // ABZ_RES[7:0] +static constexpr uint8_t REG_CONFIG_H = 0x32; // bit7 HYST[2], bits6:4 Z_PULSE_WIDTH, bits3:0 ZERO[11:8] +static constexpr uint8_t REG_ZERO_L = 0x33; // ZERO[7:0] +static constexpr uint8_t REG_HYST_L = 0x34; // bits7:6 HYST[1:0] +static constexpr uint8_t REG_OUT = 0x38; // bit7 PWM_FREQ, bit6 PWM_POL, bit5 OUT_MODE +static constexpr uint8_t REG_A_HIGH = 0x3E; // bits7:4 A_STOP[11:8], bits3:0 A_START[11:8] +static constexpr uint8_t REG_A_START_L = 0x3F; // A_START[7:0] +static constexpr uint8_t REG_A_STOP_L = 0x40; // A_STOP[7:0] + +/// MT6701 over I2C, with the configuration registers +class MT6701I2CComponent final : public mt6701::MT6701Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + // Options left unset are never written, so the chip keeps its EEPROM defaults + void set_direction(uint8_t direction) { this->direction_ = direction; } + void set_zero_offset(uint16_t position) { this->zero_offset_ = position; } + void set_hysteresis(uint8_t hysteresis) { this->hysteresis_ = hysteresis; } + void set_output_mode(uint8_t output_mode) { this->output_mode_ = output_mode; } + void set_abz_pulses_per_revolution(uint16_t pulses) { this->abz_pulses_per_revolution_ = pulses; } + void set_z_pulse_width(uint8_t width) { this->z_pulse_width_ = width; } + void set_uvw_pole_pairs(uint8_t pole_pairs) { this->uvw_pole_pairs_ = pole_pairs; } + void set_out_pin_mode(uint8_t out_pin_mode) { this->out_pin_mode_ = out_pin_mode; } + void set_pwm_frequency(uint8_t pwm_frequency) { this->pwm_frequency_ = pwm_frequency; } + void set_pwm_polarity(uint8_t pwm_polarity) { this->pwm_polarity_ = pwm_polarity; } + void set_analog_start(uint16_t position) { this->analog_start_ = position; } + void set_analog_stop(uint16_t position) { this->analog_stop_ = position; } + + /// Write the registers to the EEPROM: pauses sampling for the programming time. + /// The EEPROM wears, so only an explicit action calls this. + void save_eeprom(); + + protected: + bool read_count(uint16_t &count) override; + + void apply_config_(); + bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); + + // Unset options sit above their field's range + static constexpr uint16_t UNSET_U16 = 0xFFFF; + static constexpr uint8_t UNSET_U8 = 0xFF; + + uint16_t zero_offset_{UNSET_U16}; + uint16_t abz_pulses_per_revolution_{UNSET_U16}; + uint16_t analog_start_{UNSET_U16}; + uint16_t analog_stop_{UNSET_U16}; + uint8_t direction_{UNSET_U8}; + uint8_t hysteresis_{UNSET_U8}; + uint8_t output_mode_{UNSET_U8}; + uint8_t z_pulse_width_{UNSET_U8}; + uint8_t uvw_pole_pairs_{UNSET_U8}; + uint8_t out_pin_mode_{UNSET_U8}; + uint8_t pwm_frequency_{UNSET_U8}; + uint8_t pwm_polarity_{UNSET_U8}; +}; + +} // namespace esphome::mt6701_i2c diff --git a/esphome/components/mt6701_spi/__init__.py b/esphome/components/mt6701_spi/__init__.py new file mode 100644 index 00000000000..6f45e347b7d --- /dev/null +++ b/esphome/components/mt6701_spi/__init__.py @@ -0,0 +1,47 @@ +import esphome.codegen as cg +from esphome.components import spi +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from ..mt6701 import MT6701Component + +CODEOWNERS = ["@slimcdk"] +AUTO_LOAD = ["mt6701"] +DEPENDENCIES = ["spi"] +DOMAIN = "mt6701_spi" +MULTI_CONF = True + +mt6701_spi_ns = cg.esphome_ns.namespace("mt6701_spi") +MT6701SPIComponent = mt6701_spi_ns.class_( + "MT6701SPIComponent", MT6701Component, spi.SPIDevice +) + +CONF_MT6701_SPI_ID = "mt6701_spi_id" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MT6701SPIComponent), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend( + spi.spi_device_schema( + cs_pin_required=True, + default_data_rate=1_000_000, + default_mode="MODE1", + ) + ) +) + +# SSI only reads, so the bus needs its input line +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + "mt6701_spi", require_mosi=False, require_miso=True +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await spi.register_spi_device(var, config) diff --git a/esphome/components/mt6701_spi/binary_sensor/__init__.py b/esphome/components/mt6701_spi/binary_sensor/__init__.py new file mode 100644 index 00000000000..f06dc31aa5f --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/__init__.py @@ -0,0 +1,46 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_UPDATE_INTERVAL, + DEVICE_CLASS_PROBLEM, + ENTITY_CATEGORY_DIAGNOSTIC, +) +from esphome.types import ConfigType + +from .. import CONF_MT6701_SPI_ID, MT6701SPIComponent, mt6701_spi_ns + +DEPENDENCIES = ["mt6701_spi"] + +CONF_PUSH_BUTTON = "push_button" +CONF_TRACK_LOSS = "track_loss" + +MT6701SPIBinarySensors = mt6701_spi_ns.class_( + "MT6701SPIBinarySensors", cg.Component, cg.Parented.template(MT6701SPIComponent) +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(MT6701SPIBinarySensors), + cv.GenerateID(CONF_MT6701_SPI_ID): cv.use_id(MT6701SPIComponent), + # A press has to be seen between two reads, so the default is short + cv.Optional( + CONF_UPDATE_INTERVAL, default="50ms" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_PUSH_BUTTON): binary_sensor.binary_sensor_schema(), + cv.Optional(CONF_TRACK_LOSS): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_MT6701_SPI_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_PUSH_BUTTON, var.set_push_button_binary_sensor) + await binary_sensors(CONF_TRACK_LOSS, var.set_track_loss_binary_sensor) diff --git a/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp new file mode 100644 index 00000000000..9af88d056e2 --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp @@ -0,0 +1,29 @@ +#include "mt6701_spi_binary_sensors.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi.binary_sensor"); + +void MT6701SPIBinarySensors::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_read_ < this->update_interval_) + return; + this->last_read_ = now; + if (!this->parent_->read_encoder()) + return; + const uint8_t status = this->parent_->get_status(); + if (this->push_button_binary_sensor_ != nullptr) + this->push_button_binary_sensor_->publish_state((status & 0x04) != 0); + if (this->track_loss_binary_sensor_ != nullptr) + this->track_loss_binary_sensor_->publish_state((status & 0x08) != 0); +} + +void MT6701SPIBinarySensors::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 status:\n Update Interval: %" PRIu32 " ms", this->update_interval_); + LOG_BINARY_SENSOR(" ", "Push button", this->push_button_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Track loss", this->track_loss_binary_sensor_); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h new file mode 100644 index 00000000000..646f6341c40 --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/mt6701_spi/mt6701_spi.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::mt6701_spi { + +// Reads the encoder at its own interval and publishes the status flags of each frame +class MT6701SPIBinarySensors : public Component, public Parented { + SUB_BINARY_SENSOR(push_button) + SUB_BINARY_SENSOR(track_loss) + + public: + void set_update_interval(uint32_t update_interval) { this->update_interval_ = update_interval; } + void loop() override; + void dump_config() override; + + protected: + uint32_t update_interval_{50}; + uint32_t last_read_{0}; +}; + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/mt6701_spi.cpp b/esphome/components/mt6701_spi/mt6701_spi.cpp new file mode 100644 index 00000000000..493b7ded3f7 --- /dev/null +++ b/esphome/components/mt6701_spi/mt6701_spi.cpp @@ -0,0 +1,67 @@ +#include "mt6701_spi.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi"); + +// The 6-bit CRC (x^6 + x + 1) over 18 bits is crc8 with the polynomial shifted up by two +static constexpr uint8_t CRC6_AS_CRC8_POLY = 0x0C; + +bool decode_frame(const uint8_t frame[3], uint16_t &count, uint8_t &status) { + if (crc8(frame, 3, 0x00, CRC6_AS_CRC8_POLY, true) != 0) + return false; + const uint32_t bits = encode_uint24(frame[0], frame[1], frame[2]); + count = bits >> 10; + status = (bits >> 6) & 0x0F; + return true; +} + +ProbeResult probe_result(uint8_t valid_frames, bool any_nonzero) { + if (valid_frames < PROBE_MIN_VALID) + return ProbeResult::PROBE_RESULT_NO_ENCODER; + return any_nonzero ? ProbeResult::PROBE_RESULT_OK : ProbeResult::PROBE_RESULT_ALL_ZERO; +} + +void MT6701SPIComponent::setup() { + this->spi_setup(); + // All-zero frames only warn: an encoder at rest looks like a stuck-low data line + uint8_t valid = 0; + bool nonzero = false; + for (uint8_t i = 0; i < PROBE_FRAMES && (valid < PROBE_MIN_VALID || !nonzero); i++) { + uint16_t count; + if (this->read_count(count)) { + valid++; + nonzero |= count != 0 || this->status_ != 0; + } + } + switch (probe_result(valid, nonzero)) { + case ProbeResult::PROBE_RESULT_NO_ENCODER: + this->mark_failed(LOG_STR("no valid frame received, check the wiring and CS pin")); + break; + case ProbeResult::PROBE_RESULT_ALL_ZERO: + ESP_LOGW(TAG, "Every frame read zero: the data line may be stuck low"); + break; + case ProbeResult::PROBE_RESULT_OK: + break; + } +} + +void MT6701SPIComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 (SSI/SPI):"); + LOG_PIN(" CS Pin: ", this->cs_); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +bool MT6701SPIComponent::read_count(uint16_t &count) { + uint8_t buffer[3] = {0, 0, 0}; + this->enable(); + this->read_array(buffer, 3); + this->disable(); + return decode_frame(buffer, count, this->status_); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/mt6701_spi.h b/esphome/components/mt6701_spi/mt6701_spi.h new file mode 100644 index 00000000000..fed7217a1e0 --- /dev/null +++ b/esphome/components/mt6701_spi/mt6701_spi.h @@ -0,0 +1,41 @@ +#pragma once + +#include "esphome/components/mt6701/mt6701.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::mt6701_spi { + +/// Decode a 24-bit SSI frame (14-bit angle, 4 status bits, 6-bit CRC); false when the CRC fails +bool decode_frame(const uint8_t frame[3], uint16_t &count, uint8_t &status); + +// Noise passes the 6-bit CRC 1 time in 64, so setup wants several good frames +static constexpr uint8_t PROBE_FRAMES = 10; +static constexpr uint8_t PROBE_MIN_VALID = 3; + +enum class ProbeResult : uint8_t { + PROBE_RESULT_NO_ENCODER, + PROBE_RESULT_ALL_ZERO, // a stuck-low data line, or an encoder at rest + PROBE_RESULT_OK, +}; +ProbeResult probe_result(uint8_t valid_frames, bool any_nonzero); + +/// MT6701 over SSI: read-only, but each frame carries the field, push button and track-loss flags. +/// Data shifts out on the rising clock edge, so it is sampled on the falling edge with the clock +/// idle low; a clock idling high reads an extra leading bit. +class MT6701SPIComponent final : public mt6701::MT6701Component, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + + /// The 4-bit status of the last good frame: bit3 track loss, bit2 push button, bits1:0 field strength + uint8_t get_status() const { return this->status_; } + + protected: + bool read_count(uint16_t &count) override; + + uint8_t status_{0}; +}; + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/text_sensor/__init__.py b/esphome/components/mt6701_spi/text_sensor/__init__.py new file mode 100644 index 00000000000..bbec4a40e4f --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_MAGNET +from esphome.types import ConfigType + +from .. import CONF_MT6701_SPI_ID, MT6701SPIComponent, mt6701_spi_ns + +DEPENDENCIES = ["mt6701_spi"] + +MT6701SPIFieldStatus = mt6701_spi_ns.class_( + "MT6701SPIFieldStatus", + text_sensor.TextSensor, + cg.PollingComponent, + cg.Parented.template(MT6701SPIComponent), +) + +CONFIG_SCHEMA = ( + text_sensor.text_sensor_schema( + MT6701SPIFieldStatus, + icon=ICON_MAGNET, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ) + .extend( + { + cv.GenerateID(CONF_MT6701_SPI_ID): cv.use_id(MT6701SPIComponent), + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = await text_sensor.new_text_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_MT6701_SPI_ID]) diff --git a/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp new file mode 100644 index 00000000000..5db30c4b077 --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp @@ -0,0 +1,28 @@ +#include "mt6701_spi_field_status.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi.text_sensor"); + +// Indexed by Mg[1:0]; 0b11 is reserved in the datasheet +PROGMEM_STRING_TABLE(FieldStatusStrings, "OK", "TOO_STRONG", "TOO_WEAK", "UNKNOWN"); + +void MT6701SPIFieldStatus::update() { + if (!this->parent_->read_encoder()) + return; + char text[FieldStatusStrings::MAX_LENGTH + 1]; + ESPHOME_strncpy_P(text, + reinterpret_cast(FieldStatusStrings::get_progmem_str( + this->parent_->get_status() & 0x03, FieldStatusStrings::LAST_INDEX)), + sizeof(text)); + this->publish_state(text); +} + +void MT6701SPIFieldStatus::dump_config() { + LOG_TEXT_SENSOR("", "MT6701 field status", this); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h new file mode 100644 index 00000000000..200cc78a7fb --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/mt6701_spi/mt6701_spi.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::mt6701_spi { + +// Reports the magnetic field strength the status bits Mg[1:0] carry +class MT6701SPIFieldStatus : public text_sensor::TextSensor, + public PollingComponent, + public Parented { + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::mt6701_spi diff --git a/tests/component_tests/mt6701/__init__.py b/tests/component_tests/mt6701/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml b/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml new file mode 100644 index 00000000000..135ee0087ac --- /dev/null +++ b/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml @@ -0,0 +1,19 @@ +esphome: + name: mt6701-modes-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2c: + - id: i2c_bus + sda: GPIO21 + scl: GPIO22 + +mt6701_i2c: + id: encoder_hub + i2c_id: i2c_bus + output_mode: ABZ + out_pin: + mode: ANALOG diff --git a/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml b/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml new file mode 100644 index 00000000000..a509bc481df --- /dev/null +++ b/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml @@ -0,0 +1,21 @@ +esphome: + name: mt6701-modes-uvw-pwm-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2c: + - id: i2c_bus + sda: GPIO21 + scl: GPIO22 + +mt6701_i2c: + id: encoder_hub + i2c_id: i2c_bus + output_mode: UVW + uvw: + pole_pairs: 7 + out_pin: + mode: PWM diff --git a/tests/component_tests/mt6701/test_mt6701.py b/tests/component_tests/mt6701/test_mt6701.py new file mode 100644 index 00000000000..05a9ff93286 --- /dev/null +++ b/tests/component_tests/mt6701/test_mt6701.py @@ -0,0 +1,95 @@ +"""Config-validation tests for the mt6701 encoder component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.mt6701_i2c import DIRECTION, position12, validate_hysteresis +import esphome.config_validation as cv + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # raw 12-bit counts pass through unchanged + (0, 0), + (2048, 2048), + (4095, 4095), + # angles are converted to counts + ("0deg", 0), + ("45deg", 512), + ("90°", 1024), + # a full turn is not representable and must clamp to the max, not wrap to 0 + ("360deg", 4095), + # percentages of a full revolution + ("25%", 1024), + ("50%", 2048), + ("100%", 4095), + ], +) +def test_position12_valid(value: int | str, expected: int) -> None: + assert position12(value) == expected + + +@pytest.mark.parametrize("value", [-1, 4096, 5000, "400deg", "-10deg"]) +def test_position12_rejects_out_of_range(value: int | str) -> None: + with pytest.raises(cv.Invalid): + position12(value) + + +@pytest.mark.parametrize( + ("lsb", "register_code"), + [(0, 4), (0.25, 5), (0.5, 6), (1, 0), (2, 1), (4, 2), (8, 3)], +) +def test_hysteresis_maps_lsb_to_register_code(lsb: float, register_code: int) -> None: + assert validate_hysteresis(lsb) == register_code + + +@pytest.mark.parametrize("value", [3, 5, 16]) +def test_hysteresis_rejects_unsupported_step(value: int) -> None: + with pytest.raises(cv.Invalid): + validate_hysteresis(value) + + +def test_direction_follows_datasheet_semantics() -> None: + # Datasheet section 8.1: DIR=1 makes the angle increase clockwise. + assert DIRECTION["CLOCKWISE"] == 1 + assert DIRECTION["COUNTERCLOCKWISE"] == 0 + + +@pytest.mark.parametrize( + ("config_file", "output_mode_call", "out_pin_call"), + [ + # ABZ and ANALOG are register code 0 + ( + "mt6701_i2c_modes.yaml", + "set_output_mode(0)", + "set_out_pin_mode(0)", + ), + # UVW and PWM are register code 1 + ( + "mt6701_i2c_modes_uvw_pwm.yaml", + "set_output_mode(1)", + "set_out_pin_mode(1)", + ), + ], +) +def test_enum_options_generate_register_codes( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + output_mode_call: str, + out_pin_call: str, +) -> None: + """cv.enum returns the key string; codegen must emit its register code. + + Both codes are pinned, so that hardcoding a constant or reading a different + field is caught. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert output_mode_call in main_cpp + assert out_pin_call in main_cpp diff --git a/tests/components/mt6701_i2c/common.yaml b/tests/components/mt6701_i2c/common.yaml new file mode 100644 index 00000000000..fbcf4512f08 --- /dev/null +++ b/tests/components/mt6701_i2c/common.yaml @@ -0,0 +1,33 @@ +mt6701_i2c: + id: mt6701_i2c_encoder + i2c_id: i2c_bus + address: 0x06 + direction: CLOCKWISE + zero_offset: 45deg + hysteresis: 1 + output_mode: ABZ + abz: + pulses_per_revolution: 1024 + z_pulse_width: 1LSB + uvw: + pole_pairs: 8 + out_pin: + mode: PWM + pwm_frequency: 994.4Hz + pwm_polarity: HIGH + analog_start: 0deg + analog_stop: 360deg + +sensor: + - platform: mt6701 + mt6701_id: mt6701_i2c_encoder + name: MT6701 I2C Angle + update_interval: 100ms + raw_count: + name: MT6701 I2C Raw Count + +button: + - platform: template + name: MT6701 I2C Save EEPROM + on_press: + - mt6701_i2c.save_eeprom: mt6701_i2c_encoder diff --git a/tests/components/mt6701_i2c/test.esp32-ard.yaml b/tests/components/mt6701_i2c/test.esp32-ard.yaml new file mode 100644 index 00000000000..dced81081e2 --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml b/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml new file mode 100644 index 00000000000..3837e73028f --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-c3-idf.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp32-idf.yaml b/tests/components/mt6701_i2c/test.esp32-idf.yaml new file mode 100644 index 00000000000..a4ce3b5d391 --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp8266-ard.yaml b/tests/components/mt6701_i2c/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7639027368f --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.host.yaml b/tests/components/mt6701_i2c/test.host.yaml new file mode 100644 index 00000000000..dd5d5faf9d7 --- /dev/null +++ b/tests/components/mt6701_i2c/test.host.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/host.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.rp2040-ard.yaml b/tests/components/mt6701_i2c/test.rp2040-ard.yaml new file mode 100644 index 00000000000..06082480a6a --- /dev/null +++ b/tests/components/mt6701_i2c/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_spi/__init__.py b/tests/components/mt6701_spi/__init__.py new file mode 100644 index 00000000000..7937e0202db --- /dev/null +++ b/tests/components/mt6701_spi/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The frame tests call the decoder directly; no bus is driven + manifest.dependencies = manifest.dependencies + ["spi"] diff --git a/tests/components/mt6701_spi/common.yaml b/tests/components/mt6701_spi/common.yaml new file mode 100644 index 00000000000..093ba44dfed --- /dev/null +++ b/tests/components/mt6701_spi/common.yaml @@ -0,0 +1,27 @@ +mt6701_spi: + id: mt6701_spi_encoder + spi_id: spi_bus + cs_pin: ${cs_pin} + +sensor: + - platform: mt6701 + mt6701_id: mt6701_spi_encoder + name: MT6701 SPI Angle + update_interval: 100ms + raw_count: + name: MT6701 SPI Raw Count + +binary_sensor: + - platform: mt6701_spi + mt6701_spi_id: mt6701_spi_encoder + update_interval: 20ms + push_button: + name: MT6701 SPI Button + track_loss: + name: MT6701 SPI Track Loss + +text_sensor: + - platform: mt6701_spi + mt6701_spi_id: mt6701_spi_encoder + name: MT6701 SPI Field Status + update_interval: 10s diff --git a/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp b/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp new file mode 100644 index 00000000000..43392601aca --- /dev/null +++ b/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/mt6701_spi/mt6701_spi.h" + +namespace esphome::mt6701_spi::testing { + +namespace { + +// The datasheet's CRC: x^6 + x + 1 over the 18 data bits, most significant bit first, zero seed +uint8_t crc6(uint32_t data18) { + uint8_t crc = 0; + for (int bit = 17; bit >= 0; bit--) { + const uint8_t in = ((data18 >> bit) & 1) ^ ((crc >> 5) & 1); + crc = ((crc << 1) & 0x3F) ^ (in ? 0x03 : 0); + } + return crc; +} + +// A frame as the chip sends it: 14 angle bits, 4 status bits, 6 CRC bits +std::array make_frame(uint16_t count, uint8_t status) { + const uint32_t data18 = (static_cast(count & 0x3FFF) << 4) | (status & 0x0F); + const uint32_t frame = (data18 << 6) | crc6(data18); + return {static_cast(frame >> 16), static_cast(frame >> 8), static_cast(frame)}; +} + +} // namespace + +TEST(MT6701SpiFrame, DecodesANonZeroReading) { + const auto frame = make_frame(0x1234, 0b0101); + uint16_t count = 0; + uint8_t status = 0; + ASSERT_TRUE(decode_frame(frame.data(), count, status)); + EXPECT_EQ(count, 0x1234); + EXPECT_EQ(status, 0b0101); +} + +TEST(MT6701SpiFrame, AZeroReadingIsValid) { + const auto frame = make_frame(0, 0); + EXPECT_EQ(frame, (std::array{0, 0, 0})); + uint16_t count = 1; + uint8_t status = 1; + ASSERT_TRUE(decode_frame(frame.data(), count, status)); + EXPECT_EQ(count, 0); + EXPECT_EQ(status, 0); +} + +TEST(MT6701SpiFrame, EveryAngleAndStatusRoundTrips) { + for (uint32_t count = 0; count < 16384; count += 37) { + for (uint8_t status = 0; status < 16; status++) { + const auto frame = make_frame(count, status); + uint16_t got_count = 0; + uint8_t got_status = 0; + ASSERT_TRUE(decode_frame(frame.data(), got_count, got_status)) << count << " " << status; + EXPECT_EQ(got_count, count); + EXPECT_EQ(got_status, status); + } + } +} + +TEST(MT6701SpiFrame, ACorruptedBitFailsTheCrc) { + const auto good = make_frame(0x2ACE, 0b1010); + for (int bit = 0; bit < 24; bit++) { + auto frame = good; + frame[2 - bit / 8] ^= 1 << (bit % 8); + uint16_t count = 0; + uint8_t status = 0; + EXPECT_FALSE(decode_frame(frame.data(), count, status)) << "bit " << bit; + } +} + +TEST(MT6701SpiFrame, StatusBitsAreTheFieldPushAndTrackFlags) { + uint16_t count = 0; + uint8_t status = 0; + // Mg[1:0] = 2 (field too weak), Mg[2] push button, Mg[3] loss of track + ASSERT_TRUE(decode_frame(make_frame(100, 0b1110).data(), count, status)); + EXPECT_EQ(status & 0x03, 2); + EXPECT_TRUE(status & 0x04); + EXPECT_TRUE(status & 0x08); +} + +TEST(MT6701SpiProbe, ThreeGoodFramesProveTheEncoder) { + EXPECT_EQ(probe_result(0, false), ProbeResult::PROBE_RESULT_NO_ENCODER); + EXPECT_EQ(probe_result(PROBE_MIN_VALID - 1, true), ProbeResult::PROBE_RESULT_NO_ENCODER); + EXPECT_EQ(probe_result(PROBE_MIN_VALID, true), ProbeResult::PROBE_RESULT_OK); + EXPECT_EQ(probe_result(PROBE_FRAMES, true), ProbeResult::PROBE_RESULT_OK); +} + +TEST(MT6701SpiProbe, AllZeroFramesOnlyWarn) { + EXPECT_EQ(probe_result(PROBE_MIN_VALID, false), ProbeResult::PROBE_RESULT_ALL_ZERO); + EXPECT_EQ(probe_result(PROBE_FRAMES, false), ProbeResult::PROBE_RESULT_ALL_ZERO); +} + +} // namespace esphome::mt6701_spi::testing diff --git a/tests/components/mt6701_spi/test.esp32-ard.yaml b/tests/components/mt6701_spi/test.esp32-ard.yaml new file mode 100644 index 00000000000..6d06dd7bc59 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-ard.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp32-c3-idf.yaml b/tests/components/mt6701_spi/test.esp32-c3-idf.yaml new file mode 100644 index 00000000000..c9de669b984 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-c3-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO7 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-c3-idf.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp32-idf.yaml b/tests/components/mt6701_spi/test.esp32-idf.yaml new file mode 100644 index 00000000000..642c4d90300 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp8266-ard.yaml b/tests/components/mt6701_spi/test.esp8266-ard.yaml new file mode 100644 index 00000000000..e7b3a4401da --- /dev/null +++ b/tests/components/mt6701_spi/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO15 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.rp2040-ard.yaml b/tests/components/mt6701_spi/test.rp2040-ard.yaml new file mode 100644 index 00000000000..23435b8fb1f --- /dev/null +++ b/tests/components/mt6701_spi/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + mt6701_spi: !include common.yaml From 928cf7d69671b7f6971ece696cfa8bcf77915612 Mon Sep 17 00:00:00 2001 From: Michele Porelli Date: Sat, 17 Oct 2026 17:59:40 -0700 Subject: [PATCH 32/50] [libretiny] Emulate rtc preference storage on the Beken BK7231T, BK7231N and BK7238 with flash and the reset reason (#21007) --- .../components/libretiny/preference_backend.h | 10 ++ esphome/components/libretiny/preferences.cpp | 95 +++++++++++++++++-- esphome/components/libretiny/preferences.h | 11 ++- esphome/core/defines.h | 1 + esphome/preferences.py | 42 ++++++-- tests/unit_tests/test_preferences.py | 53 +++++++++++ 6 files changed, 192 insertions(+), 20 deletions(-) diff --git a/esphome/components/libretiny/preference_backend.h b/esphome/components/libretiny/preference_backend.h index f7f8279ac08..00d76d7d862 100644 --- a/esphome/components/libretiny/preference_backend.h +++ b/esphome/components/libretiny/preference_backend.h @@ -1,6 +1,8 @@ #pragma once #ifdef USE_LIBRETINY +#include "esphome/core/defines.h" + #include #include @@ -20,6 +22,14 @@ class LibreTinyPreferenceBackend final { uint32_t key; struct fdb_kvdb *db; struct fdb_blob *blob; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + // storage: rtc lives under its own FlashDB name prefix, so the database can drop every rtc entry + // at once after a boot that RTC memory would not have survived + bool rtc{false}; + bool is_rtc() const { return this->rtc; } +#else + static constexpr bool is_rtc() { return false; } +#endif }; class LibreTinyPreferences; diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index a868e5042e2..9d21e88b14b 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -4,6 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include #include namespace esphome::libretiny { @@ -13,20 +14,58 @@ ESPHOME_LOG_TAG(TAG, "preferences"); struct NVSData { uint32_t key; SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.) +#ifdef USE_LIBRETINY_RTC_PREFERENCES + bool rtc; + bool is_rtc() const { return this->rtc; } +#else + static constexpr bool is_rtc() { return false; } +#endif }; static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// The FlashDB name of a key: its decimal digits, with a prefix for storage: rtc so one pass over the +// database finds every rtc entry. Without rtc storage is_rtc() is a constant false and the branch folds away. +static constexpr size_t KEY_NAME_SIZE = UINT32_MAX_STR_SIZE + 1; +static constexpr char RTC_KEY_PREFIX = 'r'; +static void key_name(char (&buf)[KEY_NAME_SIZE], uint32_t key, bool rtc) { + char *digits = buf; + if (rtc) + *digits++ = RTC_KEY_PREFIX; + uint32_to_str(std::span(digits, UINT32_MAX_STR_SIZE), key); +} + +#ifdef USE_LIBRETINY_RTC_PREFERENCES +// Whether this boot followed a reset that RTC memory survives on ESP32 and ESP8266. LibreTiny chips +// have no such memory, so storage: rtc keeps preferences in flash and uses this to forget them. +static bool boot_kept_rtc_preferences() { + switch (lt_get_reboot_reason()) { + case REBOOT_REASON_SOFTWARE: + case REBOOT_REASON_WATCHDOG: + case REBOOT_REASON_CRASH: + case REBOOT_REASON_SLEEP_GPIO: + case REBOOT_REASON_SLEEP_RTC: + case REBOOT_REASON_SLEEP_USB: + return true; + default: // power-on, brown-out, reset pin, debugger, unknown + return false; + } +} +#endif // USE_LIBRETINY_RTC_PREFERENCES + bool LibreTinyPreferenceBackend::save(const uint8_t *data, size_t len) { - // try find in pending saves and update that + // try find in pending saves and update that; a flash and an rtc entry may share a key for (auto &obj : s_pending_save) { - if (obj.key == this->key) { + if (obj.key == this->key && obj.is_rtc() == this->is_rtc()) { obj.data.set(data, len); return true; } } NVSData save{}; save.key = this->key; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + save.rtc = this->rtc; +#endif save.data.set(data, len); s_pending_save.push_back(std::move(save)); ESP_LOGVV(TAG, "s_pending_save: key: %" PRIu32 ", len: %zu", this->key, len); @@ -36,7 +75,7 @@ bool LibreTinyPreferenceBackend::save(const uint8_t *data, size_t len) { bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) { // try find in pending saves and load from that for (auto &obj : s_pending_save) { - if (obj.key == this->key) { + if (obj.key == this->key && obj.is_rtc() == this->is_rtc()) { if (obj.data.size() != len) { // size mismatch return false; @@ -46,8 +85,8 @@ bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) { } } - char key_str[UINT32_MAX_STR_SIZE]; - uint32_to_str(key_str, this->key); + char key_str[KEY_NAME_SIZE]; + key_name(key_str, this->key, this->is_rtc()); fdb_blob_make(this->blob, data, len); size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob); if (actual_len != len) { @@ -64,21 +103,57 @@ void LibreTinyPreferences::open() { fdb_err_t err = fdb_kvdb_init(&this->db, "esphome", "kvs", NULL, NULL); if (err != FDB_NO_ERR) { LT_E("fdb_kvdb_init(...) failed: %d", err); - } else { - LT_I("Preferences initialized"); + return; + } + LT_I("Preferences initialized"); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + if (!boot_kept_rtc_preferences()) + this->discard_rtc_entries_(); +#endif +} + +#ifdef USE_LIBRETINY_RTC_PREFERENCES +ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type, bool in_flash) { + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type, !in_flash))); +} + +// One pass over the database deletes every rtc entry. FlashDB only marks a deleted entry's status in place +// and its iterator steps by address, so deleting the current entry is safe. This runs before the logger +// exists, so it logs through LibreTiny; a failed delete is a flash fault the next write will report as well. +void LibreTinyPreferences::discard_rtc_entries_() { + uint16_t discarded = 0; + fdb_kv_iterator iterator; + fdb_kv_iterator_init(&iterator); + while (fdb_kv_iterate(&this->db, &iterator)) { + const char *name = iterator.curr_kv.name; + if (name[0] != RTC_KEY_PREFIX) + continue; + if (const fdb_err_t err = fdb_kv_del(&this->db, name); err != FDB_NO_ERR) { + LT_E("Failed to discard rtc preference %s: %d", name, err); + continue; + } + discarded++; + } + if (discarded != 0) { + LT_D("Discarded %u rtc preferences after a hard reset", discarded); } } +#endif ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type) { // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type))); } -LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type) { +LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type, bool rtc) { LibreTinyPreferenceBackend backend; backend.key = type; backend.db = &this->db; backend.blob = &this->blob; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + backend.rtc = rtc; +#endif return backend; } @@ -97,8 +172,8 @@ bool LibreTinyPreferences::sync() { uint32_t last_key = 0; for (const auto &save : s_pending_save) { - char key_str[UINT32_MAX_STR_SIZE]; - uint32_to_str(key_str, save.key); + char key_str[KEY_NAME_SIZE]; + key_name(key_str, save.key, save.is_rtc()); ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str); if (this->is_changed_(&this->db, save, key_str)) { ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size()); diff --git a/esphome/components/libretiny/preferences.h b/esphome/components/libretiny/preferences.h index fd86c48b20d..4b3737f07fd 100644 --- a/esphome/components/libretiny/preferences.h +++ b/esphome/components/libretiny/preferences.h @@ -1,6 +1,7 @@ #pragma once #ifdef USE_LIBRETINY +#include "esphome/core/defines.h" #include "esphome/core/preference_backend.h" #include @@ -12,9 +13,14 @@ class LibreTinyPreferences final : public PreferencesMixin public: using PreferencesMixin::make_preference; void open(); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash); +#else + // Without rtc storage every preference lives in flash; inline so builds that don't use rtc pay nothing ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { return this->make_preference(length, type); } +#endif ESPPreferenceObject make_preference(size_t length, uint32_t type); /// One-shot read of a stored preference by key, without allocating a backend bool load_from_key(uint32_t type, uint8_t *data, size_t len); @@ -25,8 +31,11 @@ class LibreTinyPreferences final : public PreferencesMixin struct fdb_blob blob; protected: - LibreTinyPreferenceBackend make_backend_(uint32_t type); + LibreTinyPreferenceBackend make_backend_(uint32_t type, bool rtc = false); bool is_changed_(fdb_kvdb_t db, const NVSData &to_save, const char *key_str); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + void discard_rtc_entries_(); +#endif }; void setup_preferences(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 935952d3b31..852ea3439a9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -717,6 +717,7 @@ #else #define USE_BK72XX_BLE_TRACKER #endif +#define USE_LIBRETINY_RTC_PREFERENCES #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_SUSPEND_LOOP #define USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/preferences.py b/esphome/preferences.py index c9a465d6252..b31dcecf874 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -3,8 +3,11 @@ Preferences can be stored either in flash (durable across power loss) or in RTC memory (fast, survives deep sleep and soft resets but not power loss). The flash-vs-RTC choice is only meaningful on platforms whose preferences backend -honors the ``in_flash`` flag — currently ESP32 and ESP8266. On other platforms -the value is accepted only as ``flash`` (the sole supported backend). +honors the ``in_flash`` flag — currently ESP32, ESP8266 and the Beken BK7231T, +BK7231N and BK7238. LibreTiny chips have no RTC memory: there ``rtc`` keeps the +preference in flash and discards it after a reset that RTC memory would not +survive. On other platforms the value is accepted only as ``flash`` (the sole +supported backend). Components include :func:`storage_schema` in their config and convert the chosen value with :func:`is_in_flash` when calling ``make_preference``. @@ -20,7 +23,7 @@ STORAGE_RTC = "rtc" def rtc_supported() -> bool: - """Whether the target has RTC-backed preferences (not ESP32-C2/C61); call at validation time.""" + """Whether the target has RTC-backed preferences (ESP8266, ESP32 except C2/C61, some Beken chips); call at validation time.""" if CORE.is_esp8266: return True if CORE.is_esp32: @@ -29,7 +32,26 @@ def rtc_supported() -> bool: # Mirrors the C++ SOC_RTC_MEM_SUPPORTED guard in the ESP32 backend return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) - return False + return _libretiny_rtc_supported() + + +def _libretiny_rtc_supported() -> bool: + """Whether this LibreTiny family accepts ``rtc``, emulated with flash plus the reset reason. + + The emulation is only as good as the family's ``lt_get_reboot_reason()``, so families are + listed once that reporting has been checked on hardware: a chip-enable reset and a power cut + both report a power-on, a software, watchdog or crash reset does not. + """ + if not CORE.is_libretiny: + return False + from esphome.components.libretiny import get_libretiny_family + from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231T, + FAMILY_BK7238, + ) + + return get_libretiny_family() in (FAMILY_BK7231T, FAMILY_BK7231N, FAMILY_BK7238) def _default_storage() -> str: @@ -58,15 +80,17 @@ def storage_schema(): def request_rtc_storage() -> None: - """Compile the RTC-backed storage into the ESP32 preferences backend. + """Compile the RTC-backed storage into the ESP32 or LibreTiny preferences backend. - The RTC storage region is left out of ESP32 builds unless something asks for - it, so unused builds don't reserve RTC memory. Call this from ``to_code`` - when a config option selects RTC storage. No-op on other platforms (ESP8266 - always has its RTC backend). + It is left out of ESP32 and LibreTiny builds unless something asks for it, so + unused builds don't reserve RTC memory or check the reset reason. Call this + from ``to_code`` when a config option selects RTC storage. No-op on other + platforms (ESP8266 always has its RTC backend). """ if CORE.is_esp32: cg.add_define("USE_ESP32_RTC_PREFERENCES") + elif _libretiny_rtc_supported(): + cg.add_define("USE_LIBRETINY_RTC_PREFERENCES") def validate_rtc_storage(value): diff --git a/tests/unit_tests/test_preferences.py b/tests/unit_tests/test_preferences.py index a1ab78bfbff..da56aa8d6fa 100644 --- a/tests/unit_tests/test_preferences.py +++ b/tests/unit_tests/test_preferences.py @@ -10,15 +10,26 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C3, VARIANT_ESP32C61, ) +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, + FAMILY_RTL8710B, + KEY_FAMILY, + KEY_LIBRETINY, +) import esphome.config_validation as cv from esphome.const import ( CONF_STORAGE, KEY_CORE, KEY_TARGET_PLATFORM, KEY_VARIANT, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, + PLATFORM_RTL87XX, ) from esphome.core import CORE @@ -191,3 +202,45 @@ def test_validate_storage_rejects_unknown_value() -> None: _set_esp32(VARIANT_ESP32) with pytest.raises(cv.Invalid): preferences.validate_storage("nvs") + + +def _set_libretiny(platform: str, family: str) -> None: + _set_platform(platform) + CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family} + + +@pytest.mark.parametrize("family", [FAMILY_BK7231T, FAMILY_BK7231N, FAMILY_BK7238]) +def test_rtc_allowed_on_tested_beken_families(family: str) -> None: + # Emulated with flash plus the reset reason, which was checked on hardware. + _set_libretiny(PLATFORM_BK72XX, family) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize( + ("platform", "family"), + [ + (PLATFORM_BK72XX, FAMILY_BK7251), + (PLATFORM_RTL87XX, FAMILY_RTL8710B), + ], +) +def test_rtc_rejected_on_other_libretiny_families(platform: str, family: str) -> None: + _set_libretiny(platform, family) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_default_storage_bk7231t_is_flash() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7231T) + assert _validate({})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +def test_request_rtc_storage_bk7231t_emits_define() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7231T) + preferences.request_rtc_storage() + assert "USE_LIBRETINY_RTC_PREFERENCES" in _define_names() + + +def test_request_rtc_storage_other_libretiny_family_is_a_no_op() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7251) + preferences.request_rtc_storage() + assert "USE_LIBRETINY_RTC_PREFERENCES" not in _define_names() From bb9067acd719ffc59915668004e6492ea2b3e291 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:07:16 +0200 Subject: [PATCH 33/50] [tfluna] Recover from transient runtime reconfiguration failures (#20992) Co-authored-by: J. Nick Koston --- esphome/components/tfluna/tfluna.cpp | 64 ++++-- esphome/components/tfluna/tfluna.h | 6 + tests/components/tfluna/test_tfluna.cpp | 282 ++++++++++++++++++++++++ 3 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 tests/components/tfluna/test_tfluna.cpp diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp index e3d7045d8e6..706213024d6 100644 --- a/esphome/components/tfluna/tfluna.cpp +++ b/esphome/components/tfluna/tfluna.cpp @@ -42,31 +42,25 @@ void TFLuna::dump_config() { #endif } -void TFLuna::setup() { +bool TFLuna::configure_() { uint8_t mode; if (!this->read_byte(MODE_REGISTER, &mode)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } if (mode != MODE_TRIGGER) { if (!this->write_byte(MODE_REGISTER, MODE_TRIGGER)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } if (!this->write_byte(SAVE_REGISTER, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } } uint8_t raw_version[3]; if (!this->read_bytes(VERSION_REVISION_REGISTER, raw_version, sizeof(raw_version))) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); - return; + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return true; } else { snprintf(this->version_, sizeof(this->version_), "%d.%d.%d", raw_version[2], raw_version[1], raw_version[0]); #ifdef USE_TEXT_SENSOR @@ -75,12 +69,26 @@ void TFLuna::setup() { } #endif } + return true; +} + +void TFLuna::setup() { + if (!this->configure_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void TFLuna::reconfigure_() { + this->reconfigure_pending_ = !this->configure_(); + if (this->reconfigure_pending_) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } } bool TFLuna::read_data_() { uint8_t buf[8]; if (!this->read_bytes(DISTANCE_LOW_REGISTER, buf, sizeof(buf))) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } // Layout: @@ -125,34 +133,50 @@ void TFLuna::read_data_timeout_() { this->attempt_++; this->set_timeout(READ_RETRY_TIMEOUT_ID, READ_RETRY_MS, [this]() { this->read_data_timeout_(); }); } else { - this->status_set_warning("Hung device, restarting..."); + this->status_set_warning(LOG_STR("Hung device, restarting...")); this->restart(); } } } void TFLuna::update() { + if (this->is_failed()) { + return; + } this->cancel_timeout(READ_RETRY_TIMEOUT_ID); this->attempt_ = 0; + if (this->reconfigure_pending_) { + this->reconfigure_(); + if (this->reconfigure_pending_) { + return; + } + } if (!this->write_byte(TRIGGER_ONESHOT_REGISTER, 0x01)) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->read_data_timeout_(); } void TFLuna::factory_reset() { - if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) { return; } - this->status_set_warning("Factory reset issued; waiting for device to become ready"); - this->set_timeout(SETUP_TIMEOUT_ID, 100, [this]() { this->setup(); }); + if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->reconfigure_pending_ = false; + this->status_set_warning(LOG_STR("Factory reset issued; waiting for device to become ready")); + this->set_timeout(SETUP_TIMEOUT_ID, 100, [this]() { this->reconfigure_(); }); } void TFLuna::restart() { + if (this->is_failed()) { + return; + } if (!this->write_byte(SHUTDOWN_REBOOT_REGISTER, 0x02)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/tfluna/tfluna.h b/esphome/components/tfluna/tfluna.h index 0cc866ef1df..84de5f05d58 100644 --- a/esphome/components/tfluna/tfluna.h +++ b/esphome/components/tfluna/tfluna.h @@ -51,7 +51,13 @@ class TFLuna : public i2c::I2CDevice, public PollingComponent { void read_data_timeout_(); + [[nodiscard]] bool configure_(); + + void reconfigure_(); + uint8_t attempt_ = 0; + + bool reconfigure_pending_ = false; }; } // namespace esphome::tfluna diff --git a/tests/components/tfluna/test_tfluna.cpp b/tests/components/tfluna/test_tfluna.cpp new file mode 100644 index 00000000000..30236263c74 --- /dev/null +++ b/tests/components/tfluna/test_tfluna.cpp @@ -0,0 +1,282 @@ +#include + +#include +#include +#include "esphome/components/tfluna/tfluna.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::tfluna::testing { + +static constexpr uint8_t TIMESTAMP_LOW = 0x06; +static constexpr uint8_t VERSION = 0x0A; +static constexpr uint8_t SAVE = 0x20; +static constexpr uint8_t RESTART = 0x21; +static constexpr uint8_t MODE = 0x23; +static constexpr uint8_t MODE_TRIGGER = 0x01; +static constexpr uint8_t TRIGGER = 0x24; +static constexpr uint8_t RESET = 0x29; +static constexpr uint8_t NO_FAULT = 0xFF; // outside the register file + +struct Fault { + bool read; + uint8_t reg; +}; + +// Register file behind read_register (register byte, then read) and write_register +// (register byte + data, no read). A fault is one NACK, then the bus heals. +class RegisterBus final : public i2c::I2CBus { + public: + std::array regs{}; + uint8_t fail_read{NO_FAULT}; + uint8_t fail_write{NO_FAULT}; + unsigned mode_writes{0}; + unsigned save_writes{0}; + + i2c::ErrorCode write_readv(uint8_t /*address*/, const uint8_t *out, size_t out_len, uint8_t *in, + size_t in_len) override { + if (out_len == 0 || out[0] + (out_len - 1) + in_len > this->regs.size()) + return i2c::ERROR_INVALID_ARGUMENT; + const uint8_t reg = out[0]; + if (out_len > 1) { + if (reg == this->fail_write) { + this->fail_write = NO_FAULT; + return i2c::ERROR_NOT_ACKNOWLEDGED; + } + std::copy(out + 1, out + out_len, this->regs.begin() + reg); + this->mode_writes += reg == MODE; + this->save_writes += reg == SAVE; + if (reg == RESET) + this->regs[MODE] = 0; // fixture choice: defaults are not trigger mode + if (reg == TRIGGER) + this->regs[TIMESTAMP_LOW]++; // fixture choice: every trigger yields a new frame + } + if (in_len != 0) { + if (reg == this->fail_read) { + this->fail_read = NO_FAULT; + return i2c::ERROR_NOT_ACKNOWLEDGED; + } + std::copy_n(this->regs.begin() + reg, in_len, in); + } + return i2c::ERROR_OK; + } +}; + +class TFLunaTest : public ::testing::Test { + protected: + void SetUp() override { + // App lives for the whole binary; a timer another test left would fire on a dead object in run_timers() + ASSERT_TRUE(scheduler_idle()); + this->owns_scheduler_ = true; + this->bus_.regs[VERSION] = 3; + this->bus_.regs[VERSION + 1] = 2; + this->bus_.regs[VERSION + 2] = 1; + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x10); + } + + // Timeouts capture &device_ and running one publishes it to App; drop both before the fixture dies + void TearDown() override { + if (!this->owns_scheduler_) + return; // SetUp already failed; leave the other test's timers unrun + App.scheduler.cancel_timeout(&this->device_, uint32_t{0}); + App.scheduler.cancel_timeout(&this->device_, uint32_t{1}); + App.scheduler.call(millis()); + EXPECT_FALSE(App.scheduler.next_schedule_in(millis()).has_value()); + // Recycled items hold no callback; free them so a later App reset has none of ours to lose + App.scheduler.trim_freelist(); + LoopBlockingGuard clear{nullptr, nullptr, millis()}; + } + + // Real clock past the existing 100 ms reset timeout; no new device timing + // Timers are serviced like the main loop during the real wait instead of one long sleep + static void run_timers() { + const uint32_t start = millis(); + while (millis() - start < 110) { + App.scheduler.call(millis()); + delay(1); + } + App.scheduler.call(millis()); + } + + // Threaded hosts' next_schedule_in() skips timers still staged for the heap; merging them first runs no callback + static bool scheduler_idle() { + App.scheduler.process_to_add(); + return !App.scheduler.next_schedule_in(millis()).has_value(); + } + + void fail_at_(Fault fault) { (fault.read ? this->bus_.fail_read : this->bus_.fail_write) = fault.reg; } + + // One NACK at the read-mode, write-mode or SAVE step; fatal at boot, retried by the next update at runtime + void expect_boot_nack_marks_failed_(Fault fault) { + this->fail_at_(fault); + this->device_.setup(); + EXPECT_TRUE(this->device_.is_failed()); + } + + void expect_runtime_nack_recovers_(Fault fault) { + this->device_.setup(); + this->device_.factory_reset(); + this->fail_at_(fault); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); + } + + RegisterBus bus_; + TFLuna device_; + bool owns_scheduler_{false}; +}; + +// A timeout another suite left staged must block ownership before any callback can run +TEST_F(TFLunaTest, StagedForeignTimeoutBlocksOwnership) { + TFLuna foreign; + bool fired = false; + App.scheduler.set_timeout(&foreign, uint32_t{0}, 1000, [&fired]() { fired = true; }); + EXPECT_FALSE(scheduler_idle()); + EXPECT_FALSE(fired); + // Drop the timer before foreign and fired leave scope; no ASSERT may return early above this + EXPECT_TRUE(App.scheduler.cancel_timeout(&foreign, uint32_t{0})); + App.scheduler.call(millis()); + EXPECT_FALSE(fired); + EXPECT_TRUE(scheduler_idle()); +} + +TEST_F(TFLunaTest, BootSelectsTriggerModeAndSaves) { + this->device_.setup(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 1u); + EXPECT_EQ(this->bus_.save_writes, 1u); +} + +TEST_F(TFLunaTest, NormalFactoryResetRestoresTriggerMode) { + this->device_.setup(); + this->device_.factory_reset(); + EXPECT_TRUE(this->device_.status_has_warning()); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_EQ(this->bus_.save_writes, 2u); + this->device_.update(); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, ResetWriteNackSchedulesNothing) { + this->device_.setup(); + this->bus_.fail_write = RESET; + this->device_.factory_reset(); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + EXPECT_EQ(this->bus_.mode_writes, 1u); + this->device_.update(); // no reconfigure was left pending; the next good read clears the warning + EXPECT_EQ(this->bus_.mode_writes, 1u); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, DoublePressInWindowReconfiguresOnce) { + this->device_.setup(); + this->device_.factory_reset(); + this->device_.factory_reset(); + run_timers(); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); +} + +// Original: the first NACK marks the component failed, so Scheduler::call() drops the second reset's timeout +TEST_F(TFLunaTest, ResetWhileReconfigurePendingWaitsForWindowThenRecovers) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->device_.factory_reset(); + this->device_.update(); // inside the 100 ms window: no early reconfigure + EXPECT_EQ(this->bus_.mode_writes, 1u); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 2u); + this->device_.update(); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, ResetWriteNackKeepsPendingReconfigure) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->bus_.fail_write = RESET; + this->device_.factory_reset(); + run_timers(); + this->device_.update(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, UpdateSkipsPollingWhileReconfigureFails) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->bus_.fail_read = MODE; + this->device_.update(); + EXPECT_EQ(this->bus_.regs[TIMESTAMP_LOW], 0); // no trigger issued + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, RestartWriteNackWarnsThenNextReadClears) { + this->device_.setup(); + this->bus_.fail_write = RESTART; + this->device_.restart(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +// A boot failure is permanent; runtime entry points must not touch the bus +TEST_F(TFLunaTest, FailedComponentSkipsRuntimeWrites) { + this->bus_.fail_read = MODE; + this->device_.setup(); + ASSERT_TRUE(this->device_.is_failed()); + this->device_.factory_reset(); + this->device_.restart(); + this->device_.update(); + EXPECT_TRUE(this->device_.is_failed()); + EXPECT_FALSE(this->device_.status_has_warning()); + EXPECT_EQ(this->bus_.regs[RESET], 0); + EXPECT_EQ(this->bus_.regs[RESTART], 0); + EXPECT_EQ(this->bus_.regs[TIMESTAMP_LOW], 0); + EXPECT_EQ(this->bus_.mode_writes, 0u); +} + +TEST_F(TFLunaTest, BootReadModeNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{true, MODE}); } + +TEST_F(TFLunaTest, BootWriteModeNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{false, MODE}); } + +TEST_F(TFLunaTest, BootSaveNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{false, SAVE}); } + +TEST_F(TFLunaTest, RuntimeReadModeNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{true, MODE}); +} + +TEST_F(TFLunaTest, RuntimeWriteModeNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{false, MODE}); +} + +TEST_F(TFLunaTest, RuntimeSaveNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{false, SAVE}); +} + +} // namespace esphome::tfluna::testing From e6f1ed8d510a9d8b5b3bae431df4d9f117c632aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:07:42 -1000 Subject: [PATCH 34/50] [tcp_uart] Expose the connection entities as platforms (#21009) --- esphome/components/tcp_uart/__init__.py | 29 +++-------------- esphome/components/tcp_uart/binary_sensor.py | 25 +++++++++++++++ esphome/components/tcp_uart/sensor.py | 31 +++++++++++++++++++ esphome/components/tcp_uart/tcp_uart.cpp | 6 ++++ esphome/components/tcp_uart/tcp_uart.h | 7 +++++ tests/components/tcp_uart/benchmark.yaml | 5 +-- tests/components/tcp_uart/common.yaml | 8 +++++ .../tcp_uart/test-client.esp32-idf.yaml | 8 +++++ .../integration/fixtures/tcp_uart_bridge.yaml | 4 +++ 9 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 esphome/components/tcp_uart/binary_sensor.py create mode 100644 esphome/components/tcp_uart/sensor.py diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index df9c1720ee6..1e120275602 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_DATA_BITS, CONF_HOST, CONF_PARITY, @@ -11,23 +10,16 @@ from esphome.components.const import ( CONF_STOP_BITS, ) import esphome.config_validation as cv -from esphome.const import ( - CONF_BAUD_RATE, - CONF_ID, - CONF_PORT, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, -) +from esphome.const import CONF_BAUD_RATE, CONF_ID, CONF_PORT from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DEPENDENCIES = ["network"] -AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["uart", "socket"] DOMAIN = "tcp_uart" MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_TCP_UART_ID = "tcp_uart_id" tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart") TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) @@ -46,15 +38,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -100,7 +83,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_parity(config[CONF_PARITY])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/binary_sensor.py b/esphome/components/tcp_uart/binary_sensor.py new file mode 100644 index 00000000000..8337973c837 --- /dev/null +++ b/esphome/components/tcp_uart/binary_sensor.py @@ -0,0 +1,25 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/tcp_uart/sensor.py b/esphome/components/tcp_uart/sensor.py new file mode 100644 index 00000000000..099ca8734e1 --- /dev/null +++ b/esphome/components/tcp_uart/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp index 3dead1fb65c..fa465aa55a8 100644 --- a/esphome/components/tcp_uart/tcp_uart.cpp +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -17,9 +17,11 @@ void TcpUart::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -38,7 +40,9 @@ void TcpUart::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -58,9 +62,11 @@ void TcpUart::sync_link_() { // Unread bytes of the last session stay readable while down, never into the next one. this->rx_start_ = this->rx_end_ = 0; } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h index 1e1d48e4c20..6ecfd1a0862 100644 --- a/esphome/components/tcp_uart/tcp_uart.h +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -23,7 +26,9 @@ class TcpUart : public uart::UARTComponent, public Component { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -64,7 +69,9 @@ class TcpUart : public uart::UARTComponent, public Component { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif #ifdef USE_SENSOR sensor::Sensor *disconnects_sensor_{nullptr}; uint32_t disconnects_{0}; diff --git a/tests/components/tcp_uart/benchmark.yaml b/tests/components/tcp_uart/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/tcp_uart/benchmark.yaml +++ b/tests/components/tcp_uart/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/tcp_uart/common.yaml b/tests/components/tcp_uart/common.yaml index 97ac0247da3..97bcd24f6a7 100644 --- a/tests/components/tcp_uart/common.yaml +++ b/tests/components/tcp_uart/common.yaml @@ -10,8 +10,16 @@ tcp_uart: allowed_ips: - 192.0.2.20 - 192.0.2.0/24 + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server connected: name: TCP UART Server Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server disconnects: name: TCP UART Server Disconnects diff --git a/tests/components/tcp_uart/test-client.esp32-idf.yaml b/tests/components/tcp_uart/test-client.esp32-idf.yaml index 74bd26a98bc..921ce0cbc1a 100644 --- a/tests/components/tcp_uart/test-client.esp32-idf.yaml +++ b/tests/components/tcp_uart/test-client.esp32-idf.yaml @@ -7,7 +7,15 @@ tcp_uart: host: 192.0.2.10 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: TCP UART Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 disconnects: name: TCP UART Disconnects diff --git a/tests/integration/fixtures/tcp_uart_bridge.yaml b/tests/integration/fixtures/tcp_uart_bridge.yaml index 12791e7dd46..74a8710b19e 100644 --- a/tests/integration/fixtures/tcp_uart_bridge.yaml +++ b/tests/integration/fixtures/tcp_uart_bridge.yaml @@ -20,6 +20,10 @@ tcp_uart: host: 127.0.0.1 port: 18125 reconnect_interval: 3s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: Bridge Connected From 42e849967a94eb8f82f6654b88cb142c05d8be3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:14 -1000 Subject: [PATCH 35/50] [bq76972] Expose the sensors as a sensor platform (#21006) --- esphome/components/bq76972/__init__.py | 60 +--------------- esphome/components/bq76972/bq76972.cpp | 8 +++ esphome/components/bq76972/bq76972.h | 9 ++- esphome/components/bq76972/sensor.py | 73 ++++++++++++++++++++ tests/components/bq76972/common.yaml | 96 ++++++++++++++------------ 5 files changed, 142 insertions(+), 104 deletions(-) create mode 100644 esphome/components/bq76972/sensor.py diff --git a/esphome/components/bq76972/__init__.py b/esphome/components/bq76972/__init__.py index 672d6428d26..46f674da269 100644 --- a/esphome/components/bq76972/__init__.py +++ b/esphome/components/bq76972/__init__.py @@ -1,18 +1,10 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import i2c, sensor +from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ADDRESS, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, - UNIT_CELSIUS, - UNIT_VOLT, -) +from esphome.const import CONF_ADDRESS from esphome.types import ConfigType -AUTO_LOAD = ["sensor"] CODEOWNERS = ["@limpkin"] DEPENDENCIES = ["i2c"] DOMAIN = "bq76972" @@ -25,37 +17,7 @@ BQ76972Component = bq76972_ns.class_( CONF_REG_DISABLED = "reg_disabled" CONF_CRC_ENABLED = "crc_enabled" -CONF_STACK_VOLTAGE = "stack_voltage" - -# Ordered as the chip reports them, starting at command 0x68 -CONF_TEMPERATURES = [ - "internal_temperature", - "cfetoff_temperature", - "dfetoff_temperature", - "alert_temperature", - "ts1_temperature", - "ts2_temperature", - "ts3_temperature", - "hdq_temperature", - "dchg_temperature", - "ddsg_temperature", -] - -CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)] - -voltage_sensor_schema = sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=3, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, -) - -temp_sensor_schema = sensor.sensor_schema( - unit_of_measurement=UNIT_CELSIUS, - accuracy_decimals=1, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, -) +CONF_BQ76972_ID = "bq76972_id" CONFIG_SCHEMA = ( cv.Schema( @@ -63,9 +25,6 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(BQ76972Component), cv.Optional(CONF_CRC_ENABLED, default=False): cv.boolean, cv.Optional(CONF_REG_DISABLED, default=False): cv.boolean, - cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema, - **{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES}, - **{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES}, } ) .extend(i2c.i2c_device_schema(0x08)) @@ -79,19 +38,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED])) cg.add(var.set_reg_disable(config[CONF_REG_DISABLED])) - sensors = sensor.sub_sensors(config) - await sensors(CONF_STACK_VOLTAGE, var.set_stack_voltage_sensor) - - for idx, key in enumerate(CONF_TEMPERATURES): - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(var.set_temperature_sensor(sens, idx)) - - for idx, key in enumerate(CONF_CELL_VOLTAGES): - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(var.set_cell_sensor(sens, idx)) - BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( { diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp index ea229307d5c..9426fbb2526 100644 --- a/esphome/components/bq76972/bq76972.cpp +++ b/esphome/components/bq76972/bq76972.cpp @@ -172,10 +172,12 @@ const LogString *BQ76972Component::configure_() { const bool write_regs = !regs_disabled; bool write_thermistors = false; +#ifdef USE_SENSOR for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { if (i != BQ76972_TS1_INDEX && this->temperature_sensors_[i] != nullptr) write_thermistors = true; } +#endif if (write_regs || write_thermistors) { if (!this->enter_cfg_update_()) @@ -201,12 +203,14 @@ const LogString *BQ76972Component::write_config_(bool write_regs) { !this->write_subcommand_(BQ76972_MEM_REG0_CONFIG, &zero, 1)) return LOG_STR("Writing regulator config failed"); } +#ifdef USE_SENSOR for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { if (i == BQ76972_TS1_INDEX || this->temperature_sensors_[i] == nullptr) continue; if (!this->write_subcommand_(BQ76972_MEM_TEMP_PIN_CONFIG_BASE + i, &BQ76972_PIN_CONFIG_THERMISTOR, 1)) return LOG_STR("Writing thermistor pin config failed"); } +#endif return nullptr; } @@ -248,6 +252,7 @@ void BQ76972Component::program_i2c_address(uint8_t address) { ESP_LOGI(TAG, "I2C address set to 0x%02X", address); } +#ifdef USE_SENSOR template static bool any_set(const std::array &sensors) { for (auto *sensor : sensors) { if (sensor != nullptr) @@ -294,5 +299,8 @@ void BQ76972Component::update() { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } +#else +void BQ76972Component::update() {} +#endif } // namespace esphome::bq76972 diff --git a/esphome/components/bq76972/bq76972.h b/esphome/components/bq76972/bq76972.h index d27a4ce96f4..ccc4f45cfaf 100644 --- a/esphome/components/bq76972/bq76972.h +++ b/esphome/components/bq76972/bq76972.h @@ -1,8 +1,11 @@ #pragma once -#include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif #include @@ -19,9 +22,11 @@ class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; } void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; } +#ifdef USE_SENSOR void set_stack_voltage_sensor(sensor::Sensor *s) { this->stack_voltage_sensor_ = s; } void set_cell_sensor(sensor::Sensor *s, size_t index) { this->cell_sensors_[index] = s; } void set_temperature_sensor(sensor::Sensor *s, size_t index) { this->temperature_sensors_[index] = s; } +#endif void program_i2c_address(uint8_t address); @@ -43,9 +48,11 @@ class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { bool reg_disable_{false}; bool crc_mode_{false}; +#ifdef USE_SENSOR sensor::Sensor *stack_voltage_sensor_{nullptr}; std::array cell_sensors_{}; std::array temperature_sensors_{}; +#endif }; } // namespace esphome::bq76972 diff --git a/esphome/components/bq76972/sensor.py b/esphome/components/bq76972/sensor.py new file mode 100644 index 00000000000..0c7efc41235 --- /dev/null +++ b/esphome/components/bq76972/sensor.py @@ -0,0 +1,73 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_VOLT, +) +from esphome.types import ConfigType + +from . import CONF_BQ76972_ID, BQ76972Component + +DEPENDENCIES = ["bq76972"] + +CONF_STACK_VOLTAGE = "stack_voltage" + +# Ordered as the chip reports them, starting at command 0x68 +CONF_TEMPERATURES = [ + "internal_temperature", + "cfetoff_temperature", + "dfetoff_temperature", + "alert_temperature", + "ts1_temperature", + "ts2_temperature", + "ts3_temperature", + "hdq_temperature", + "dchg_temperature", + "ddsg_temperature", +] + +CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)] + +voltage_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) + +temp_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_BQ76972_ID): cv.use_id(BQ76972Component), + cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema, + **{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES}, + **{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES}, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ76972_ID]) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_STACK_VOLTAGE, hub.set_stack_voltage_sensor) + + for idx, key in enumerate(CONF_TEMPERATURES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_temperature_sensor(sens, idx)) + + for idx, key in enumerate(CONF_CELL_VOLTAGES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_cell_sensor(sens, idx)) diff --git a/tests/components/bq76972/common.yaml b/tests/components/bq76972/common.yaml index 4b2e62eb20b..7270a5ce03c 100644 --- a/tests/components/bq76972/common.yaml +++ b/tests/components/bq76972/common.yaml @@ -1,60 +1,64 @@ bq76972: - id: bq76972_id + id: bms i2c_id: i2c_bus address: 0x08 crc_enabled: true reg_disabled: true update_interval: 10s - stack_voltage: - name: "Pack Total Voltage" - internal_temperature: - name: "Temperature - Internal" - ts1_temperature: - name: "Temperature - Pin TS1" - ts2_temperature: - name: "Temperature - Pin TS2" - ts3_temperature: - name: "Temperature - Pin TS3" - hdq_temperature: - name: "Temperature - Pin HDQ" - cell_1_voltage: - name: "Cell 1 Voltage" - cell_2_voltage: - name: "Cell 2 Voltage" - cell_3_voltage: - name: "Cell 3 Voltage" - cell_4_voltage: - name: "Cell 4 Voltage" - cell_5_voltage: - name: "Cell 5 Voltage" - cell_6_voltage: - name: "Cell 6 Voltage" - cell_7_voltage: - name: "Cell 7 Voltage" - cell_8_voltage: - name: "Cell 8 Voltage" - cell_9_voltage: - name: "Cell 9 Voltage" - cell_10_voltage: - name: "Cell 10 Voltage" - cell_11_voltage: - name: "Cell 11 Voltage" - cell_12_voltage: - name: "Cell 12 Voltage" - cell_13_voltage: - name: "Cell 13 Voltage" - cell_14_voltage: - name: "Cell 14 Voltage" - cell_15_voltage: - name: "Cell 15 Voltage" - cell_16_voltage: - name: "Cell 16 Voltage" button: - platform: template name: "Program BQ I2C Address" on_press: - bq76972.program_i2c_address: - id: bq76972_id + id: bms address: 0x09 - bq76972.program_i2c_address: !lambda "return 0x08;" + +sensor: + - platform: bq76972 + bq76972_id: bms + stack_voltage: + name: "Pack Total Voltage" + internal_temperature: + name: "Temperature - Internal" + ts1_temperature: + name: "Temperature - Pin TS1" + ts2_temperature: + name: "Temperature - Pin TS2" + ts3_temperature: + name: "Temperature - Pin TS3" + hdq_temperature: + name: "Temperature - Pin HDQ" + cell_1_voltage: + name: "Cell 1 Voltage" + cell_2_voltage: + name: "Cell 2 Voltage" + cell_3_voltage: + name: "Cell 3 Voltage" + cell_4_voltage: + name: "Cell 4 Voltage" + cell_5_voltage: + name: "Cell 5 Voltage" + cell_6_voltage: + name: "Cell 6 Voltage" + cell_7_voltage: + name: "Cell 7 Voltage" + cell_8_voltage: + name: "Cell 8 Voltage" + cell_9_voltage: + name: "Cell 9 Voltage" + cell_10_voltage: + name: "Cell 10 Voltage" + cell_11_voltage: + name: "Cell 11 Voltage" + cell_12_voltage: + name: "Cell 12 Voltage" + cell_13_voltage: + name: "Cell 13 Voltage" + cell_14_voltage: + name: "Cell 14 Voltage" + cell_15_voltage: + name: "Cell 15 Voltage" + cell_16_voltage: + name: "Cell 16 Voltage" From e034662dc6cf1f82fc19bb15d5a9d9ef109da6f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:32 -1000 Subject: [PATCH 36/50] [uart_tcp] Expose the connection entities as platforms (#21008) --- esphome/components/uart_tcp/__init__.py | 23 ++------------ esphome/components/uart_tcp/binary_sensor.py | 25 +++++++++++++++ esphome/components/uart_tcp/sensor.py | 31 +++++++++++++++++++ esphome/components/uart_tcp/uart_tcp.cpp | 6 ++++ esphome/components/uart_tcp/uart_tcp.h | 7 +++++ tests/components/uart_tcp/benchmark.yaml | 5 +-- tests/components/uart_tcp/common.yaml | 8 +++++ .../uart_tcp/test-client.esp32-idf.yaml | 8 +++++ tests/components/uart_tcp/test.host.yaml | 8 +++++ .../integration/fixtures/uart_tcp_bridge.yaml | 8 +++-- 10 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 esphome/components/uart_tcp/binary_sensor.py create mode 100644 esphome/components/uart_tcp/sensor.py diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 9d62d48d7c2..c7eba4a26a5 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_HOST, CONF_RECONNECT_INTERVAL, CONF_ROLE, @@ -15,9 +14,6 @@ from esphome.const import ( CONF_ID, CONF_PORT, CONF_UART_ID, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, ) from esphome.core import CORE import esphome.final_validate as fv @@ -26,10 +22,10 @@ from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DOMAIN = "uart_tcp" DEPENDENCIES = ["network", "uart"] -AUTO_LOAD = ["binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["socket"] MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_UART_TCP_ID = "uart_tcp_id" uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp") UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice) @@ -43,15 +39,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -144,7 +131,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/binary_sensor.py b/esphome/components/uart_tcp/binary_sensor.py new file mode 100644 index 00000000000..c8d19319337 --- /dev/null +++ b/esphome/components/uart_tcp/binary_sensor.py @@ -0,0 +1,25 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/uart_tcp/sensor.py b/esphome/components/uart_tcp/sensor.py new file mode 100644 index 00000000000..ad44b56ad09 --- /dev/null +++ b/esphome/components/uart_tcp/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp index d634ac579f2..ca6c2204bdd 100644 --- a/esphome/components/uart_tcp/uart_tcp.cpp +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -16,9 +16,11 @@ void UartTcp::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -37,7 +39,9 @@ void UartTcp::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -57,9 +61,11 @@ void UartTcp::sync_link_() { // The driver kept whatever arrived while the link was down. this->discard_uart_(); } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/uart_tcp/uart_tcp.h b/esphome/components/uart_tcp/uart_tcp.h index 256e059133a..6080cc95514 100644 --- a/esphome/components/uart_tcp/uart_tcp.h +++ b/esphome/components/uart_tcp/uart_tcp.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -22,7 +25,9 @@ class UartTcp final : public Component, public uart::UARTDevice { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -53,7 +58,9 @@ class UartTcp final : public Component, public uart::UARTDevice { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif // Loop start time of the last socket-to-UART write; sizes the next paced write. uint32_t last_write_ms_{0}; #ifdef USE_SENSOR diff --git a/tests/components/uart_tcp/benchmark.yaml b/tests/components/uart_tcp/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/uart_tcp/benchmark.yaml +++ b/tests/components/uart_tcp/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/uart_tcp/common.yaml b/tests/components/uart_tcp/common.yaml index fd34757d0ee..cf86dc9da89 100644 --- a/tests/components/uart_tcp/common.yaml +++ b/tests/components/uart_tcp/common.yaml @@ -10,7 +10,15 @@ uart_tcp: allowed_ips: - 192.168.1.10 - 192.168.1.0/24 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test-client.esp32-idf.yaml b/tests/components/uart_tcp/test-client.esp32-idf.yaml index 5ba900347c4..6bce693f854 100644 --- a/tests/components/uart_tcp/test-client.esp32-idf.yaml +++ b/tests/components/uart_tcp/test-client.esp32-idf.yaml @@ -12,7 +12,15 @@ uart_tcp: host: 192.0.2.20 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test.host.yaml b/tests/components/uart_tcp/test.host.yaml index a0b82c568df..b793ae144c3 100644 --- a/tests/components/uart_tcp/test.host.yaml +++ b/tests/components/uart_tcp/test.host.yaml @@ -8,7 +8,15 @@ uart_tcp: uart_id: uart_bus host: 127.0.0.1 port: 44502 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/integration/fixtures/uart_tcp_bridge.yaml b/tests/integration/fixtures/uart_tcp_bridge.yaml index 7ad28316f75..cf6c8fad64b 100644 --- a/tests/integration/fixtures/uart_tcp_bridge.yaml +++ b/tests/integration/fixtures/uart_tcp_bridge.yaml @@ -24,11 +24,15 @@ uart_tcp: port: 18126 allowed_ips: - 127.0.0.1 - connected: - name: Bridge Connected - id: denied_bridge uart_id: denied_bus role: server port: 18127 allowed_ips: - 192.0.2.1 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge + connected: + name: Bridge Connected From 1197f22f0067961440d07af468c59a38a6e7f420 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:49 -1000 Subject: [PATCH 37/50] [fusb302b] Expose the sensors as platforms (#21004) --- esphome/components/fusb302b/__init__.py | 37 +----------------- esphome/components/fusb302b/sensor.py | 45 ++++++++++++++++++++++ esphome/components/fusb302b/text_sensor.py | 27 +++++++++++++ tests/components/fusb302b/common.yaml | 20 +++++++--- 4 files changed, 88 insertions(+), 41 deletions(-) create mode 100644 esphome/components/fusb302b/sensor.py create mode 100644 esphome/components/fusb302b/text_sensor.py diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index 26bf49b28e3..a1770605498 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -1,30 +1,21 @@ from esphome import automation, pins import esphome.codegen as cg -from esphome.components import i2c, sensor, text_sensor +from esphome.components import i2c import esphome.config_validation as cv from esphome.const import ( - CONF_CURRENT, CONF_INTERRUPT_PIN, CONF_ON_CONNECT, CONF_ON_DISCONNECT, CONF_ON_ERROR, CONF_VOLTAGE, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_VOLTAGE, - ENTITY_CATEGORY_DIAGNOSTIC, - ICON_FLASH, - STATE_CLASS_MEASUREMENT, - UNIT_AMPERE, - UNIT_VOLT, ) from esphome.types import ConfigType -AUTO_LOAD = ["sensor", "text_sensor"] CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] DOMAIN = "fusb302b" -CONF_CONTRACT = "contract" +CONF_FUSB302B_ID = "fusb302b_id" CONF_ON_POWER_READY = "on_power_ready" CONF_ON_REQUEST_FAILED = "on_request_failed" CONF_REQUEST_VOLTAGE = "request_voltage" @@ -47,24 +38,6 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(FUSB302B), cv.Required(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pullup_pin_schema, cv.Required(CONF_REQUEST_VOLTAGE): validate_voltage, - cv.Optional(CONF_CONTRACT): text_sensor.text_sensor_schema( - icon=ICON_FLASH, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_CURRENT): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}), cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}), cv.Optional(CONF_ON_POWER_READY): automation.validate_automation({}), @@ -101,12 +74,6 @@ async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config, interrupt_pin) cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) - sensors = sensor.sub_sensors(config) - await sensors(CONF_VOLTAGE, var.set_voltage_sensor) - await sensors(CONF_CURRENT, var.set_current_sensor) - text_sensors = text_sensor.sub_text_sensors(config) - await text_sensors(CONF_CONTRACT, var.set_contract_text_sensor) - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/fusb302b/sensor.py b/esphome/components/fusb302b/sensor.py new file mode 100644 index 00000000000..34dd35a60c7 --- /dev/null +++ b/esphome/components/fusb302b/sensor.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_CURRENT, + CONF_VOLTAGE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_VOLT, +) +from esphome.types import ConfigType + +from . import CONF_FUSB302B_ID, FUSB302B + +DEPENDENCIES = ["fusb302b"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_FUSB302B_ID): cv.use_id(FUSB302B), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_FUSB302B_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, hub.set_voltage_sensor) + await sensors(CONF_CURRENT, hub.set_current_sensor) diff --git a/esphome/components/fusb302b/text_sensor.py b/esphome/components/fusb302b/text_sensor.py new file mode 100644 index 00000000000..ee18af469e4 --- /dev/null +++ b/esphome/components/fusb302b/text_sensor.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_FLASH +from esphome.types import ConfigType + +from . import CONF_FUSB302B_ID, FUSB302B + +DEPENDENCIES = ["fusb302b"] + +CONF_CONTRACT = "contract" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_FUSB302B_ID): cv.use_id(FUSB302B), + cv.Optional(CONF_CONTRACT): text_sensor.text_sensor_schema( + icon=ICON_FLASH, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_FUSB302B_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_CONTRACT, hub.set_contract_text_sensor) diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml index e2c4ee87ca7..60c8cc615e9 100644 --- a/tests/components/fusb302b/common.yaml +++ b/tests/components/fusb302b/common.yaml @@ -3,12 +3,6 @@ fusb302b: i2c_id: i2c_bus interrupt_pin: ${interrupt_pin} request_voltage: 20 - contract: - name: PD Contract - voltage: - name: PD Voltage - current: - name: PD Current on_connect: - logger.log: "Source attached" on_disconnect: @@ -26,3 +20,17 @@ fusb302b: - fusb302b.request_voltage: id: pd voltage: !lambda "return 9;" + +sensor: + - platform: fusb302b + fusb302b_id: pd + voltage: + name: PD Voltage + current: + name: PD Current + +text_sensor: + - platform: fusb302b + fusb302b_id: pd + contract: + name: PD Contract From 3b7847d951a9cc70a8153885df2d254b600424c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:13:49 -1000 Subject: [PATCH 38/50] [components] Create SPI devices with new_spi_component (#20920) --- esphome/components/adc128s102/__init__.py | 5 +---- esphome/components/ads1118/__init__.py | 5 +---- esphome/components/atm90e26/sensor.py | 5 +---- esphome/components/cc1101/__init__.py | 4 +--- esphome/components/cs5460a/sensor.py | 4 +--- esphome/components/honeywellabp/sensor.py | 5 +---- esphome/components/mcp3008/__init__.py | 5 +---- esphome/components/sx126x/__init__.py | 4 +--- esphome/components/sx127x/__init__.py | 4 +--- 9 files changed, 9 insertions(+), 32 deletions(-) diff --git a/esphome/components/adc128s102/__init__.py b/esphome/components/adc128s102/__init__.py index 125b4113c57..56df70a170c 100644 --- a/esphome/components/adc128s102/__init__.py +++ b/esphome/components/adc128s102/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -20,6 +19,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/ads1118/__init__.py b/esphome/components/ads1118/__init__.py index 98129ecbea3..e7cc1b4f45a 100644 --- a/esphome/components/ads1118/__init__.py +++ b/esphome/components/ads1118/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@solomondg1"] @@ -26,6 +25,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/atm90e26/sensor.py b/esphome/components/atm90e26/sensor.py index 5a6b667535f..ed116faaba0 100644 --- a/esphome/components/atm90e26/sensor.py +++ b/esphome/components/atm90e26/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CURRENT, CONF_FORWARD_ACTIVE_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_LINE_FREQUENCY, CONF_POWER, CONF_POWER_FACTOR, @@ -125,9 +124,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 2660d5bbde7..5375d6bf396 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -274,9 +274,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) for opt in CONFIG_MAP: key = opt.schema diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 54e452efc88..eab99fde147 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -103,9 +103,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) cg.add(var.set_samples(config[CONF_SAMPLES])) cg.add(var.set_phase_offset(config[CONF_PHASE_OFFSET])) diff --git a/esphome/components/honeywellabp/sensor.py b/esphome/components/honeywellabp/sensor.py index d7e7cf29a4e..2549610b8ed 100644 --- a/esphome/components/honeywellabp/sensor.py +++ b/esphome/components/honeywellabp/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, spi import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -52,9 +51,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) sensors = sensor.sub_sensors(config) if await sensors(CONF_PRESSURE, var.set_pressure_sensor): diff --git a/esphome/components/mcp3008/__init__.py b/esphome/components/mcp3008/__init__.py index f29050f9fe5..aa3fb5c64ef 100644 --- a/esphome/components/mcp3008/__init__.py +++ b/esphome/components/mcp3008/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -22,6 +21,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 075aad534e7..3af042ea72b 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -238,9 +238,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) if CONF_ON_PACKET in config: await automation.build_automation( var.get_packet_trigger(), diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 4b54c646338..08660415a62 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -221,9 +221,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) if CONF_ON_PACKET in config: await automation.build_automation( var.get_packet_trigger(), From ec9a207f42c7869d64daa1c49c95a68e3ef23681 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:15:21 -1000 Subject: [PATCH 39/50] [components] Create UART devices with new_uart_component (#20919) --- esphome/components/apc1/sensor.py | 5 +---- esphome/components/bl0906/sensor.py | 4 +--- esphome/components/bl0939/sensor.py | 5 +---- esphome/components/bl0940/sensor.py | 5 +---- esphome/components/bl0942/sensor.py | 5 +---- esphome/components/cm1106/sensor.py | 5 +---- esphome/components/cse7761/sensor.py | 5 +---- esphome/components/cse7766/sensor.py | 5 +---- esphome/components/daly_bms/__init__.py | 6 ++---- esphome/components/dfplayer/__init__.py | 6 ++---- esphome/components/dfrobot_sen0395/__init__.py | 4 +--- esphome/components/dfrobot_sen0609/__init__.py | 4 +--- esphome/components/gcja5/sensor.py | 5 +---- esphome/components/gps/__init__.py | 4 +--- esphome/components/hc8/sensor.py | 4 +--- esphome/components/hlk_fm22x/__init__.py | 5 +---- esphome/components/hlw8032/sensor.py | 5 +---- esphome/components/hydreon_rgxx/sensor.py | 5 +---- esphome/components/kamstrup_kmp/sensor.py | 5 +---- esphome/components/ld2410/__init__.py | 4 +--- esphome/components/ld2410s/__init__.py | 5 +---- esphome/components/ld2412/__init__.py | 6 ++---- esphome/components/ld2420/__init__.py | 5 +---- esphome/components/ld2450/__init__.py | 6 ++---- esphome/components/ld2460/__init__.py | 6 ++---- esphome/components/ld6002b/__init__.py | 6 ++---- esphome/components/mhz19/sensor.py | 4 +--- esphome/components/micronova/__init__.py | 5 +---- esphome/components/mitsubishi_cn105/__init__.py | 4 +--- esphome/components/mk2pvrouter/__init__.py | 6 ++---- esphome/components/pipsolar/__init__.py | 5 +---- esphome/components/pm1006/sensor.py | 5 +---- esphome/components/pmsx003/sensor.py | 5 +---- esphome/components/pylontech/__init__.py | 5 +---- esphome/components/pzem004t/sensor.py | 5 +---- esphome/components/rd03d/__init__.py | 6 ++---- esphome/components/rdm6300/__init__.py | 6 ++---- esphome/components/rf_bridge/__init__.py | 5 +---- esphome/components/sds011/sensor.py | 5 +---- esphome/components/seeed_mr60bha2/__init__.py | 5 +---- esphome/components/seeed_mr60fda2/__init__.py | 5 +---- esphome/components/senseair/sensor.py | 4 +--- esphome/components/serial_proxy/__init__.py | 6 ++---- esphome/components/sim800l/__init__.py | 6 ++---- esphome/components/sm300d2/sensor.py | 5 +---- esphome/components/sml/__init__.py | 6 ++---- esphome/components/smt100/sensor.py | 5 +---- esphome/components/sun_gtil2/__init__.py | 5 +---- esphome/components/systa_bus/__init__.py | 5 +---- esphome/components/t6615/sensor.py | 5 +---- esphome/components/teleinfo/__init__.py | 5 +---- esphome/components/tmc22xx/__init__.py | 4 +--- esphome/components/tuya/__init__.py | 6 ++---- esphome/components/uart_tcp/__init__.py | 4 +--- esphome/components/ufm01/__init__.py | 4 +--- esphome/components/uponor_smatrix/__init__.py | 6 ++---- esphome/components/vbus/__init__.py | 5 +---- esphome/components/zwave_proxy/__init__.py | 6 ++---- 58 files changed, 73 insertions(+), 220 deletions(-) diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py index b64a49903f2..f3f7e0203f9 100644 --- a/esphome/components/apc1/sensor.py +++ b/esphome/components/apc1/sensor.py @@ -7,7 +7,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ECO2, CONF_HUMIDITY, - CONF_ID, CONF_PM_0_3UM, CONF_PM_0_5UM, CONF_PM_1_0, @@ -165,9 +164,7 @@ FINAL_VALIDATE_SCHEMA = final_validate 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 25c50555dbc..d8e2f092c06 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -147,9 +147,7 @@ automation.register_apply_action( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FREQUENCY, var.set_frequency_sensor) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 5e33759dea4..45a2e6e983d 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_MODE, CONF_VOLTAGE, @@ -112,9 +111,7 @@ CONFIG_SCHEMA = ( 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) + var = await uart.new_uart_component(config) if (mode := config[CONF_MODE]) != DEFAULT_MODE: cg.add(var.set_mode(mode)) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 1cf7e8e6e38..962abf636d1 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CURRENT, CONF_ENERGY, CONF_EXTERNAL_TEMPERATURE, - CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_POWER, CONF_REFERENCE_VOLTAGE, @@ -227,9 +226,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/bl0942/sensor.py b/esphome/components/bl0942/sensor.py index 397471cf7a9..a5e6ffb64db 100644 --- a/esphome/components/bl0942/sensor.py +++ b/esphome/components/bl0942/sensor.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CURRENT, CONF_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_LINE_FREQUENCY, CONF_POWER, CONF_RESET, @@ -97,9 +96,7 @@ CONFIG_SCHEMA = ( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index fa179f9667d..43c1f44d3d1 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_BASELINE, CONF_CO2, CONF_CYCLE, - CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, ICON_MOLECULE_CO2, STATE_CLASS_MEASUREMENT, @@ -68,9 +67,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: """Code generation entry point.""" - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 1618cb43962..17c23242f7f 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -3,7 +3,6 @@ from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( CONF_FREQUENCY, - CONF_ID, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -121,9 +120,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_CURRENT_INPUT]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config, config[CONF_CURRENT_INPUT]) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cse7766/sensor.py b/esphome/components/cse7766/sensor.py index 86b6965e3a2..d5162230db0 100644 --- a/esphome/components/cse7766/sensor.py +++ b/esphome/components/cse7766/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_APPARENT_POWER, CONF_CURRENT, CONF_ENERGY, - CONF_ID, CONF_POWER, CONF_POWER_FACTOR, CONF_REACTIVE_POWER, @@ -94,9 +93,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/daly_bms/__init__.py b/esphome/components/daly_bms/__init__.py index 01d0eef857f..e2234985d43 100644 --- a/esphome/components/daly_bms/__init__.py +++ b/esphome/components/daly_bms/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.const import CONF_ADDRESS from esphome.types import ConfigType CODEOWNERS = ["@s1lvi0"] @@ -37,7 +37,5 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 69b745df9cf..719a76ff07d 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -5,7 +5,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_LOOP import esphome.config_validation as cv -from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME +from esphome.const import CONF_DEVICE, CONF_FILE, CONF_VOLUME DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] @@ -51,9 +51,7 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index d4e7aed91d8..801c2654a01 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -42,9 +42,7 @@ CONFIG_SCHEMA = cv.All( 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) + await uart.new_uart_component(config) automation.register_apply_action( diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py index 76ca362949a..d588d70efd1 100644 --- a/esphome/components/dfrobot_sen0609/__init__.py +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -79,7 +79,5 @@ def final_validate_mode(keys: dict[str, str]) -> Callable[[ConfigType], None]: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], RUN_MODES[config[CONF_MODE]]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, RUN_MODES[config[CONF_MODE]]) diff --git a/esphome/components/gcja5/sensor.py b/esphome/components/gcja5/sensor.py index 49907443ff9..85110dafc25 100644 --- a/esphome/components/gcja5/sensor.py +++ b/esphome/components/gcja5/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -113,9 +112,7 @@ TYPES = { 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) + var = await uart.new_uart_component(config) for key, funcName in TYPES.items(): if key in config: diff --git a/esphome/components/gps/__init__.py b/esphome/components/gps/__init__.py index cc77dff496b..be1b13b70e9 100644 --- a/esphome/components/gps/__init__.py +++ b/esphome/components/gps/__init__.py @@ -96,9 +96,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("gps", require_rx=True 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) + var = await uart.new_uart_component(config) # Pre-create all sensor variables so automations that reference # sibling sensors don't deadlock waiting for unregistered IDs. diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 10074fbf10f..7ef0ce14c50 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -51,9 +51,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index 72d3cc2ada3..a013fe884d3 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -4,7 +4,6 @@ from esphome.components import uart import esphome.config_validation as cv from esphome.const import ( CONF_DIRECTION, - CONF_ID, CONF_NAME, CONF_ON_ENROLLMENT_DONE, CONF_ON_ENROLLMENT_FAILED, @@ -90,9 +89,7 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/hlw8032/sensor.py b/esphome/components/hlw8032/sensor.py index f2a8ffb2161..09cfe7320b4 100644 --- a/esphome/components/hlw8032/sensor.py +++ b/esphome/components/hlw8032/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_APPARENT_POWER, CONF_CURRENT, CONF_CURRENT_RESISTOR, - CONF_ID, CONF_POWER, CONF_POWER_FACTOR, CONF_VOLTAGE, @@ -75,9 +74,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index 222a5308ae1..eeab5303922 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_MODEL, CONF_MOISTURE, CONF_RESOLUTION, @@ -140,10 +139,8 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add_define( "HYDREON_RGXX_PROTOCOL_LIST(F, sep)", diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index f6c236b72df..ae00f86e264 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_COMMAND, CONF_CUSTOM, CONF_FLOW, - CONF_ID, CONF_POWER, CONF_VOLUME, DEVICE_CLASS_EMPTY, @@ -113,9 +112,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) # Standard sensors for key in [ diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index e2728aa73c7..a03d87d9168 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -72,9 +72,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + await uart.new_uart_component(config) CALIBRATION_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py index ecc90455d1c..f4e09cc1867 100644 --- a/esphome/components/ld2410s/__init__.py +++ b/esphome/components/ld2410s/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -37,6 +36,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + await uart.new_uart_component(config) diff --git a/esphome/components/ld2412/__init__.py b/esphome/components/ld2412/__init__.py index fd63f78c3d4..9a1772e780d 100644 --- a/esphome/components/ld2412/__init__.py +++ b/esphome/components/ld2412/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.const import CONF_THROTTLE from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -43,6 +43,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + await uart.new_uart_component(config) diff --git a/esphome/components/ld2420/__init__.py b/esphome/components/ld2420/__init__.py index 87a54d278a7..b92e84d5965 100644 --- a/esphome/components/ld2420/__init__.py +++ b/esphome/components/ld2420/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@descipher"] @@ -36,6 +35,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + await uart.new_uart_component(config) diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 066ace871f8..dfca220b6a7 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -3,7 +3,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE +from esphome.const import CONF_ON_DATA, CONF_THROTTLE from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -63,7 +63,5 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld2460/__init__.py b/esphome/components/ld2460/__init__.py index 88c3a173efe..84cba297190 100644 --- a/esphome/components/ld2460/__init__.py +++ b/esphome/components/ld2460/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.const import CONF_ON_DATA from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -41,7 +41,5 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index c7529f93149..48017a8f00e 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_WAKEUP_PIN +from esphome.const import CONF_WAKEUP_PIN from esphome.types import ConfigType from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE @@ -62,9 +62,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) if wakeup_pin_config := config.get(CONF_WAKEUP_PIN): pin = await cg.gpio_pin_expression(wakeup_pin_config) diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 4daeef5059c..5c5b24f6f48 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -73,9 +73,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index ff06d0b913d..3745b1084ba 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -4,7 +4,6 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.cpp_generator import MockObj @@ -106,9 +105,7 @@ async def to_code_micronova_listener( async def to_code(config: ConfigType) -> None: enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) - var = cg.new_Pvariable(config[CONF_ID], enable_rx_pin) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, enable_rx_pin) CORE.add_job(_final_step) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 442869eec53..ba51bd3a806 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -90,9 +90,7 @@ async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> 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) + var = await uart.new_uart_component(config) cg.add( var.set_telemetry_request_min_interval( config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] diff --git a/esphome/components/mk2pvrouter/__init__.py b/esphome/components/mk2pvrouter/__init__.py index 7df1f7104f5..3d84c40c9ed 100644 --- a/esphome/components/mk2pvrouter/__init__.py +++ b/esphome/components/mk2pvrouter/__init__.py @@ -1,7 +1,7 @@ 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.const import CONF_TAG from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -65,6 +65,4 @@ async def register_mk2pvrouter_listener(mk2pvrouter: MockObj, var: MockObj) -> N 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) + await uart.new_uart_component(config) diff --git a/esphome/components/pipsolar/__init__.py b/esphome/components/pipsolar/__init__.py index 54337b0b130..77b97b24719 100644 --- a/esphome/components/pipsolar/__init__.py +++ b/esphome/components/pipsolar/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -29,6 +28,4 @@ CONFIG_SCHEMA = cv.All( 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) + await uart.new_uart_component(config) diff --git a/esphome/components/pm1006/sensor.py b/esphome/components/pm1006/sensor.py index 98c64066761..fc1bec8296b 100644 --- a/esphome/components/pm1006/sensor.py +++ b/esphome/components/pm1006/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_2_5, CONF_UPDATE_INTERVAL, DEVICE_CLASS_PM25, @@ -58,9 +57,7 @@ FINAL_VALIDATE_SCHEMA = validate_interval_uart 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index 723b99a5040..55f0c0678b2 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_PM_0_3UM, CONF_PM_0_5UM, CONF_PM_1_0, @@ -328,9 +327,7 @@ FINAL_VALIDATE_SCHEMA = final_validate 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) + var = await uart.new_uart_component(config) cg.add(var.set_type(config[CONF_TYPE])) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index e15f8f04c82..3fc194da057 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -6,7 +6,6 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -108,7 +107,5 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/pzem004t/sensor.py b/esphome/components/pzem004t/sensor.py index 2b19cf84985..9a2b73ea4cc 100644 --- a/esphome/components/pzem004t/sensor.py +++ b/esphome/components/pzem004t/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, CONF_ENERGY, - CONF_ID, CONF_POWER, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, @@ -65,9 +64,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/rd03d/__init__.py b/esphome/components/rd03d/__init__.py index 96bf4ffd743..ff2eaebad59 100644 --- a/esphome/components/rd03d/__init__.py +++ b/esphome/components/rd03d/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.const import CONF_THROTTLE from esphome.types import ConfigType CODEOWNERS = ["@jasstrong"] @@ -41,9 +41,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) if CONF_TRACKING_MODE in config: cg.add(var.set_tracking_mode(config[CONF_TRACKING_MODE])) diff --git a/esphome/components/rdm6300/__init__.py b/esphome/components/rdm6300/__init__.py index 86eb2cd7380..18d8e551f95 100644 --- a/esphome/components/rdm6300/__init__.py +++ b/esphome/components/rdm6300/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_TAG, CONF_TRIGGER_ID +from esphome.const import CONF_ON_TAG, CONF_TRIGGER_ID from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -37,9 +37,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index e967335c180..e970df9faa8 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CODE, CONF_DURATION, CONF_HIGH, - CONF_ID, CONF_LENGTH, CONF_LOW, CONF_PROTOCOL, @@ -70,9 +69,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index fe1481df9a5..93a2b9b0212 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_2_5, CONF_PM_10_0, CONF_RX_ONLY, @@ -85,9 +84,7 @@ async def to_code(config: ConfigType) -> None: # Pop update_interval before register_component so it doesn't generate # a set_update_interval call — sds011 handles this via set_update_interval_min update_interval = config.pop(CONF_UPDATE_INTERVAL, None) - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if update_interval is not None: cg.add(var.set_update_interval_min(update_interval)) diff --git a/esphome/components/seeed_mr60bha2/__init__.py b/esphome/components/seeed_mr60bha2/__init__.py index 7a85567510a..bf91e4b1e27 100644 --- a/esphome/components/seeed_mr60bha2/__init__.py +++ b/esphome/components/seeed_mr60bha2/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] @@ -38,6 +37,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + await uart.new_uart_component(config) diff --git a/esphome/components/seeed_mr60fda2/__init__.py b/esphome/components/seeed_mr60fda2/__init__.py index cc227415831..081581683a2 100644 --- a/esphome/components/seeed_mr60fda2/__init__.py +++ b/esphome/components/seeed_mr60fda2/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] @@ -39,6 +38,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + await uart.new_uart_component(config) diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 2494e9a6d1f..5222d56a106 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -49,9 +49,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 6c3327e556f..35b65f6bae3 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -19,7 +19,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.usb_uart import is_usb_uart_channel import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_NAME, CONF_UART_ID +from esphome.const import CONF_NAME, CONF_UART_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv @@ -94,9 +94,7 @@ async def _add_serial_proxy_count_define() -> None: 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) + var = await uart.new_uart_component(config) cg.add(cg.App.register_serial_proxy(var)) cg.add(var.set_name(config[CONF_NAME])) cg.add(var.set_port_type(config[CONF_PORT_TYPE])) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index efc742610d9..2500e4684bf 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MESSAGE +from esphome.const import CONF_MESSAGE DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] @@ -66,10 +66,8 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/sm300d2/sensor.py b/esphome/components/sm300d2/sensor.py index 4245283ab48..b63db068e63 100644 --- a/esphome/components/sm300d2/sensor.py +++ b/esphome/components/sm300d2/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CO2, CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_PM_2_5, CONF_PM_10_0, CONF_TEMPERATURE, @@ -95,9 +94,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index 35ca231f023..587cb13662b 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -5,7 +5,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.const import CONF_ON_DATA from esphome.types import ConfigType CODEOWNERS = ["@alengwenus"] @@ -50,9 +50,7 @@ _CALLBACK_AUTOMATIONS = ( 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) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/smt100/sensor.py b/esphome/components/smt100/sensor.py index fba10eab9ae..115434ee9ac 100644 --- a/esphome/components/smt100/sensor.py +++ b/esphome/components/smt100/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_COUNTS, CONF_DIELECTRIC_CONSTANT, - CONF_ID, CONF_MOISTURE, CONF_PERMITTIVITY, CONF_TEMPERATURE, @@ -79,10 +78,8 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_COUNTS, var.set_counts_sensor) diff --git a/esphome/components/sun_gtil2/__init__.py b/esphome/components/sun_gtil2/__init__.py index 61643cf4883..b66f7075bea 100644 --- a/esphome/components/sun_gtil2/__init__.py +++ b/esphome/components/sun_gtil2/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] @@ -27,6 +26,4 @@ CONFIG_SCHEMA = ( 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) + await uart.new_uart_component(config) diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py index ae44017112b..ee193c4d3a2 100644 --- a/esphome/components/systa_bus/__init__.py +++ b/esphome/components/systa_bus/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -39,6 +38,4 @@ async def register_systa_bus_listener(systa_bus: MockObj, var: MockObj) -> None: 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) + await uart.new_uart_component(config) diff --git a/esphome/components/t6615/sensor.py b/esphome/components/t6615/sensor.py index 4b055516e79..42223b373ab 100644 --- a/esphome/components/t6615/sensor.py +++ b/esphome/components/t6615/sensor.py @@ -3,7 +3,6 @@ from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( CONF_CO2, - CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, @@ -44,9 +43,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 5a6a01f6695..1353b6a912f 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@0hax", "@FredM67"] @@ -53,6 +52,4 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_HISTORICAL_MODE]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, config[CONF_HISTORICAL_MODE]) diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index ce06cb82e3c..a3ac518d8eb 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -162,9 +162,7 @@ def final_validate(config: ConfigType) -> ConfigType: async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await stepper.register_stepper(var, config) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index c05db51cdf9..51db405512c 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -2,7 +2,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID +from esphome.const import CONF_SENSOR_DATAPOINT, CONF_TIME_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -83,9 +83,7 @@ CONFIG_SCHEMA = cv.All( 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) + var = await uart.new_uart_component(config) if (time_id := config.get(CONF_TIME_ID)) is not None: time_ = await cg.get_variable(time_id) cg.add(var.set_time_id(time_)) diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index c7eba4a26a5..4a69029e6f7 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -116,9 +116,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate 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) + var = await uart.new_uart_component(config) if config[CONF_ROLE] == "server": socket.require_tcp_listener() cg.add(var.set_server(True)) diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index 1846958e591..ced3e3d0af5 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -66,9 +66,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + await uart.new_uart_component(config) if _get_data().clear_action_used: cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index 5dc32259ea2..e23f4157a7e 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID, CONF_TIME_ID +from esphome.const import CONF_ADDRESS, CONF_TIME_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -66,9 +66,7 @@ UPONOR_SMATRIX_DEVICE_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: cg.add_global(uponor_smatrix_ns.using) - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if time_id := config.get(CONF_TIME_ID): time_ = await cg.get_variable(time_id) diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index 533acfbbb92..92709915ad4 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] @@ -40,6 +39,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( 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) + await uart.new_uart_component(config) diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index e451fe79f4d..7ed4ec4b59e 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI +from esphome.const import CONF_POWER_SAVE_MODE, CONF_WIFI import esphome.final_validate as fv from esphome.types import ConfigType @@ -38,7 +38,5 @@ FINAL_VALIDATE_SCHEMA = final_validate 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) + await uart.new_uart_component(config) cg.add_define("USE_ZWAVE_PROXY") From ec642f928f7c1d0b8806316d6c6ecb8072ecc37b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:15:36 -1000 Subject: [PATCH 40/50] [i2c] Add little-endian and signed byte decoding helpers (#20938) --- esphome/components/i2c/i2c.cpp | 5 ++ esphome/components/i2c/i2c.h | 18 ++++ esphome/core/helpers.h | 37 ++++++-- tests/components/core/test_encode_value.cpp | 48 +++++++++++ tests/components/i2c/test_i2c_le.cpp | 96 +++++++++++++++++++++ 5 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 tests/components/core/test_encode_value.cpp create mode 100644 tests/components/i2c/test_i2c_le.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 61382ccf21e..9d079f9f89e 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -94,6 +94,11 @@ bool I2CDevice::write_byte_16(uint8_t a_register, uint16_t data) const { return this->bus_->write_readv(this->address_, buffer, sizeof(buffer), nullptr, 0) == ERROR_OK; } +bool I2CDevice::write_byte_16_le(uint8_t a_register, uint16_t data) const { + const uint8_t buffer[3] = {a_register, static_cast(data), static_cast(data >> 8)}; + return this->bus_->write_readv(this->address_, buffer, sizeof(buffer), nullptr, 0) == ERROR_OK; +} + I2CRegister &I2CRegister::operator=(uint8_t value) { this->parent_->write_register(this->register_, &value, 1); return *this; diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 6a9a6533436..d5690b92063 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -280,6 +280,21 @@ class I2CDevice { bool read_byte_16(uint8_t a_register, uint16_t *data) { return read_bytes_16(a_register, data, 1); } + /// Read len 16-bit values stored least significant byte first; no conversion is compiled on little-endian hosts. + bool read_bytes_16_le(uint8_t a_register, uint16_t *data, uint8_t len) { + if (this->read_register(a_register, reinterpret_cast(data), len * 2) != ERROR_OK) + return false; + for (size_t i = 0; i < len; i++) + data[i] = convert_little_endian(data[i]); + return true; + } + bool read_bytes_16_le(uint8_t a_register, int16_t *data, uint8_t len) { + return this->read_bytes_16_le(a_register, reinterpret_cast(data), len); + } + + /// Read a 16-bit value stored least significant byte first. + bool read_byte_16_le(uint8_t a_register, uint16_t *data) { return this->read_bytes_16_le(a_register, data, 1); } + bool write_bytes(uint8_t a_register, const uint8_t *data, uint8_t len) const { return write_register(a_register, data, len) == ERROR_OK; } @@ -298,6 +313,9 @@ class I2CDevice { bool write_byte_16(uint8_t a_register, uint16_t data) const; + /// Write a 16-bit value least significant byte first. + bool write_byte_16_le(uint8_t a_register, uint16_t data) const; + protected: uint8_t address_{0x00}; ///< store the address of the device on the bus I2CBus *bus_{nullptr}; ///< pointer to I2CBus instance diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 2989655a29a..c3e752c9690 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1015,19 +1015,42 @@ template constexpr int32_t sign_extend(uint32_t value) { } /// Encode a value from its constituent bytes (from most to least significant) in an array with length sizeof(T). -template::value, int> = 0> constexpr T encode_value(const uint8_t *bytes) { - T val = 0; - for (size_t i = 0; i < sizeof(T); i++) { - val <<= 8; - val |= bytes[i]; +/// A signed T takes the two's complement of the assembled bits. +template::value, int> = 0> constexpr T encode_value(const uint8_t *bytes) { + if constexpr (sizeof(T) == 1) { + return static_cast(bytes[0]); + } else { + std::make_unsigned_t val = 0; + for (size_t i = 0; i < sizeof(T); i++) { + val <<= 8; + val |= bytes[i]; + } + return static_cast(val); } - return val; } /// Encode a value from its constituent bytes (from most to least significant) in an std::array with length sizeof(T). -template::value, int> = 0> +template::value, int> = 0> constexpr T encode_value(const std::array bytes) { return encode_value(bytes.data()); } +/// Encode a value from its constituent bytes (from least to most significant) in an array with length sizeof(T). +/// A signed T takes the two's complement of the assembled bits. +template::value, int> = 0> +constexpr T encode_value_le(const uint8_t *bytes) { + // Loops here grow 16-bit callers by up to 48 bytes on ESP8266 through different inlining + if constexpr (sizeof(T) == 1) { + return static_cast(bytes[0]); + } else if constexpr (sizeof(T) == 2) { + return static_cast(encode_uint16(bytes[1], bytes[0])); + } else { + std::make_unsigned_t val = 0; + for (size_t i = sizeof(T); i > 0; i--) { + val <<= 8; + val |= bytes[i - 1]; + } + return static_cast(val); + } +} /// Decode a value into its constituent bytes (from most to least significant). template::value, int> = 0> constexpr std::array decode_value(T val) { diff --git a/tests/components/core/test_encode_value.cpp b/tests/components/core/test_encode_value.cpp new file mode 100644 index 00000000000..c76ae50935a --- /dev/null +++ b/tests/components/core/test_encode_value.cpp @@ -0,0 +1,48 @@ +#include + +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static constexpr uint8_t BYTES[] = {0x12, 0x34, 0x56, 0x78}; + +static_assert(encode_value_le(BYTES) == 0x3412); +static_assert(encode_value(BYTES) == 0x1234); + +TEST(EncodeValue, LittleEndianUnsigned) { + EXPECT_EQ(encode_value_le(BYTES), 0x12); + EXPECT_EQ(encode_value_le(BYTES + 1), 0x5634); + EXPECT_EQ(encode_value_le(BYTES), 0x78563412u); +} + +TEST(EncodeValue, BigEndianSigned) { + static constexpr uint8_t MINUS_ONE[] = {0xFF, 0xFF, 0xFF, 0xFF}; + static constexpr uint8_t MIN16[] = {0x80, 0x00}; + static constexpr uint8_t MIN32[] = {0x80, 0x00, 0x00, 0x00}; + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MIN16), INT16_MIN); + EXPECT_EQ(encode_value(MIN32), INT32_MIN); +} + +TEST(EncodeValue, LittleEndianSigned) { + static constexpr uint8_t MIN16[] = {0x00, 0x80}; + static constexpr uint8_t MAX16[] = {0xFF, 0x7F}; + static constexpr uint8_t MINUS_TWO[] = {0xFE, 0xFF}; + static constexpr uint8_t MINUS_256[] = {0x00, 0xFF, 0xFF, 0xFF}; + EXPECT_EQ(encode_value_le(MIN16), INT16_MIN); + EXPECT_EQ(encode_value_le(MAX16), INT16_MAX); + EXPECT_EQ(encode_value_le(MINUS_TWO), -2); + EXPECT_EQ(encode_value_le(MINUS_256), -256); +} + +TEST(EncodeValue, BoolStillCompiles) { + static constexpr uint8_t ONE[] = {0x01}; + static_assert(encode_value(ONE)); + static_assert(encode_value_le(ONE)); +} + +} // namespace esphome::core::testing diff --git a/tests/components/i2c/test_i2c_le.cpp b/tests/components/i2c/test_i2c_le.cpp new file mode 100644 index 00000000000..973490f28d3 --- /dev/null +++ b/tests/components/i2c/test_i2c_le.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include +#include + +#include "esphome/components/i2c/i2c.h" + +namespace esphome::i2c::testing { + +class MemoryBus : public I2CBus { + public: + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + if (this->fail) + return ERROR_NOT_ACKNOWLEDGED; + if (write_count == 0) + return ERROR_INVALID_ARGUMENT; + uint8_t reg = write_buffer[0]; + for (size_t i = 1; i < write_count; i++) + this->memory[reg + i - 1] = write_buffer[i]; + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = this->memory[reg + i]; + this->last_write_count = write_count; + return ERROR_OK; + } + + std::array memory{}; + size_t last_write_count{0}; + bool fail{false}; +}; + +class I2CLittleEndianTest : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x40); + } + MemoryBus bus_; + I2CDevice device_; +}; + +TEST_F(I2CLittleEndianTest, ReadByte16LittleEndian) { + this->bus_.memory[4] = 0x34; + this->bus_.memory[5] = 0x12; + uint16_t value = 0; + ASSERT_TRUE(this->device_.read_byte_16_le(4, &value)); + EXPECT_EQ(value, 0x1234); + ASSERT_TRUE(this->device_.read_byte_16(4, &value)); + EXPECT_EQ(value, 0x3412); +} + +TEST_F(I2CLittleEndianTest, ReadBytes16LittleEndian) { + const uint8_t raw[6] = {0x34, 0x12, 0xFF, 0xFF, 0x00, 0x80}; + std::copy(raw, raw + 6, this->bus_.memory.begin() + 8); + uint16_t values[3] = {}; + ASSERT_TRUE(this->device_.read_bytes_16_le(8, values, 3)); + EXPECT_EQ(values[0], 0x1234); + EXPECT_EQ(values[1], 0xFFFF); + EXPECT_EQ(values[2], 0x8000); + int16_t signed_values[3] = {}; + ASSERT_TRUE(this->device_.read_bytes_16_le(8, signed_values, 3)); + EXPECT_EQ(signed_values[0], 0x1234); + EXPECT_EQ(signed_values[1], -1); + EXPECT_EQ(signed_values[2], INT16_MIN); +} + +TEST_F(I2CLittleEndianTest, ReadBytes16FailureReturnsFalse) { + this->bus_.fail = true; + int16_t values[2] = {}; + EXPECT_FALSE(this->device_.read_bytes_16_le(0, values, 2)); +} + +TEST_F(I2CLittleEndianTest, ReadFailureReturnsFalse) { + this->bus_.fail = true; + uint16_t value = 0xBEEF; + EXPECT_FALSE(this->device_.read_byte_16_le(0, &value)); + EXPECT_EQ(value, 0xBEEF); +} + +TEST_F(I2CLittleEndianTest, WriteByte16LittleEndian) { + ASSERT_TRUE(this->device_.write_byte_16_le(2, 0xA1B2)); + EXPECT_EQ(this->bus_.last_write_count, 3u); + EXPECT_EQ(this->bus_.memory[2], 0xB2); + EXPECT_EQ(this->bus_.memory[3], 0xA1); + uint16_t value = 0; + ASSERT_TRUE(this->device_.read_byte_16_le(2, &value)); + EXPECT_EQ(value, 0xA1B2); +} + +TEST_F(I2CLittleEndianTest, WriteFailureReturnsFalse) { + this->bus_.fail = true; + EXPECT_FALSE(this->device_.write_byte_16_le(2, 0xA1B2)); +} + +} // namespace esphome::i2c::testing From c1687aba4c441a2783cd36745311d0acb1f2bcf6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:16:21 -1000 Subject: [PATCH 41/50] [components] Let core report communication failures in dump_config (5/5) (#20874) --- esphome/components/shtcx/shtcx.cpp | 6 +--- esphome/components/sps30/sps30.cpp | 31 ++----------------- esphome/components/sps30/sps30.h | 10 ------ .../components/ssd1306_i2c/ssd1306_i2c.cpp | 7 +---- esphome/components/ssd1306_i2c/ssd1306_i2c.h | 2 -- .../components/ssd1327_i2c/ssd1327_i2c.cpp | 7 +---- esphome/components/ssd1327_i2c/ssd1327_i2c.h | 2 -- esphome/components/st7567_i2c/st7567_i2c.cpp | 7 +---- esphome/components/st7567_i2c/st7567_i2c.h | 2 -- esphome/components/stcc4/stcc4.cpp | 12 ++----- esphome/components/sts3x/sts3x.cpp | 7 ++--- esphome/components/stts22h/stts22h.cpp | 3 -- esphome/components/sx126x/sx126x.cpp | 8 ++--- esphome/components/sx127x/sx127x.cpp | 11 ++----- esphome/components/sx1509/sx1509.cpp | 9 ++---- esphome/components/sy6970/sy6970.cpp | 6 +--- esphome/components/tc74/tc74.cpp | 5 +-- esphome/components/tca8418/tca8418.cpp | 15 +++------ esphome/components/tca9555/tca9555.cpp | 7 ++--- esphome/components/tcs34725/tcs34725.cpp | 13 +++----- .../components/tlc59208f/tlc59208f_output.cpp | 19 +++--------- esphome/components/tmc22xx/tmc22xx.cpp | 3 -- esphome/components/tmp102/tmp102.cpp | 5 +-- esphome/components/tmp1075/tmp1075.cpp | 6 ++-- esphome/components/tmp117/tmp117.cpp | 7 ++--- esphome/components/tsl2561/tsl2561.cpp | 8 ++--- esphome/components/tsl2591/tsl2591.cpp | 4 +-- esphome/components/ttp229_lsf/ttp229_lsf.cpp | 11 +------ esphome/components/ttp229_lsf/ttp229_lsf.h | 4 --- esphome/components/veml3235/veml3235.cpp | 11 ++----- esphome/components/veml7700/veml7700.cpp | 7 +---- .../waveshare_io_ch32v003.cpp | 6 +--- esphome/components/xgzp68xx/xgzp68xx.cpp | 7 ++--- esphome/components/xl9535/xl9535.cpp | 6 +--- 34 files changed, 54 insertions(+), 220 deletions(-) diff --git a/esphome/components/shtcx/shtcx.cpp b/esphome/components/shtcx/shtcx.cpp index 68f2c4ea21e..814e09162ec 100644 --- a/esphome/components/shtcx/shtcx.cpp +++ b/esphome/components/shtcx/shtcx.cpp @@ -29,8 +29,7 @@ void SHTCXComponent::setup() { uint16_t device_id_register; if (!this->write_command(SHTCX_COMMAND_READ_ID_REGISTER) || !this->read_data(&device_id_register, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -53,9 +52,6 @@ void SHTCXComponent::dump_config() { " Model: %s (%04x)", LOG_STR_ARG(shtcx_type_to_string(this->type_)), this->sensor_id_); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); diff --git a/esphome/components/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index 3a2ebb14c39..42628e64c91 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -29,15 +29,13 @@ void SPS30Component::setup() { this->set_timeout(500, [this]() { /// Firmware version identification if (!this->get_register(SPS30_CMD_GET_FIRMWARE_VERSION, raw_firmware_version_, 1)) { - this->error_code_ = FIRMWARE_VERSION_READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read firmware version")); return; } /// Serial number identification uint16_t raw_serial_number[8]; if (!this->get_register(SPS30_CMD_GET_SERIAL_NUMBER, raw_serial_number, 8, 1)) { - this->error_code_ = SERIAL_NUMBER_READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read serial number")); return; } @@ -75,31 +73,6 @@ void SPS30Component::setup() { void SPS30Component::dump_config() { ESP_LOGCONFIG(TAG, "SPS30:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement Initialization failed"); - break; - case SERIAL_NUMBER_REQUEST_FAILED: - ESP_LOGW(TAG, "Unable to request serial number"); - break; - case SERIAL_NUMBER_READ_FAILED: - ESP_LOGW(TAG, "Unable to read serial number"); - break; - case FIRMWARE_VERSION_REQUEST_FAILED: - ESP_LOGW(TAG, "Unable to request firmware version"); - break; - case FIRMWARE_VERSION_READ_FAILED: - ESP_LOGW(TAG, "Unable to read firmware version"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Serial number: %s\n" diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index 10b89c844bc..c1e47f089ac 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -42,16 +42,6 @@ class SPS30Component final : public PollingComponent, public sensirion_common::S bool start_continuous_measurement_(); - enum ErrorCode : uint8_t { - COMMUNICATION_FAILED, - FIRMWARE_VERSION_REQUEST_FAILED, - FIRMWARE_VERSION_READ_FAILED, - SERIAL_NUMBER_REQUEST_FAILED, - SERIAL_NUMBER_READ_FAILED, - MEASUREMENT_INIT_FAILED, - UNKNOWN - } error_code_{UNKNOWN}; - sensor::Sensor *pm_1_0_sensor_{nullptr}; sensor::Sensor *pm_2_5_sensor_{nullptr}; sensor::Sensor *pm_4_0_sensor_{nullptr}; diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp index 1cdfed9f502..7081d766e32 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp @@ -10,8 +10,7 @@ void I2CSSD1306::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -32,10 +31,6 @@ void I2CSSD1306::dump_config() { LOG_I2C_DEVICE(this); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CSSD1306::command(uint8_t value) { this->write_byte(0x00, value); } void HOT I2CSSD1306::write_display_data() { diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.h b/esphome/components/ssd1306_i2c/ssd1306_i2c.h index 54c7d862870..e77f4464f5f 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.h +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.h @@ -14,8 +14,6 @@ class I2CSSD1306 final : public ssd1306_base::SSD1306, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::ssd1306_i2c diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp index 98328c800c1..11cf3296a15 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp @@ -10,8 +10,7 @@ void I2CSSD1327::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -23,10 +22,6 @@ void I2CSSD1327::dump_config() { ESP_LOGCONFIG(TAG, " Model: %s", this->model_str_()); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CSSD1327::command(uint8_t value) { this->write_byte(0x00, value); } void HOT I2CSSD1327::write_display_data() { diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.h b/esphome/components/ssd1327_i2c/ssd1327_i2c.h index 75f854d3da3..7c4ae3bec11 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.h +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.h @@ -14,8 +14,6 @@ class I2CSSD1327 final : public ssd1327_base::SSD1327, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::ssd1327_i2c diff --git a/esphome/components/st7567_i2c/st7567_i2c.cpp b/esphome/components/st7567_i2c/st7567_i2c.cpp index 657a60f9549..ad672270a8a 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.cpp +++ b/esphome/components/st7567_i2c/st7567_i2c.cpp @@ -10,8 +10,7 @@ void I2CST7567::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ST7567::setup(); @@ -28,10 +27,6 @@ void I2CST7567::dump_config() { LOG_I2C_DEVICE(this); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CST7567::command(uint8_t value) { this->write_byte(0x00, value); } diff --git a/esphome/components/st7567_i2c/st7567_i2c.h b/esphome/components/st7567_i2c/st7567_i2c.h index eea3068e030..f17ec419785 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.h +++ b/esphome/components/st7567_i2c/st7567_i2c.h @@ -14,8 +14,6 @@ class I2CST7567 final : public st7567_base::ST7567, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::st7567_i2c diff --git a/esphome/components/stcc4/stcc4.cpp b/esphome/components/stcc4/stcc4.cpp index ac7a998e5d6..a5e47584eb0 100644 --- a/esphome/components/stcc4/stcc4.cpp +++ b/esphome/components/stcc4/stcc4.cpp @@ -77,8 +77,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) (uint64_t(raw_product_id[4]) << 16) | raw_product_id[5]; ESP_LOGD(TAG, "Product ID: 0x%08" PRIX32 ", Serial: 0x%016" PRIX64, product_id, serial_number); if (product_id != STCC4_PRODUCT_ID) { - ESP_LOGE(TAG, "Unsupported product ID"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Unsupported product ID")); return; } @@ -106,8 +105,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) // Start continuous measurement if (!this->write_command(STCC4_CMD_START_CONTINUOUS_MEASUREMENT)) { - ESP_LOGE(TAG, "Failed to start continuous measurement"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Failed to start continuous measurement")); return; } this->schedule_continuous_update_(false); @@ -123,8 +121,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) return; } - ESP_LOGE(TAG, "Failed to stop continuous measurements and read product ID"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Failed to stop continuous measurements and read product ID")); } void STCC4Component::finish_setup_() { @@ -143,9 +140,6 @@ void STCC4Component::finish_setup_() { void STCC4Component::dump_config() { ESP_LOGCONFIG(TAG, "STCC4:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " Measurement mode: %s", this->measurement_mode_ == MeasurementMode::CONTINUOUS ? LOG_STR_LITERAL("Continuous (1s)") : LOG_STR_LITERAL("Single shot")); diff --git a/esphome/components/sts3x/sts3x.cpp b/esphome/components/sts3x/sts3x.cpp index 8b97b2f4997..fd9569b951d 100644 --- a/esphome/components/sts3x/sts3x.cpp +++ b/esphome/components/sts3x/sts3x.cpp @@ -18,13 +18,13 @@ static const uint16_t STS3X_COMMAND_FETCH_DATA = 0xE000; void STS3XComponent::setup() { if (!this->write_command(STS3X_COMMAND_READ_SERIAL_NUMBER)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint16_t raw_serial_number[2]; if (!this->read_data(raw_serial_number, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint32_t serial_number = (uint32_t(raw_serial_number[0]) << 16); @@ -33,9 +33,6 @@ void STS3XComponent::setup() { void STS3XComponent::dump_config() { ESP_LOGCONFIG(TAG, "STS3x:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "STS3x", this); diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index 5132b2a3aa5..fa96c6a0227 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -37,9 +37,6 @@ void STTS22HComponent::dump_config() { LOG_SENSOR("", "STTS22H", this); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } float STTS22HComponent::read_temperature_() { diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 0bac0eb0466..6c95fe5f7a6 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -164,7 +164,7 @@ void SX126x::configure() { this->read_register_(REG_VERSION_STRING, (uint8_t *) this->version_, sizeof(this->version_)); this->version_[sizeof(this->version_) - 1] = '\0'; if (strncmp(this->version_, "SX126", 5) != 0 && strncmp(this->version_, "LLCC68", 6) != 0) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unsupported chip version")); return; } @@ -494,8 +494,7 @@ void SX126x::wait_busy_() { uint32_t start = millis(); while (this->busy_pin_->digital_read()) { if (millis() - start > BUSY_TIMEOUT_MS) { - ESP_LOGE(TAG, "Wait busy timeout"); - this->mark_failed(); + this->mark_failed(LOG_STR("Wait busy timeout")); break; } } @@ -562,9 +561,6 @@ void SX126x::dump_config() { if (this->whitening_enable_) { ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->whitening_initial_); } - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring SX126x failed"); - } } } // namespace esphome::sx126x diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index fb816094c29..f48e8de3a35 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -81,7 +81,7 @@ void SX127x::configure() { // check silicon version to make sure hw is ok if (this->read_register_(REG_VERSION) != 0x12) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unsupported chip version")); return; } @@ -367,8 +367,7 @@ void SX127x::run_image_cal() { uint32_t start = millis(); while (this->read_register_(REG_IMAGE_CAL) & IMAGE_CAL_RUNNING) { if (millis() - start > 20) { - ESP_LOGE(TAG, "Image cal failure"); - this->mark_failed(); + this->mark_failed(LOG_STR("Image cal failure")); break; } } @@ -390,8 +389,7 @@ void SX127x::set_mode_(uint8_t modulation, uint8_t mode) { break; } if (millis() - start > 20) { - ESP_LOGE(TAG, "Set mode failure"); - this->mark_failed(); + this->mark_failed(LOG_STR("Set mode failure")); return; } } @@ -508,9 +506,6 @@ void SX127x::dump_config() { this->preamble_polarity_, this->preamble_size_, this->preamble_detect_, this->preamble_errors_); } } - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring SX127x failed"); - } } } // namespace esphome::sx127x diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 169d35331f8..d883982bcd5 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -9,18 +9,18 @@ ESPHOME_LOG_TAG(TAG, "sx1509"); void SX1509Component::setup() { ESP_LOGV(TAG, " Resetting devices"); if (!this->write_byte(REG_RESET, 0x12)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->write_byte(REG_RESET, 0x34); uint16_t data; if (!this->read_byte_16(REG_INTERRUPT_MASK_A, &data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (data != 0xFF00) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unexpected reset value")); return; } clock_(INTERNAL_CLOCK_2MHZ); @@ -31,9 +31,6 @@ void SX1509Component::setup() { void SX1509Component::dump_config() { ESP_LOGCONFIG(TAG, "SX1509:"); - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up SX1509 failed!"); - } LOG_I2C_DEVICE(this); } diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 5e1611ea558..754cfb14ca8 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -60,8 +60,7 @@ void SY6970Component::setup() { // Try to read chip ID uint8_t reg_value; if (!this->read_byte(SY6970_REG_DEVICE_ID, ®_value)) { - ESP_LOGE(TAG, "Failed to communicate with SY6970"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -116,9 +115,6 @@ void SY6970Component::dump_config() { LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Communication with SY6970 failed!"); - } } void SY6970Component::update() { diff --git a/esphome/components/tc74/tc74.cpp b/esphome/components/tc74/tc74.cpp index 98428cd1225..7bba2b7fd75 100644 --- a/esphome/components/tc74/tc74.cpp +++ b/esphome/components/tc74/tc74.cpp @@ -16,7 +16,7 @@ static const uint8_t TC74_DATA_READY_MASK = 0x40; void TC74Component::setup() { uint8_t config_reg; if (this->read_register(TC74_REGISTER_CONFIGURATION, &config_reg, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->data_ready_ = config_reg & TC74_DATA_READY_MASK; @@ -27,9 +27,6 @@ void TC74Component::update() { this->read_temperature_(); } void TC74Component::dump_config() { LOG_SENSOR("", "TC74", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Connection with TC74 failed!"); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp index 7add67e9b09..204bc18c1e5 100644 --- a/esphome/components/tca8418/tca8418.cpp +++ b/esphome/components/tca8418/tca8418.cpp @@ -15,21 +15,18 @@ void TCA8418Component::setup() { // The configuration register reads back, so use it to check the device is there. uint8_t config; if (!this->read_byte(TCA8418_REG_CFG, &config)) { - ESP_LOGE(TAG, "Failed to read from device - check wiring / address"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read from device - check wiring / address")); return; } if (!this->configure_pins_()) { - ESP_LOGE(TAG, "Failed to configure pins"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to configure pins")); return; } // Report key events, and keep the interrupt asserted while events remain queued. if (!this->write_byte(TCA8418_REG_CFG, TCA8418_CFG_KEY_INT_EN | TCA8418_CFG_INT_CFG)) { - ESP_LOGE(TAG, "Failed to write the configuration register"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to write the configuration register")); return; } @@ -43,8 +40,7 @@ void TCA8418Component::setup() { // Discard anything the device queued before it was configured, and clear // every interrupt flag: leaving one set holds the interrupt output asserted. if (!this->drain_events_(false)) { - ESP_LOGE(TAG, "Failed to clear the event queue"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to clear the event queue")); return; } @@ -162,9 +158,6 @@ void TCA8418Component::dump_config() { } LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } } // namespace esphome::tca8418 diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 7a59c403f31..2b234983a20 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -16,11 +16,11 @@ ESPHOME_LOG_TAG(TAG, "tca9555"); void TCA9555Component::setup() { if (!this->read_gpio_modes_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->read_gpio_outputs_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -36,9 +36,6 @@ void TCA9555Component::dump_config() { ESP_LOGCONFIG(TAG, "TCA9555:"); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 8e79ac1c162..979137a9729 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -19,23 +19,23 @@ static const uint8_t TCS34725_REGISTER_CRGBDATAL = TCS34725_COMMAND_BIT | 0x14; void TCS34725Component::setup() { uint8_t id; if (this->read_register(TCS34725_REGISTER_ID, &id, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->write_config_register_(TCS34725_REGISTER_ATIME, this->integration_reg_) != i2c::ERROR_OK || this->write_config_register_(TCS34725_REGISTER_CONTROL, this->gain_reg_) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->write_config_register_(TCS34725_REGISTER_ENABLE, 0x01) != i2c::ERROR_OK) { // Power on (internal oscillator on) - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(3); if (this->write_config_register_(TCS34725_REGISTER_ENABLE, 0x03) != i2c::ERROR_OK) { // Power on (internal oscillator on) + RGBC ADC Enable - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -43,9 +43,6 @@ void TCS34725Component::setup() { void TCS34725Component::dump_config() { ESP_LOGCONFIG(TAG, "TCS34725:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Clear Channel", this->clear_sensor_); @@ -292,7 +289,7 @@ void TCS34725Component::update() { set_gain((TCS34725Gain) gain_reg_val_new); if (this->write_config_register_(TCS34725_REGISTER_ATIME, this->integration_reg_) != i2c::ERROR_OK || this->write_config_register_(TCS34725_REGISTER_CONTROL, this->gain_reg_) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); ESP_LOGW(TAG, "TCS34725I update timing failed!"); } else { this->integration_time_ = integration_time_next; diff --git a/esphome/components/tlc59208f/tlc59208f_output.cpp b/esphome/components/tlc59208f/tlc59208f_output.cpp index d76845b4f03..f73dcbd611b 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.cpp +++ b/esphome/components/tlc59208f/tlc59208f_output.cpp @@ -65,32 +65,27 @@ void TLC59208FOutput::setup() { // Reset all devices on the bus if (this->bus_->write_readv(TLC59208F_SWRST_ADDR >> 1, TLC59208F_SWRST_SEQ, sizeof TLC59208F_SWRST_SEQ, nullptr, 0) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "RESET failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("RESET failed")); return; } // Auto increment registers, and respond to all-call address if (!this->write_byte(TLC59208F_REG_MODE1, TLC59208F_MODE1_AI2 | TLC59208F_MODE1_ALLCALL)) { - ESP_LOGE(TAG, "MODE1 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("MODE1 failed")); return; } if (!this->write_byte(TLC59208F_REG_MODE2, this->mode_)) { - ESP_LOGE(TAG, "MODE2 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("MODE2 failed")); return; } // Set all 3 outputs to be individually controlled // TODO: think of a way to support group dimming if (!this->write_byte(TLC59208F_REG_LEDOUT0, (LDR_PWM << 6) | (LDR_PWM << 4) | (LDR_PWM << 2) | (LDR_PWM << 0))) { - ESP_LOGE(TAG, "LEDOUT0 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("LEDOUT0 failed")); return; } if (!this->write_byte(TLC59208F_REG_LEDOUT1, (LDR_PWM << 6) | (LDR_PWM << 4) | (LDR_PWM << 2) | (LDR_PWM << 0))) { - ESP_LOGE(TAG, "LEDOUT1 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("LEDOUT1 failed")); return; } delayMicroseconds(500); @@ -103,10 +98,6 @@ void TLC59208FOutput::dump_config() { "TLC59208F:\n" " Mode: 0x%02X", this->mode_); - - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up TLC59208F failed!"); - } } void TLC59208FOutput::loop() { diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 7bb756819bd..f68496bd0ca 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -256,9 +256,6 @@ void TMC22XXStepper::dump_config() { LOG_PIN(" INDEX Pin: ", this->index_pin_); LOG_PIN(" DIAG Pin: ", this->diag_pin_); LOG_STEPPER(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void TMC22XXStepper::loop() { diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index 06d94f1ba86..7639c00cf95 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -96,7 +96,7 @@ void TMP102Component::setup_configured_() { #endif if (!this->write_configuration_()) { this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SETUP_FAILED); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } #ifdef USE_TMP102_NUMBER @@ -123,9 +123,6 @@ void TMP102Component::setup_configured_() { void TMP102Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP102:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); #ifdef USE_TMP102_BINARY_SENSOR diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 9b152c1ad34..0cb2456cbd3 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -20,7 +20,7 @@ void TMP1075Sensor::setup() { uint8_t cfg; if (!this->read_byte(REG_CFGR, &cfg)) { ESP_LOGE(TAG, "'%s' - unable to read", this->name_.c_str()); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -42,10 +42,8 @@ void TMP1075Sensor::update() { void TMP1075Sensor::dump_config() { LOG_SENSOR("", "TMP1075 Sensor", this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " limit low : %.4f °C\n" " limit high : %.4f °C\n" diff --git a/esphome/components/tmp117/tmp117.cpp b/esphome/components/tmp117/tmp117.cpp index 5925b297c2b..3c76e098b1b 100644 --- a/esphome/components/tmp117/tmp117.cpp +++ b/esphome/components/tmp117/tmp117.cpp @@ -25,22 +25,19 @@ void TMP117Component::update() { } void TMP117Component::setup() { if (!this->write_config_(this->config_)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } int16_t data; if (!this->read_data_(&data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void TMP117Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP117:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this); } diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index d1d1d66a265..f887b7d32ca 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -18,13 +18,13 @@ static const uint8_t TSL2561_REGISTER_DATA_1 = 0x0E; void TSL2561Sensor::setup() { uint8_t id; if (!this->tsl2561_read_byte(TSL2561_REGISTER_ID, &id)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint8_t timing; if (!this->tsl2561_read_byte(TSL2561_REGISTER_TIMING, &timing)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -40,10 +40,6 @@ void TSL2561Sensor::dump_config() { LOG_SENSOR("", "TSL2561", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } - int gain = this->gain_ == TSL2561_GAIN_1X ? 1 : 16; ESP_LOGCONFIG(TAG, " Gain: %dx\n" diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index 493f4ad3627..46b0f4c8054 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -73,10 +73,8 @@ void TSL2591Component::dump_config() { ESP_LOGCONFIG(TAG, "TSL2591:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Name: %s", this->name_); TSL2591ComponentGain raw_gain = this->component_gain_; diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.cpp b/esphome/components/ttp229_lsf/ttp229_lsf.cpp index 7ed06c6d6f2..bf5c3f67284 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.cpp +++ b/esphome/components/ttp229_lsf/ttp229_lsf.cpp @@ -8,22 +8,13 @@ ESPHOME_LOG_TAG(TAG, "ttp229_lsf"); void TTP229LSFComponent::setup() { uint8_t data[2]; if (this->read(data, 2) != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void TTP229LSFComponent::dump_config() { ESP_LOGCONFIG(TAG, "ttp229:"); LOG_I2C_DEVICE(this); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case NONE: - default: - break; - } } void TTP229LSFComponent::loop() { uint16_t touched = 0; diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.h b/esphome/components/ttp229_lsf/ttp229_lsf.h index 50e2baa7f7a..e221ded0855 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.h +++ b/esphome/components/ttp229_lsf/ttp229_lsf.h @@ -26,10 +26,6 @@ class TTP229LSFComponent final : public Component, public i2c::I2CDevice { protected: std::vector channels_{}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - } error_code_{NONE}; }; } // namespace esphome::ttp229_lsf diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 3a856696782..09340f3bdee 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -21,16 +21,14 @@ static const uint8_t MAX_ADJUSTMENTS_PER_UPDATE = 2; void VEML3235Sensor::setup() { uint8_t device_id[] = {0, 0}; if (!this->refresh_config_reg()) { - ESP_LOGE(TAG, "Unable to write configuration"); - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to write configuration")); return; } if ((this->read_register(ID_REG, device_id, sizeof device_id) != i2c::ERROR_OK)) { - ESP_LOGE(TAG, "Unable to read ID"); - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read ID")); } else if (device_id[0] != DEVICE_ID) { ESP_LOGE(TAG, "Incorrect device ID - expected 0x%.2x, read 0x%.2x", DEVICE_ID, device_id[0]); - this->mark_failed(); + this->mark_failed(LOG_STR("Incorrect device ID")); } } @@ -187,9 +185,6 @@ void VEML3235Sensor::dump_config() { LOG_SENSOR("", "VEML3235", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Auto-gain enabled: %s", YESNO(this->auto_gain_)); if (this->auto_gain_) { diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 236b4bb2a98..210e68705bb 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -80,8 +80,7 @@ static const char *get_gain_str(Gain gain) { void VEML7700Component::setup() { auto err = this->configure_(); if (err != i2c::ERROR_OK) { - ESP_LOGW(TAG, "Sensor configuration failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Sensor configuration failed")); } else { this->state_ = State::INITIAL_SETUP_COMPLETED; } @@ -109,10 +108,6 @@ void VEML7700Component::dump_config() { LOG_SENSOR(" ", "FAKE_IR channel lux", this->fake_infrared_sensor_); LOG_SENSOR(" ", "Actual gain", this->actual_gain_sensor_); LOG_SENSOR(" ", "Actual integration time", this->actual_integration_time_sensor_); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void VEML7700Component::update() { diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 159390086f5..0347cfa083c 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -20,8 +20,7 @@ void WaveshareIOCH32V003Component::setup() { bool step2 = this->write_gpio_outputs_(); if (!step1 || !step2) { - ESP_LOGE(TAG, "Failed to initialize Waveshare IO expander"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to initialize Waveshare IO expander")); return; } @@ -46,9 +45,6 @@ void WaveshareIOCH32V003Component::loop() { this->reset_pin_cache_(); } void WaveshareIOCH32V003Component::dump_config() { ESP_LOGCONFIG(TAG, "WaveshareIO:"); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } uint16_t WaveshareIOCH32V003Component::get_adc_value() { diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index b184a855f6f..8ab42d24fe0 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -93,11 +93,11 @@ void XGZP68XXComponent::setup() { // Display some sample bits to confirm we are talking to the sensor if (i2c::ErrorCode::ERROR_OK != this->read_register(SYSCONFIG_ADDRESS, &config1, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (i2c::ErrorCode::ERROR_OK != this->read_register(PCONFIG_ADDRESS, &config2, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ESP_LOGD(TAG, "sys_config 0x%x, p_config 0x%x", config1, config2); @@ -111,9 +111,6 @@ void XGZP68XXComponent::dump_config() { ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->pressure_oversampling_)); } LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, " Connection failed"); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index 228274b3211..9be536353c8 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -9,7 +9,7 @@ void XL9535Component::setup() { // Check to see if the device can read from the register uint8_t port = 0; if (this->read_register(XL9535_INPUT_PORT_0_REGISTER, &port, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -17,10 +17,6 @@ void XL9535Component::setup() { void XL9535Component::dump_config() { ESP_LOGCONFIG(TAG, "XL9535:"); LOG_I2C_DEVICE(this); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool XL9535Component::digital_read(uint8_t pin) { From 74bcec34ad22e136bd9169050f2e47e148f9f5f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:18:36 -1000 Subject: [PATCH 42/50] [i2c] Add I2CDevice::update_register for register read-modify-write (#20854) --- esphome/components/i2c/i2c.cpp | 9 ++ esphome/components/i2c/i2c.h | 5 ++ .../i2c/test_i2c_update_register.cpp | 82 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 tests/components/i2c/test_i2c_update_register.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 9d079f9f89e..8aa1fbe3cf3 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -75,6 +75,15 @@ bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { return true; } +bool I2CDevice::update_register(uint8_t a_register, uint8_t mask, uint8_t value) { + uint8_t data; + if (!this->read_byte(a_register, &data)) + return false; + const uint8_t updated = (data & ~mask) | (value & mask); + ESP_LOGV(TAG, "Register 0x%02X: 0x%02X -> 0x%02X", a_register, data, updated); + return this->write_byte(a_register, updated); +} + bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) const { const size_t size = 1 + len * 2; SmallBufferWithHeapFallback<17> buffer_alloc(size); // Register plus up to 8 words diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index d5690b92063..1c3137c6d5f 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -316,6 +316,11 @@ class I2CDevice { /// Write a 16-bit value least significant byte first. bool write_byte_16_le(uint8_t a_register, uint16_t data) const; + /// Read a register, replace the bits in @p mask with those of @p value and write it back. + /// The write always happens; bits of @p value outside @p mask are ignored. Logs both values at VERBOSE. + /// @return false if the read or the write failed (nothing is written after a failed read) + bool update_register(uint8_t a_register, uint8_t mask, uint8_t value); + protected: uint8_t address_{0x00}; ///< store the address of the device on the bus I2CBus *bus_{nullptr}; ///< pointer to I2CBus instance diff --git a/tests/components/i2c/test_i2c_update_register.cpp b/tests/components/i2c/test_i2c_update_register.cpp new file mode 100644 index 00000000000..f973a726cad --- /dev/null +++ b/tests/components/i2c/test_i2c_update_register.cpp @@ -0,0 +1,82 @@ +#include + +#include +#include + +#include "esphome/components/i2c/i2c.h" + +namespace esphome::i2c::testing { + +static constexpr uint8_t ADDRESS = 0x40; +static constexpr uint8_t REG = 0x12; + +class RegisterBus final : public I2CBus { + public: + std::array regs{}; + std::vector writes; + bool fail_read{false}; + bool fail_write{false}; + + ErrorCode write_readv(uint8_t address, const uint8_t *out, size_t out_size, uint8_t *in, size_t in_size) override { + EXPECT_EQ(address, ADDRESS); + if (out_size == 0) + return ERROR_TIMEOUT; + const uint8_t reg = out[0]; + if (in_size > 0) { + if (this->fail_read) + return ERROR_TIMEOUT; + for (size_t i = 0; i < in_size; i++) + in[i] = this->regs[reg + i]; + return ERROR_OK; + } + if (this->fail_write) + return ERROR_TIMEOUT; + this->writes.push_back(reg); + for (size_t i = 1; i < out_size; i++) + this->regs[reg + i - 1] = out[i]; + return ERROR_OK; + } +}; + +class UpdateRegisterTest : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(ADDRESS); + } + + RegisterBus bus_; + I2CDevice device_; +}; + +TEST_F(UpdateRegisterTest, ReplacesOnlyMaskedBits) { + this->bus_.regs[REG] = 0b1010'0101; + EXPECT_TRUE(this->device_.update_register(REG, 0b0000'1111, 0b0000'0011)); + EXPECT_EQ(this->bus_.regs[REG], 0b1010'0011); +} + +TEST_F(UpdateRegisterTest, IgnoresValueBitsOutsideMask) { + this->bus_.regs[REG] = 0x00; + EXPECT_TRUE(this->device_.update_register(REG, 0x0F, 0xFF)); + EXPECT_EQ(this->bus_.regs[REG], 0x0F); +} + +TEST_F(UpdateRegisterTest, WritesEvenWhenUnchanged) { + this->bus_.regs[REG] = 0x3C; + EXPECT_TRUE(this->device_.update_register(REG, 0x0C, 0x0C)); + EXPECT_EQ(this->bus_.regs[REG], 0x3C); + EXPECT_EQ(this->bus_.writes, std::vector{REG}); +} + +TEST_F(UpdateRegisterTest, FailedReadWritesNothing) { + this->bus_.fail_read = true; + EXPECT_FALSE(this->device_.update_register(REG, 0xFF, 0x01)); + EXPECT_TRUE(this->bus_.writes.empty()); +} + +TEST_F(UpdateRegisterTest, FailedWriteReturnsFalse) { + this->bus_.fail_write = true; + EXPECT_FALSE(this->device_.update_register(REG, 0xFF, 0x01)); +} + +} // namespace esphome::i2c::testing From 32dc3b85351dd5ffc90fac187f9e70c040bf2deb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:19:24 -1000 Subject: [PATCH 43/50] [http_request] Test ota.http_request.flash with every field (#20829) --- tests/components/http_request/common.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/components/http_request/common.yaml b/tests/components/http_request/common.yaml index 62d0a7941aa..f20bd1a23f0 100644 --- a/tests/components/http_request/common.yaml +++ b/tests/components/http_request/common.yaml @@ -42,6 +42,20 @@ button: md5: 0123456789abcdef0123456789abcdef url: http://my.ha.net:8123/local/esphome/firmware.bin + - ota.http_request.flash: + id: http_request_ota + md5_url: !lambda 'return std::string("http://my.ha.net:8123/local/esphome/firmware.md5");' + username: user + password: !lambda 'return std::string("pass");' + url: http://my.ha.net:8123/local/esphome/firmware.bin + + - ota.http_request.flash: + id: http_request_ota + md5: !lambda 'return std::string("0123456789abcdef0123456789abcdef");' + username: !lambda 'return std::string("user");' + password: pass + url: !lambda 'return std::string("http://my.ha.net:8123/local/esphome/firmware.bin");' + - logger.log: "This message should be not displayed (reboot)" update: From 68fa7b9847075aab6ecf58008a047d574093c53b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:20:00 -1000 Subject: [PATCH 44/50] [wifi] Test the wifi.connected and wifi.enabled conditions (#20841) --- tests/components/wifi/common.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/wifi/common.yaml b/tests/components/wifi/common.yaml index 10a8a61c661..63bd820e673 100644 --- a/tests/components/wifi/common.yaml +++ b/tests/components/wifi/common.yaml @@ -14,6 +14,14 @@ esphome: condition: wifi.ap_active then: - logger.log: "WiFi AP is active!" + - if: + condition: wifi.connected + then: + - logger.log: "WiFi is connected!" + - if: + condition: wifi.enabled + then: + - logger.log: "WiFi is enabled!" - wifi.roam wifi: From 626d84bc641007476d8eec710904e6aabb13cae2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:20:52 -1000 Subject: [PATCH 45/50] [sensor][number] Test in_range with every bound combination (#20847) --- tests/components/template/common-base.yaml | 32 ++++ .../fixtures/in_range_conditions.yaml | 158 ++++++++++++++++++ tests/integration/test_in_range_conditions.py | 62 +++++++ 3 files changed, 252 insertions(+) create mode 100644 tests/integration/fixtures/in_range_conditions.yaml create mode 100644 tests/integration/test_in_range_conditions.py diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index f87a4e0320e..76a5e0b1a2f 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -76,6 +76,38 @@ esphome: date: "2021-01-01" binary_sensor: + - platform: template + id: template_sensor_above + condition: + sensor.in_range: + id: template_template_sens + above: 10.0 + - platform: template + id: template_sensor_between + condition: + sensor.in_range: + id: template_template_sens + above: 10.0 + below: 30.0 + - platform: template + id: template_number_above + condition: + number.in_range: + id: template_number + above: 10.0 + - platform: template + id: template_number_below + condition: + number.in_range: + id: template_number + below: 90.0 + - platform: template + id: template_number_between + condition: + number.in_range: + id: template_number + above: 10.0 + below: 90.0 - platform: template id: template_some_binary_sensor name: "Garage Door Open" diff --git a/tests/integration/fixtures/in_range_conditions.yaml b/tests/integration/fixtures/in_range_conditions.yaml new file mode 100644 index 00000000000..f7f636aa584 --- /dev/null +++ b/tests/integration/fixtures/in_range_conditions.yaml @@ -0,0 +1,158 @@ +esphome: + name: test-in-range-conditions + +host: + +api: + actions: + - action: run_cases + then: + - script.execute: + id: check + label: neg + value: !lambda "return -10.0f;" + - script.wait: check + - script.execute: + id: check + label: low + value: !lambda "return 0.0f;" + - script.wait: check + - script.execute: + id: check + label: mid + value: !lambda "return 5.0f;" + - script.wait: check + - script.execute: + id: check + label: high + value: !lambda "return 10.0f;" + - script.wait: check + - script.execute: + id: check + label: over + value: !lambda "return 20.0f;" + - script.wait: check + - script.execute: + id: check + label: nan + value: !lambda "return NAN;" + - script.wait: check + - script.execute: + id: check + label: neginf + value: !lambda "return -INFINITY;" + - script.wait: check + - script.execute: + id: check + label: posinf + value: !lambda "return INFINITY;" + - script.wait: check + - logger.log: "INRANGE done" + +logger: + level: DEBUG + +sensor: + - platform: template + id: range_sensor + name: Range Sensor + +number: + - platform: template + id: range_number + name: Range Number + optimistic: true + min_value: -100 + max_value: 100 + step: 0.5 + +script: + - id: check + parameters: + label: string + value: float + then: + # Set the states directly so NaN and infinities reach the conditions unchanged + - lambda: |- + id(range_sensor).publish_state(value); + id(range_number).publish_state(value); + - if: + condition: + sensor.in_range: + id: range_sensor + above: 0 + then: + - logger.log: + format: "INRANGE %s sensor_above 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_above 0" + args: [label.c_str()] + - if: + condition: + sensor.in_range: + id: range_sensor + below: 10 + then: + - logger.log: + format: "INRANGE %s sensor_below 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_below 0" + args: [label.c_str()] + - if: + condition: + sensor.in_range: + id: range_sensor + above: 0 + below: 10 + then: + - logger.log: + format: "INRANGE %s sensor_both 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_both 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + above: 0 + then: + - logger.log: + format: "INRANGE %s number_above 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_above 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + below: 10 + then: + - logger.log: + format: "INRANGE %s number_below 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_below 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + above: 0 + below: 10 + then: + - logger.log: + format: "INRANGE %s number_both 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_both 0" + args: [label.c_str()] diff --git a/tests/integration/test_in_range_conditions.py b/tests/integration/test_in_range_conditions.py new file mode 100644 index 00000000000..667f7fe43d4 --- /dev/null +++ b/tests/integration/test_in_range_conditions.py @@ -0,0 +1,62 @@ +"""Test sensor.in_range and number.in_range against a fixed truth table.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Bounds in the fixture: above 0, below 10, both inclusive. A NaN state is never in range. +# Columns: above only, below only, both bounds. +EXPECTED = { + "neg": (0, 1, 0), # -10 + "low": (1, 1, 1), # 0, the lower bound + "mid": (1, 1, 1), # 5 + "high": (1, 1, 1), # 10, the upper bound + "over": (1, 0, 0), # 20 + "nan": (0, 0, 0), + "neginf": (0, 1, 0), + "posinf": (1, 0, 0), +} +CONDITIONS = ("above", "below", "both") +RESULT_RE = re.compile(r"INRANGE (\w+) (sensor|number)_(above|below|both) ([01])") + + +@pytest.mark.asyncio +async def test_in_range_conditions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Each in_range condition gives the same result for sensors and numbers.""" + loop = asyncio.get_running_loop() + results: dict[tuple[str, str, str], int] = {} + done = loop.create_future() + + def check_output(line: str) -> None: + if match := RESULT_RE.search(line): + label, entity, condition, value = match.groups() + results[(label, entity, condition)] = int(value) + elif "INRANGE done" in line and not done.done(): + done.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service = next((s for s in services if s.name == "run_cases"), None) + assert service is not None, "run_cases action not found" + await client.execute_service(service, {}) + await asyncio.wait_for(done, timeout=10.0) + + expected = { + (label, entity, condition): row[index] + for label, row in EXPECTED.items() + for entity in ("sensor", "number") + for index, condition in enumerate(CONDITIONS) + } + assert results == expected From 21362f8c92ab8e8d5002569ef508e0a98c9afc6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:21:18 -1000 Subject: [PATCH 46/50] [ethernet] Test the ethernet.connected condition (#20844) --- tests/components/ethernet/test-w5500.esp32-idf.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/components/ethernet/test-w5500.esp32-idf.yaml b/tests/components/ethernet/test-w5500.esp32-idf.yaml index c99dfaf5281..ebd7aa360ab 100644 --- a/tests/components/ethernet/test-w5500.esp32-idf.yaml +++ b/tests/components/ethernet/test-w5500.esp32-idf.yaml @@ -38,6 +38,11 @@ button: - platform: template name: "Disable Ethernet" on_press: + - if: + condition: + ethernet.connected: + then: + - logger.log: "Disabling connected Ethernet" - ethernet.disable: binary_sensor: From 34e6d3ec9c75a21efb5ba73ea0587f9aeca678e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:21:36 -1000 Subject: [PATCH 47/50] [pylontech] Restore the CONF_ID import (#21013) --- esphome/components/pylontech/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 3fc194da057..ed18556002f 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -6,6 +6,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv +from esphome.const import CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType From a2754f655c1f5d297cd163ed27798e38bcd391e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:06 -1000 Subject: [PATCH 48/50] [dfrobot_sen0609][http_request] Use ESPHOME_strstr_P (#20825) --- .../dfrobot_sen0609/dfrobot_sen0609.cpp | 18 +++++------------- .../http_request/http_request_arduino.cpp | 3 ++- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp index e1a1dbe89d0..802142bb8de 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -64,15 +64,6 @@ static bool starts_with(const char *str, ESPHOME_PGM_P prefix) { return ESPHOME_strncmp_P(str, prefix, ESPHOME_strlen_P(prefix)) == 0; } -static bool contains(const char *str, ESPHOME_PGM_P needle) { - const size_t len = ESPHOME_strlen_P(needle); - for (; *str != '\0'; str++) { - if (ESPHOME_strncmp_P(str, needle, len) == 0) - return true; - } - return false; -} - // Splits "a,b,c*" in place; returns the number of fields stored in `fields` static uint8_t split_fields(char *str, char **fields, uint8_t max_fields) { uint8_t count = 0; @@ -94,9 +85,9 @@ static uint8_t split_fields(char *str, char **fields, uint8_t max_fields) { static bool answer_means_done(Operation operation, const char *line) { switch (operation) { case Operation::OPERATION_STOP: - return contains(line, ESPHOME_PSTR("stopped already")); + return ESPHOME_strstr_P(line, ESPHOME_PSTR("stopped already")) != nullptr; case Operation::OPERATION_START: - return contains(line, ESPHOME_PSTR("started already")); + return ESPHOME_strstr_P(line, ESPHOME_PSTR("started already")) != nullptr; case Operation::OPERATION_SAVE: return starts_with(line, ESPHOME_PSTR("no parameter")); default: @@ -261,11 +252,12 @@ void DFRobotSEN0609Component::handle_line_(char *line) { this->finish_operation_(); } else if (starts_with(line, ESPHOME_PSTR("Response "))) { this->parse_response_(line + 9); - } else if (contains(line, ESPHOME_PSTR("not stopped"))) { + } else if (ESPHOME_strstr_P(line, ESPHOME_PSTR("not stopped")) != nullptr) { // Stop the sensor before the next attempt this->stopped_ = false; this->fail_operation_(); - } else if (starts_with(line, ESPHOME_PSTR("Error")) || contains(line, ESPHOME_PSTR("not recognized"))) { + } else if (starts_with(line, ESPHOME_PSTR("Error")) || + ESPHOME_strstr_P(line, ESPHOME_PSTR("not recognized")) != nullptr) { ESP_LOGW(TAG, "Command '%s' rejected: %s", this->command_, line); this->fail_operation_(); } else { diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 2062122adf6..814d598132e 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -10,6 +10,7 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" // Include BearSSL error constants for TLS failure diagnostics #ifdef USE_ESP8266 @@ -39,7 +40,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons const uint32_t start = millis(); - bool secure = strstr(url, "https:") != nullptr; + bool secure = ESPHOME_strstr_P(url, ESPHOME_PSTR("https:")) != nullptr; container->set_secure(secure); container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms)); From af5efabdc6313eb5a841c54796afc351b7426977 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:22 -1000 Subject: [PATCH 49/50] [light] Test light.toggle with a transition length (#20832) --- tests/components/light/common.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index c1e58c673f6..95b587df45d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -83,6 +83,12 @@ esphome: call.perform(); - light.toggle: test_binary_light + - light.toggle: + id: test_monochromatic_light + transition_length: 500ms + - light.toggle: + id: test_monochromatic_light + transition_length: !lambda "return 250;" - light.turn_off: test_rgb_light - if: condition: From 5df6c65cc249adef35af836f089eb7a5a91f4fa8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:35 -1000 Subject: [PATCH 50/50] [api] Test api.connected without options and with a lambda (#20835) --- tests/components/api/common-base.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 7f6cb83f16c..e64ba88f112 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -5,6 +5,17 @@ esphome: condition: api.connected: state_subscription_only: true + - if: + condition: + api.connected: + then: + - logger.log: "API client connected" + - if: + condition: + api.connected: + state_subscription_only: !lambda "return true;" + then: + - logger.log: "API client subscribed to states" - homeassistant.event: event: esphome.button_pressed data: