From e7730cff0024a9d48680ef6f2a2771f7461b6705 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 13:59:40 -1000 Subject: [PATCH 01/11] [esp32_ble] Optimize BLE event hot path performance (#14627) --- esphome/components/esp32_ble/ble_event.h | 18 ++++++------------ esphome/core/lock_free_queue.h | 10 +++++++++- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index 299fd7705f..ba87fd8805 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -155,44 +155,38 @@ class BLEEvent { void release() { switch (this->type_) { case GAP: - // GAP events don't have heap allocations + // GAP events never have heap allocations break; case GATTC: - // Param is now stored inline, only delete heap data if it was heap-allocated if (!this->event_.gattc.is_inline && this->event_.gattc.data.heap_data != nullptr) { delete[] this->event_.gattc.data.heap_data; + this->event_.gattc.data.heap_data = nullptr; } - // Clear critical fields to prevent issues if type changes - this->event_.gattc.is_inline = false; - this->event_.gattc.data.heap_data = nullptr; break; case GATTS: - // Param is now stored inline, only delete heap data if it was heap-allocated if (!this->event_.gatts.is_inline && this->event_.gatts.data.heap_data != nullptr) { delete[] this->event_.gatts.data.heap_data; + this->event_.gatts.data.heap_data = nullptr; } - // Clear critical fields to prevent issues if type changes - this->event_.gatts.is_inline = false; - this->event_.gatts.data.heap_data = nullptr; break; } } // Load new event data for reuse (replaces previous event data) + // Note: release() is NOT called here because EventPool::release() already + // calls event->release() before returning to the free list. Every event + // from allocate() is already in a clean state. void load_gap_event(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { - this->release(); this->type_ = GAP; this->init_gap_data_(e, p); } void load_gattc_event(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) { - this->release(); this->type_ = GATTC; this->init_gattc_data_(e, i, p); } void load_gatts_event(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) { - this->release(); this->type_ = GATTS; this->init_gatts_data_(e, i, p); } diff --git a/esphome/core/lock_free_queue.h b/esphome/core/lock_free_queue.h index 522fbd36e1..316186ea54 100644 --- a/esphome/core/lock_free_queue.h +++ b/esphome/core/lock_free_queue.h @@ -104,7 +104,15 @@ template class LockFreeQueue { } } - uint16_t get_and_reset_dropped_count() { return dropped_count_.exchange(0, std::memory_order_relaxed); } + uint16_t get_and_reset_dropped_count() { + // Fast path: relaxed load is a single instruction on all platforms. + // The atomic exchange (especially for uint16_t on Xtensa) compiles to + // an expensive sub-word CAS retry loop (~25 instructions + memory barriers). + // Since drops are rare, avoid the exchange in the common case. + if (dropped_count_.load(std::memory_order_relaxed) == 0) + return 0; + return dropped_count_.exchange(0, std::memory_order_relaxed); + } void increment_dropped_count() { dropped_count_.fetch_add(1, std::memory_order_relaxed); } From 76c567a71cec76ca820ee0b23107b4258276b72e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 14:00:04 -1000 Subject: [PATCH 02/11] [scheduler] Use std::atomic instead of std::atomic for remove flag (#14626) --- esphome/core/scheduler.h | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index cefbdd1b22..eb6cea4f37 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -178,9 +178,11 @@ class Scheduler { uint16_t next_execution_high_; // Upper 16 bits (millis_major counter) #ifdef ESPHOME_THREAD_MULTI_ATOMICS - // Multi-threaded with atomics: use atomic for lock-free access - // Place atomic separately since it can't be packed with bit fields - std::atomic remove{false}; + // Multi-threaded with atomics: use atomic uint8_t for lock-free access. + // std::atomic is not used because GCC on Xtensa generates an indirect + // function call for std::atomic::load() instead of inlining it. + // std::atomic inlines correctly on all platforms. + std::atomic remove{0}; // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; @@ -204,7 +206,7 @@ class Scheduler { next_execution_low_(0), next_execution_high_(0), #ifdef ESPHOME_THREAD_MULTI_ATOMICS - // remove is initialized in the member declaration as std::atomic{false} + // remove is initialized in the member declaration type(TIMEOUT), name_type_(NameType::STATIC_STRING), is_retry(false) { @@ -508,7 +510,7 @@ class Scheduler { // Multi-threaded with atomics: use atomic store with appropriate ordering // Release ordering when setting to true ensures cancellation is visible to other threads // Relaxed ordering when setting to false is sufficient for initialization - item->remove.store(removed, removed ? std::memory_order_release : std::memory_order_relaxed); + item->remove.store(removed ? 1 : 0, removed ? std::memory_order_release : std::memory_order_relaxed); #else // Single-threaded (ESPHOME_THREAD_SINGLE) or // multi-threaded without atomics (ESPHOME_THREAD_MULTI_NO_ATOMICS): direct write From 771404668d04418b377c660398f6ae1812d8bdd5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 14:01:01 -1000 Subject: [PATCH 03/11] [api] Inline fast path of try_to_clear_buffer (#14630) --- esphome/components/api/api_connection.cpp | 6 +----- esphome/components/api/api_connection.h | 10 +++++++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 43f5070a40..28a770a4fb 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1945,11 +1945,7 @@ void APIConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSet #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_subscribe_home_assistant_states_request() { state_subs_at_ = 0; } #endif -bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { - if (this->flags_.remove) - return false; - if (this->helper_->can_write_without_blocking()) - return true; +bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { delay(0); APIError err = this->helper_->loop(); if (err != APIError::OK) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 5d1469e419..ccb51186d6 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -305,7 +305,13 @@ class APIConnection final : public APIServerConnectionBase { this->prepare_first_message_buffer(shared_buf, header_padding, payload_size + header_padding + footer_size); } - bool try_to_clear_buffer(bool log_out_of_space); + bool try_to_clear_buffer(bool log_out_of_space) { + if (this->flags_.remove) + return false; + if (this->helper_->can_write_without_blocking()) + return true; + return this->try_to_clear_buffer_slow_(log_out_of_space); + } bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override; const char *get_name() const { return this->helper_->get_client_name(); } @@ -315,6 +321,8 @@ class APIConnection final : public APIServerConnectionBase { } protected: + bool try_to_clear_buffer_slow_(bool log_out_of_space); + // Helper function to handle authentication completion void complete_authentication_(); From 66a5ad0d75cf90ebc22ca85d139a698090be30ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 14:06:55 -1000 Subject: [PATCH 04/11] [core] Skip zero-initialization of StaticVector data array (#14592) --- esphome/core/helpers.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 6ce5de4975..11e0afe526 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -215,7 +215,7 @@ template class StaticVector { using const_reverse_iterator = std::reverse_iterator; private: - std::array data_{}; + std::array data_; // intentionally not value-initialized to avoid memset size_t count_{0}; public: From 93d7ec4d72dc77df92b01b3ca84cb29264e46ae5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 14:07:59 -1000 Subject: [PATCH 05/11] [esp32_ble] Inline ble_addr_to_uint64 to eliminate call overhead (#14591) --- esphome/components/esp32_ble/ble.cpp | 11 ----------- esphome/components/esp32_ble/ble.h | 11 ++++++++++- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 7fa5370072..ff9d9bb15a 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -727,17 +727,6 @@ void ESP32BLE::dump_config() { } } -uint64_t ble_addr_to_uint64(const esp_bd_addr_t address) { - uint64_t u = 0; - u |= uint64_t(address[0] & 0xFF) << 40; - u |= uint64_t(address[1] & 0xFF) << 32; - u |= uint64_t(address[2] & 0xFF) << 24; - u |= uint64_t(address[3] & 0xFF) << 16; - u |= uint64_t(address[4] & 0xFF) << 8; - u |= uint64_t(address[5] & 0xFF) << 0; - return u; -} - ESP32BLE *global_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::esp32_ble diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 2ce17e97be..04bec3f785 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -35,7 +35,16 @@ static constexpr uint8_t MAX_BLE_QUEUE_SIZE = 100; // 64 + 36 (ring buffer size static constexpr uint8_t MAX_BLE_QUEUE_SIZE = 88; // 64 + 24 (ring buffer size without PSRAM) #endif -uint64_t ble_addr_to_uint64(const esp_bd_addr_t address); +inline uint64_t ble_addr_to_uint64(const esp_bd_addr_t address) { + uint64_t u = 0; + u |= uint64_t(address[0] & 0xFF) << 40; + u |= uint64_t(address[1] & 0xFF) << 32; + u |= uint64_t(address[2] & 0xFF) << 24; + u |= uint64_t(address[3] & 0xFF) << 16; + u |= uint64_t(address[4] & 0xFF) << 8; + u |= uint64_t(address[5] & 0xFF) << 0; + return u; +} // NOLINTNEXTLINE(modernize-use-using) typedef struct { From 88536ff72bce462b8290d48ca7bc2f3f40a5e9b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 14:31:42 -1000 Subject: [PATCH 06/11] [modbus] Fix timeout for non-hardware UARTs (e.g., USB UART) (#14614) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/modbus/modbus.cpp | 18 ++++- esphome/components/uart/uart_component.h | 6 +- .../external_components/uart_mock/__init__.py | 8 +- .../uart_mock/automation.h | 16 +++- .../uart_mock/uart_mock.cpp | 20 +++++ .../external_components/uart_mock/uart_mock.h | 10 +++ .../uart_mock_modbus_no_threshold.yaml | 64 +++++++++++++++ tests/integration/test_uart_mock_modbus.py | 79 +++++++++++++++++++ 8 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 28e26e307e..82672217c5 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -11,6 +11,11 @@ static const char *const TAG = "modbus"; // Maximum bytes to log for Modbus frames (truncated if larger) static constexpr size_t MODBUS_MAX_LOG_BYTES = 64; +// Approximate bits per character on the wire (depends on parity/stop bit config) +static constexpr uint32_t MODBUS_BITS_PER_CHAR = 11; +// Milliseconds per second +static constexpr uint32_t MS_PER_SEC = 1000; + void Modbus::setup() { if (this->flow_control_pin_ != nullptr) { this->flow_control_pin_->setup(); @@ -19,10 +24,17 @@ void Modbus::setup() { this->frame_delay_ms_ = std::max(2, // 1750us minimum per spec - rounded up to 2ms. // 3.5 characters * 11 bits per character * 1000ms/sec / (bits/sec) (Standard modbus frame delay) - (uint16_t) (3.5 * 11 * 1000 / this->parent_->get_baud_rate()) + 1); + (uint16_t) (3.5 * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate()) + 1); + // When rx_full_threshold is configured (non-zero), the UART has a hardware FIFO with a + // meaningful threshold (e.g., ESP32 native UART), so we can calculate a precise delay. + // Otherwise (e.g., USB UART), use 50ms to handle data arriving in chunks. + static constexpr uint16_t DEFAULT_LONG_RX_BUFFER_DELAY_MS = 50; + size_t rx_threshold = this->parent_->get_rx_full_threshold(); this->long_rx_buffer_delay_ms_ = - (this->parent_->get_rx_full_threshold() * 11 * 1000 / this->parent_->get_baud_rate()) + 1; + rx_threshold != uart::UARTComponent::RX_FULL_THRESHOLD_UNSET + ? (rx_threshold * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate()) + 1 + : DEFAULT_LONG_RX_BUFFER_DELAY_MS; } void Modbus::loop() { @@ -290,7 +302,7 @@ void Modbus::send_next_frame_() { this->last_send_tx_offset_ = 0; } else { this->write_array(frame.data.get(), frame.size); - this->last_send_tx_offset_ = frame.size * 11 * 1000 / this->parent_->get_baud_rate() + 1; + this->last_send_tx_offset_ = frame.size * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 3d7e71b89f..853de719fe 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -39,6 +39,8 @@ enum class FlushResult { class UARTComponent { public: + static constexpr size_t RX_FULL_THRESHOLD_UNSET = 0; + // Writes an array of bytes to the UART bus. // @param data A vector of bytes to be written. void write_array(const std::vector &data) { this->write_array(&data[0], data.size()); } @@ -201,7 +203,9 @@ class UARTComponent { InternalGPIOPin *rx_pin_{}; InternalGPIOPin *flow_control_pin_{}; size_t rx_buffer_size_{}; - size_t rx_full_threshold_{1}; + // ESP32 (both Arduino and ESP-IDF) always sets this at codegen time via set_rx_full_threshold(). + // Other platforms (USB UART, Arduino, etc.) leave it unset. + size_t rx_full_threshold_{RX_FULL_THRESHOLD_UNSET}; size_t rx_timeout_{0}; uint32_t baud_rate_{0}; uint8_t stop_bits_{0}; diff --git a/tests/integration/fixtures/external_components/uart_mock/__init__.py b/tests/integration/fixtures/external_components/uart_mock/__init__.py index c10d73354e..fdd481b397 100644 --- a/tests/integration/fixtures/external_components/uart_mock/__init__.py +++ b/tests/integration/fixtures/external_components/uart_mock/__init__.py @@ -62,6 +62,7 @@ CONFIG_INJECT_RX_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(MockUartComponent), cv.Required("data"): cv.templatable(validate_raw_data), + cv.Optional(CONF_DELAY): cv.positive_time_period_milliseconds, }, key=CONF_DATA, ) @@ -87,7 +88,7 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(): cv.declare_id(MockUartComponent), cv.Required(CONF_BAUD_RATE): cv.int_range(min=1), cv.Optional(CONF_RX_BUFFER_SIZE, default=256): cv.validate_bytes, - cv.Optional(CONF_RX_FULL_THRESHOLD, default=10): cv.int_range(min=1, max=120), + cv.Optional(CONF_RX_FULL_THRESHOLD): cv.int_range(min=1, max=120), cv.Optional(CONF_RX_TIMEOUT, default=2): cv.int_range(min=0, max=92), cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True), cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), @@ -126,6 +127,8 @@ async def inject_rx_to_code(config, action_id, template_arg, args): arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) cg.add(var.set_data_static(arr, len(data))) + if CONF_DELAY in config: + cg.add(var.set_delay(config[CONF_DELAY])) return var @@ -135,7 +138,8 @@ async def to_code(config): cg.add(var.set_baud_rate(config[CONF_BAUD_RATE])) cg.add(var.set_rx_buffer_size(config[CONF_RX_BUFFER_SIZE])) - cg.add(var.set_rx_full_threshold(config[CONF_RX_FULL_THRESHOLD])) + if CONF_RX_FULL_THRESHOLD in config: + cg.add(var.set_rx_full_threshold(config[CONF_RX_FULL_THRESHOLD])) cg.add(var.set_rx_timeout(config[CONF_RX_TIMEOUT])) cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) cg.add(var.set_data_bits(config[CONF_DATA_BITS])) diff --git a/tests/integration/fixtures/external_components/uart_mock/automation.h b/tests/integration/fixtures/external_components/uart_mock/automation.h index 83a057d3a0..b2336ad065 100644 --- a/tests/integration/fixtures/external_components/uart_mock/automation.h +++ b/tests/integration/fixtures/external_components/uart_mock/automation.h @@ -22,18 +22,30 @@ template class MockUartInjectRXAction : public Action, pu this->len_ = len; // Length >= 0 indicates static mode } + void set_delay(uint32_t delay_ms) { this->delay_ms_ = delay_ms; } + void play(const Ts &...x) override { if (this->len_ >= 0) { // Static mode: use pointer and length - this->parent_->inject_to_rx_buffer(this->code_.data, static_cast(this->len_)); + if (this->delay_ms_ > 0) { + std::vector data(this->code_.data, this->code_.data + this->len_); + this->parent_->inject_to_rx_buffer_delayed(data, this->delay_ms_); + } else { + this->parent_->inject_to_rx_buffer(this->code_.data, static_cast(this->len_)); + } } else { // Template mode: call function auto val = this->code_.func(x...); - this->parent_->inject_to_rx_buffer(val); + if (this->delay_ms_ > 0) { + this->parent_->inject_to_rx_buffer_delayed(val, this->delay_ms_); + } else { + this->parent_->inject_to_rx_buffer(val); + } } } protected: + uint32_t delay_ms_{0}; ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length union Code { std::vector (*func)(Ts...); // Function pointer (stateless lambdas) diff --git a/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp b/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp index 1edeb97cf1..1a15da76d1 100644 --- a/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp +++ b/tests/integration/fixtures/external_components/uart_mock/uart_mock.cpp @@ -53,6 +53,15 @@ void MockUartComponent::loop() { } } + // Process staged RX - deliver bytes whose delay has elapsed + uint32_t now_ms = millis(); + while (!this->staged_rx_.empty() && (static_cast(now_ms - this->staged_rx_.front().available_at_ms) >= 0)) { + auto &staged = this->staged_rx_.front(); + ESP_LOGD(TAG, "Delivering %zu staged RX bytes", staged.data.size()); + this->inject_to_rx_buffer(staged.data); + this->staged_rx_.pop_front(); + } + // Process delayed responses for (auto &response : this->responses_) { if (response.delay_ms > 0 && response.last_match_ms > 0 && now - response.last_match_ms >= response.delay_ms) { @@ -209,4 +218,15 @@ void MockUartComponent::inject_to_rx_buffer(const std::vector &data) { } } +void MockUartComponent::inject_to_rx_buffer_delayed(const std::vector &data, uint32_t delay_ms) { + if (!data.empty() && data.size() <= 64) { + char hex_buf[format_hex_pretty_size(64)]; + ESP_LOGD(TAG, "Staging %zu RX bytes with %ums delay: %s", data.size(), delay_ms, + format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + } else if (data.size() > 64) { + ESP_LOGD(TAG, "Staging %zu RX bytes with %ums delay (too large to log inline)", data.size(), delay_ms); + } + this->staged_rx_.push_back({data, millis() + delay_ms}); +} + } // namespace esphome::uart_mock diff --git a/tests/integration/fixtures/external_components/uart_mock/uart_mock.h b/tests/integration/fixtures/external_components/uart_mock/uart_mock.h index c9afb96357..82e3b3d563 100644 --- a/tests/integration/fixtures/external_components/uart_mock/uart_mock.h +++ b/tests/integration/fixtures/external_components/uart_mock/uart_mock.h @@ -43,6 +43,8 @@ class MockUartComponent : public uart::UARTComponent, public Component { void set_tx_hook(std::function &)> &&cb) { this->tx_hook_ = std::move(cb); } void inject_to_rx_buffer(const std::vector &data); void inject_to_rx_buffer(const uint8_t *data, size_t len); + // Stage bytes for delayed delivery - simulates transport-level latency (e.g., USB packets) + void inject_to_rx_buffer_delayed(const std::vector &data, uint32_t delay_ms); protected: void check_logger_conflict() override {} @@ -82,6 +84,14 @@ class MockUartComponent : public uart::UARTComponent, public Component { }; std::vector periodic_rx_; + // Staged RX - bytes that are pending delivery after a delay + // Simulates transport-level latency (e.g., USB packet delivery) + struct StagedRx { + std::vector data; + uint32_t available_at_ms; // millis() time when bytes become available + }; + std::deque staged_rx_; + // Observability uint32_t tx_count_{0}; uint32_t rx_count_{0}; diff --git a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml new file mode 100644 index 0000000000..e3e8c8c8da --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml @@ -0,0 +1,64 @@ +esphome: + name: uart-mock-modbus-no-thresh + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Simulate a non-hardware UART (e.g., USB UART) by not setting rx_full_threshold. +# This leaves it at the default sentinel value (0), triggering the 50ms fallback timeout. +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + auto_start: false + debug: + on_tx: + - then: + - if: + condition: #Read 80 input registers on device 2, starting at address 0 (SDM meter request) + lambda: "return data == std::vector({0x02,0x04,0x00,0x00,0x00,0x50,0xF0,0x05});" + then: + - uart_mock.inject_rx: # First USB packet: SDM meter response part 1 + !lambda return {0x02,0x04,0xA0,0x43,0x73,0x19,0x9A,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,0x3F,0x80,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}; + - uart_mock.inject_rx: # Second USB packet: rest of response (staged with 40ms latency) + delay: 40ms + data: !lambda return{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x42,0x6F,0xCC,0xCD,0x43,0x7C,0xB8,0x10,0x3D,0x38,0x51,0xEC, + 0x43,0x81,0x1B,0xE7,0x3B,0x03,0x12,0x6F,0x50,0x1B}; + +modbus: + uart_id: virtual_uart_dev + turnaround_time: 10ms + +sensor: + - platform: sdm_meter + address: 2 + update_interval: 1s + phase_a: + voltage: + name: sdm_voltage + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(virtual_uart_dev).start_scenario();' diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 6901dc27fe..e341d86f53 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -5,6 +5,10 @@ test_uart_mock_modbus : 1. Read a single register and parse successfully (basic_register) 2. Read multiple registers from SDM meter and parse successfully (sdm_voltage), with some intermediate delay to simulate UART buffer time. +test_uart_mock_modbus_no_threshold : + Test modbus with no rx_full_threshold set (simulating USB UART / non-hardware UART). + Verifies the 50ms fallback timeout handles chunked data with USB packet gaps. + """ from __future__ import annotations @@ -218,3 +222,78 @@ async def test_uart_mock_modbus_timing( f"Timeout waiting for SDM voltage change. Received sensor states:\n" f" sdm_voltage: {sensor_states['sdm_voltage']}\n" ) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_no_threshold( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus with no rx_full_threshold (simulating USB UART). + + Without the 50ms fallback timeout, the chunked response with a 40ms gap + between USB packets would cause a false timeout and CRC failure cascade. + """ + # Replace external component path placeholder + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + + # Track sensor state updates (after initial state is swallowed) + sensor_states: dict[str, list[float]] = { + "sdm_voltage": [], + } + + voltage_changed = loop.create_future() + + def on_state(state: EntityState) -> None: + if isinstance(state, SensorState) and not state.missing_state: + sensor_name = key_to_sensor.get(state.key) + if sensor_name and sensor_name in sensor_states: + sensor_states[sensor_name].append(state.state) + # Check if this is a good voltage reading (243V) + if ( + sensor_name == "sdm_voltage" + and state.state > 200.0 + and not voltage_changed.done() + ): + voltage_changed.set_result(True) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + + # Build key mappings for all sensor types + all_names = list(sensor_states.keys()) + key_to_sensor = build_key_to_entity_mapping(entities, all_names) + + # Set up initial state helper + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Start the UART mock scenario now that we're subscribed + 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) + + # Wait for voltage to be updated with successful parse + try: + await asyncio.wait_for(voltage_changed, timeout=2.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for SDM voltage change. Received sensor states:\n" + f" sdm_voltage: {sensor_states['sdm_voltage']}\n" + ) From c11ad7f0e6fdbce4c65eb714e794c0791a8c7352 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 14:32:35 -1000 Subject: [PATCH 07/11] [rp2040] Wrap printf/vprintf/fprintf to eliminate _vfprintf_r (~9.2 KB flash) (#14622) --- esphome/components/rp2040/__init__.py | 17 ++++- esphome/components/rp2040/const.py | 1 + esphome/components/rp2040/printf_stubs.cpp | 74 ++++++++++++++++++++ tests/components/rp2040/test.rp2040-ard.yaml | 3 + 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 esphome/components/rp2040/printf_stubs.cpp diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 1442a0a7f7..54e1db27aa 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -21,7 +21,13 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed -from .const import KEY_BOARD, KEY_PIO_FILES, KEY_RP2040, rp2040_ns +from .const import ( + CONF_ENABLE_FULL_PRINTF, + KEY_BOARD, + KEY_PIO_FILES, + KEY_RP2040, + rp2040_ns, +) # force import gpio to register pin schema from .gpio import rp2040_pin_to_code # noqa @@ -153,6 +159,7 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=8388)), ), + cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, } ), set_core_data, @@ -190,6 +197,14 @@ async def to_code(config): ], ) + # Wrap FILE*-based printf functions to eliminate newlib's _vfprintf_r + # (~9.2 KB). See printf_stubs.cpp for implementation. + if config.get(CONF_ENABLE_FULL_PRINTF): + cg.add_define("USE_FULL_PRINTF") + else: + for symbol in ("vprintf", "printf", "fprintf"): + cg.add_build_flag(f"-Wl,--wrap={symbol}") + cg.add_platformio_option("board_build.core", "earlephilhower") cg.add_platformio_option("board_build.filesystem_size", "1m") diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2040/const.py index ab5f42d757..7eeddffc76 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2040/const.py @@ -1,5 +1,6 @@ import esphome.codegen as cg +CONF_ENABLE_FULL_PRINTF = "enable_full_printf" KEY_BOARD = "board" KEY_RP2040 = "rp2040" KEY_PIO_FILES = "pio_files" diff --git a/esphome/components/rp2040/printf_stubs.cpp b/esphome/components/rp2040/printf_stubs.cpp new file mode 100644 index 0000000000..c2174a1dec --- /dev/null +++ b/esphome/components/rp2040/printf_stubs.cpp @@ -0,0 +1,74 @@ +/* + * Linker wrap stubs for FILE*-based printf functions. + * + * The RP2040 Arduino framework and libraries may reference printf(), + * vprintf(), and fprintf() which pull in newlib's _vfprintf_r (~8.9 KB). + * ESPHome never uses these — all logging goes through the logger component + * which uses snprintf/vsnprintf, so the libc FILE*-based printf path is + * dead code. + * + * These stubs redirect through vsnprintf() (which is already in the binary) + * and fwrite(), allowing the linker to dead-code eliminate _vfprintf_r. + * + * Saves ~8.9 KB of flash. + */ + +#if defined(USE_RP2040) && !defined(USE_FULL_PRINTF) +#include +#include +#include + +namespace esphome::rp2040 {} + +static constexpr size_t PRINTF_BUFFER_SIZE = 512; + +// These stubs are essentially dead code at runtime — ESPHome uses its own +// logging through snprintf/vsnprintf, not libc printf. +// The buffer overflow check is purely defensive and should never trigger. +static int write_printf_buffer(FILE *stream, char *buf, int len) { + if (len < 0) { + return len; + } + size_t write_len = len; + if (write_len >= PRINTF_BUFFER_SIZE) { + fwrite(buf, 1, PRINTF_BUFFER_SIZE - 1, stream); + // Use fwrite for the message to avoid recursive __wrap_printf call + static const char msg[] = "\nprintf buffer overflow\n"; + fwrite(msg, 1, sizeof(msg) - 1, stream); + abort(); + } + if (fwrite(buf, 1, write_len, stream) < write_len || ferror(stream)) { + return -1; + } + return len; +} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_vprintf(const char *fmt, va_list ap) { + char buf[PRINTF_BUFFER_SIZE]; + return write_printf_buffer(stdout, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); +} + +int __wrap_printf(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + int len = __wrap_vprintf(fmt, ap); + va_end(ap); + return len; +} + +int __wrap_fprintf(FILE *stream, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char buf[PRINTF_BUFFER_SIZE]; + int len = write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); + va_end(ap); + return len; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_RP2040 && !USE_FULL_PRINTF diff --git a/tests/components/rp2040/test.rp2040-ard.yaml b/tests/components/rp2040/test.rp2040-ard.yaml index 039a261016..1eb315a3b4 100644 --- a/tests/components/rp2040/test.rp2040-ard.yaml +++ b/tests/components/rp2040/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +rp2040: + enable_full_printf: false + logger: level: VERBOSE From e1c849d5d22651a6e7d3457f2d5dbe206d21c386 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 14:32:47 -1000 Subject: [PATCH 08/11] [esp8266] Wrap printf/vprintf/fprintf to eliminate _vfiprintf_r (~1.6 KB flash) (#14621) --- esphome/components/esp8266/__init__.py | 10 +++ esphome/components/esp8266/const.py | 1 + esphome/components/esp8266/printf_stubs.cpp | 71 +++++++++++++++++++ .../components/esp8266/test.esp8266-ard.yaml | 3 + 4 files changed, 85 insertions(+) create mode 100644 esphome/components/esp8266/printf_stubs.cpp diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 927a59fd61..1ef4f5e037 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -23,6 +23,7 @@ from esphome.helpers import copy_file_if_changed from .boards import BOARDS, ESP8266_LD_SCRIPTS from .const import ( CONF_EARLY_PIN_INIT, + CONF_ENABLE_FULL_PRINTF, CONF_ENABLE_SERIAL, CONF_ENABLE_SERIAL1, CONF_RESTORE_FROM_FLASH, @@ -179,6 +180,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_ENABLE_SERIAL): cv.boolean, cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean, + cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, } ), set_core_data, @@ -260,6 +262,14 @@ async def to_code(config): if CORE.testing_mode: cg.add_build_flag("-DESPHOME_TESTING_MODE") + # Wrap FILE*-based printf functions to eliminate newlib's _vfiprintf_r + # (~1.6 KB). See printf_stubs.cpp for implementation. + if config.get(CONF_ENABLE_FULL_PRINTF): + cg.add_define("USE_FULL_PRINTF") + else: + for symbol in ("vprintf", "printf", "fprintf"): + cg.add_build_flag(f"-Wl,--wrap={symbol}") + cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 229ac61f24..57eb54f0f8 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -6,6 +6,7 @@ KEY_BOARD = "board" KEY_PIN_INITIAL_STATES = "pin_initial_states" CONF_RESTORE_FROM_FLASH = "restore_from_flash" CONF_EARLY_PIN_INIT = "early_pin_init" +CONF_ENABLE_FULL_PRINTF = "enable_full_printf" CONF_ENABLE_SERIAL = "enable_serial" CONF_ENABLE_SERIAL1 = "enable_serial1" KEY_FLASH_SIZE = "flash_size" diff --git a/esphome/components/esp8266/printf_stubs.cpp b/esphome/components/esp8266/printf_stubs.cpp new file mode 100644 index 0000000000..e6d4a74866 --- /dev/null +++ b/esphome/components/esp8266/printf_stubs.cpp @@ -0,0 +1,71 @@ +/* + * Linker wrap stubs for FILE*-based printf functions. + * + * The ESP8266 Arduino framework and libraries may reference printf(), + * vprintf(), and fprintf() which pull in newlib's _vfprintf_r (~900 bytes). + * ESPHome never uses these — all logging writes directly to the UART via + * Arduino's Serial, so the libc FILE*-based printf path is dead code. + * + * These stubs redirect through vsnprintf() (which is already in the binary + * for ESPHome's logging) and fwrite(), allowing the linker to dead-code + * eliminate _vfprintf_r. + * + * Saves ~1.6 KB of flash. + */ + +#if defined(USE_ESP8266) && !defined(USE_FULL_PRINTF) +#include +#include +#include + +namespace esphome::esp8266 {} + +static constexpr size_t PRINTF_BUFFER_SIZE = 512; + +// These stubs are essentially dead code at runtime — ESPHome writes directly +// to the UART via Arduino's Serial, and Serial.printf() has its own implementation. +// The buffer overflow check is purely defensive and should never trigger. +static int write_printf_buffer(FILE *stream, char *buf, int len) { + if (len < 0) { + return len; + } + size_t write_len = len; + if (write_len >= PRINTF_BUFFER_SIZE) { + fwrite(buf, 1, PRINTF_BUFFER_SIZE - 1, stream); + abort(); + } + if (fwrite(buf, 1, write_len, stream) < write_len || ferror(stream)) { + return -1; + } + return len; +} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_vprintf(const char *fmt, va_list ap) { + char buf[PRINTF_BUFFER_SIZE]; + return write_printf_buffer(stdout, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); +} + +int __wrap_printf(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + int len = __wrap_vprintf(fmt, ap); + va_end(ap); + return len; +} + +int __wrap_fprintf(FILE *stream, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char buf[PRINTF_BUFFER_SIZE]; + int len = write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); + va_end(ap); + return len; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP8266 && !USE_FULL_PRINTF diff --git a/tests/components/esp8266/test.esp8266-ard.yaml b/tests/components/esp8266/test.esp8266-ard.yaml index 039a261016..c77218f7a3 100644 --- a/tests/components/esp8266/test.esp8266-ard.yaml +++ b/tests/components/esp8266/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +esp8266: + enable_full_printf: false + logger: level: VERBOSE From aef2d74e41123f77900bc5cab37a75cfb9b6e2b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 14:32:59 -1000 Subject: [PATCH 09/11] [ld2450] Add integration tests with mock UART (#14611) --- .../fixtures/uart_mock_ld2450.yaml | 221 ++++++++++++++++++ tests/integration/state_utils.py | 28 ++- tests/integration/test_uart_mock_ld2450.py | 204 ++++++++++++++++ 3 files changed, 448 insertions(+), 5 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_ld2450.yaml create mode 100644 tests/integration/test_uart_mock_ld2450.py diff --git a/tests/integration/fixtures/uart_mock_ld2450.yaml b/tests/integration/fixtures/uart_mock_ld2450.yaml new file mode 100644 index 0000000000..269136da68 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450.yaml @@ -0,0 +1,221 @@ +esphome: + name: uart-mock-ld2450-test + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2450'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: 256000 + auto_start: false + responses: + # Catch-all response: match any command footer (04 03 02 01). + # Returns a generic ACK to unblock setup commands. + # + # Response layout: + # [0-3] FD FC FB FA = header + # [4-5] 04 00 = length 4 + # [6] FF = cmd (handled as CMD_ENABLE_CONF) + # [7] 01 = status (ACK) + # [8-9] 00 00 = error = 0 + # [10-13] 04 03 02 01 = footer + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + injections: + # Phase 1 (t=100ms): Valid LD2450 periodic data frame - happy path + # The buffer is clean at this point, so this frame should parse correctly. + # + # Target 1: X=-500mm, Y=1000mm, Speed=-50mm/s (approaching), Res=320mm + # X: magnitude=500 (0x01F4), negative → high=0x01, low=0xF4 + # Y: magnitude=1000 (0x03E8), positive → high=0x83, low=0xE8 + # Speed: raw=5, negative (approaching) → high=0x00, low=0x05, decoded=-50mm/s + # Resolution: 320 → low=0x40, high=0x01 + # Distance: sqrt(500²+1000²) = sqrt(1250000) ≈ 1118mm + # + # Target 2: X=200mm, Y=500mm, Speed=0 (stationary), Res=100mm + # X: magnitude=200 (0x00C8), positive → high=0x80, low=0xC8 + # Y: magnitude=500 (0x01F4), positive → high=0x81, low=0xF4 + # Speed: 0 → 0x00, 0x00 + # Resolution: 100 → low=0x64, high=0x00 + # Distance: sqrt(200²+500²) = sqrt(290000) ≈ 538mm + # + # Target 3: No target (all zeros) + # Distance: 0 → sensors publish unknown/NaN + # + # Counts: target_count=2, moving_target_count=1, still_target_count=1 + # + # Frame layout (30 bytes): + # [0-3] AA FF 03 00 = periodic data header + # [4-11] Target 1 (8 bytes): X_L X_H Y_L Y_H SPD_L SPD_H RES_L RES_H + # [12-19] Target 2 (8 bytes) + # [20-27] Target 3 (8 bytes) + # [28-29] 55 CC = periodic data footer + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x05, 0x00, 0x40, 0x01, + 0xC8, 0x80, 0xF4, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + + # Phase 2 (t=300ms): Garbage bytes + # LD2450's readline_ does NOT reject bytes at position 0 (unlike LD2412), + # so these bytes accumulate in the buffer. buffer_pos_ goes from 0 to 7. + - delay: 200ms + inject_rx: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22] + + # Phase 3 (t=400ms): Truncated frame (header + partial data, no footer) + # More bytes accumulating in the buffer without a footer match. + # After this, buffer_pos_ = 7 + 8 = 15. + - delay: 100ms + inject_rx: [0xAA, 0xFF, 0x03, 0x00, 0x01, 0x02, 0x03, 0x04] + + # Phase 4 (t=600ms): Overflow - inject 75 bytes of 0xFF (MAX_LINE_LENGTH=45) + # Buffer has 15 bytes from phases 2+3. + # readline_() stores bytes while buffer_pos_ < 44. When buffer_pos_ == 44, + # the next byte triggers overflow: logs warning, resets buffer_pos_ to 0, + # and discards that byte. + # + # First overflow: 29 bytes fill positions 15-43 (buffer_pos_=44), byte 30 + # triggers overflow (discarded). Total consumed: 30 bytes. + # Second overflow: 44 bytes fill positions 0-43 (buffer_pos_=44), byte 45 + # triggers overflow (discarded). Total consumed: 30+45 = 75 bytes. + # After both overflows, buffer_pos_ = 0 (clean state for recovery frame). + - delay: 200ms + inject_rx: + [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ] + + # Phase 5 (t=700ms): Valid frame after overflow - recovery test + # Buffer was reset by overflow. This valid frame should parse correctly. + # + # Target 1: X=300mm, Y=400mm, Speed=30mm/s (moving away), Res=100mm + # X: magnitude=300 (0x012C), positive → high=0x81, low=0x2C + # Y: magnitude=400 (0x0190), positive → high=0x81, low=0x90 + # Speed: raw=3, positive (moving away) → high=0x80, low=0x03, decoded=30mm/s + # Resolution: 100 → low=0x64, high=0x00 + # Distance: sqrt(300²+400²) = 500mm + # + # Target 2 & 3: No target (all zeros) + # Counts: target_count=1, moving_target_count=1, still_target_count=0 + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x03, 0x80, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + target_count: + name: "Target Count" + filters: &sensor_filters + - timeout: + timeout: 50ms + value: last + - throttle_with_priority: 50ms + still_target_count: + name: "Still Target Count" + filters: *sensor_filters + moving_target_count: + name: "Moving Target Count" + filters: *sensor_filters + target_1: + x: + name: "Target 1 X" + filters: *sensor_filters + y: + name: "Target 1 Y" + filters: *sensor_filters + speed: + name: "Target 1 Speed" + filters: *sensor_filters + distance: + name: "Target 1 Distance" + filters: *sensor_filters + resolution: + name: "Target 1 Resolution" + filters: *sensor_filters + angle: + name: "Target 1 Angle" + filters: *sensor_filters + target_2: + x: + name: "Target 2 X" + filters: *sensor_filters + y: + name: "Target 2 Y" + filters: *sensor_filters + speed: + name: "Target 2 Speed" + filters: *sensor_filters + distance: + name: "Target 2 Distance" + filters: *sensor_filters + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + has_target: + name: "Has Target" + filters: &binary_sensor_filters + - settle: 50ms + has_moving_target: + name: "Has Moving Target" + filters: *binary_sensor_filters + has_still_target: + name: "Has Still Target" + filters: *binary_sensor_filters + +text_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + target_1: + direction: + name: "Target 1 Direction" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index e8c2cc5e66..ab9fdb01bb 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -13,6 +13,7 @@ from aioesphomeapi import ( EntityInfo, EntityState, SensorState, + TextSensorState, ) _LOGGER = logging.getLogger(__name__) @@ -244,12 +245,13 @@ class InitialStateHelper: class SensorStateCollector: - """Collects sensor and binary sensor state updates and provides wait helpers. + """Collects sensor, binary sensor, and text sensor state updates with wait helpers. Usage: collector = SensorStateCollector( sensor_names=["moving_distance", "still_distance"], binary_sensor_names=["has_target"], + text_sensor_names=["direction"], ) # Use collector.on_state as the callback (or wrap it) client.subscribe_states(helper.on_state_wrapper(collector.on_state)) @@ -259,18 +261,23 @@ class SensorStateCollector: # Access collected states assert collector.sensor_states["moving_distance"][0] == approx(100.0) + assert collector.text_sensor_states["direction"][0] == "Approaching" """ def __init__( self, sensor_names: list[str], binary_sensor_names: list[str] | None = None, + text_sensor_names: list[str] | None = None, entities: list[EntityInfo] | None = None, ) -> None: self.sensor_states: dict[str, list[float]] = {name: [] for name in sensor_names} self.binary_states: dict[str, list[bool]] = { name: [] for name in (binary_sensor_names or []) } + self.text_sensor_states: dict[str, list[str]] = { + name: [] for name in (text_sensor_names or []) + } self._key_to_sensor: dict[int, str] = {} self._waiters: list[tuple[Callable[[], bool], asyncio.Future[bool]]] = [] @@ -279,7 +286,11 @@ class SensorStateCollector: def build_key_mapping(self, entities: list[EntityInfo]) -> None: """Build key-to-name mapping from entities. Sorted by descending length.""" - all_names = list(self.sensor_states.keys()) + list(self.binary_states.keys()) + all_names = ( + list(self.sensor_states.keys()) + + list(self.binary_states.keys()) + + list(self.text_sensor_states.keys()) + ) all_names.sort(key=len, reverse=True) self._key_to_sensor = build_key_to_entity_mapping(entities, all_names) @@ -295,6 +306,11 @@ class SensorStateCollector: if sensor_name and sensor_name in self.binary_states: self.binary_states[sensor_name].append(state.state) self._check_waiters() + elif isinstance(state, TextSensorState) and not state.missing_state: + sensor_name = self._key_to_sensor.get(state.key) + if sensor_name and sensor_name in self.text_sensor_states: + self.text_sensor_states[sensor_name].append(state.state) + self._check_waiters() def _check_waiters(self) -> None: """Check all pending waiters and resolve any whose condition is met.""" @@ -303,9 +319,11 @@ class SensorStateCollector: future.set_result(True) def _all_have_values(self) -> bool: - """Check if all sensor and binary sensor lists have at least one value.""" - return all(len(v) >= 1 for v in self.sensor_states.values()) and all( - len(v) >= 1 for v in self.binary_states.values() + """Check if all sensor, binary sensor, and text sensor lists have at least one value.""" + return ( + all(len(v) >= 1 for v in self.sensor_states.values()) + and all(len(v) >= 1 for v in self.binary_states.values()) + and all(len(v) >= 1 for v in self.text_sensor_states.values()) ) async def wait_for_all(self, timeout: float = 3.0) -> None: diff --git a/tests/integration/test_uart_mock_ld2450.py b/tests/integration/test_uart_mock_ld2450.py new file mode 100644 index 0000000000..b1aa2f6952 --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450.py @@ -0,0 +1,204 @@ +"""Integration test for LD2450 component with mock UART. + +Tests: +test_uart_mock_ld2450: + 1. Happy path - valid periodic data frame publishes correct target sensor values + 2. Multi-target tracking - verifies target count, moving/still counts + 3. Target coordinate decoding - signed X/Y coordinates with sign-magnitude encoding + 4. Speed decoding - approaching (negative) and stationary (zero) targets + 5. Distance calculation - computed from X/Y via sqrt(x²+y²) + 6. Direction text sensor - "Approaching" for negative speed target + 7. Garbage resilience - random bytes don't crash the component + 8. Truncated frame handling - partial frame doesn't corrupt state + 9. Buffer overflow recovery - overflow resets the parser + 10. Post-overflow parsing - next valid frame after overflow is parsed correctly + 11. TX logging - verifies LD2450 sends expected setup commands +""" + +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 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LD2450 data parsing with happy path, garbage, overflow, and recovery.""" + # Replace external component path placeholder + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + + # Track overflow warning in logs + overflow_seen = loop.create_future() + + # Track TX data logged by the mock for assertions + tx_log_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "Max command length exceeded" in line and not overflow_seen.done(): + overflow_seen.set_result(True) + # Capture all TX log lines from uart_mock + if "uart_mock" in line and "TX " in line: + tx_log_lines.append(line) + + collector = SensorStateCollector( + sensor_names=[ + "target_1_x", + "target_1_y", + "target_1_speed", + "target_1_distance", + "target_1_resolution", + "target_1_angle", + "target_2_x", + "target_2_y", + "target_2_speed", + "target_2_distance", + "target_count", + "still_target_count", + "moving_target_count", + ], + binary_sensor_names=[ + "has_target", + "has_moving_target", + "has_still_target", + ], + text_sensor_names=[ + "target_1_direction", + ], + ) + + # Signal when we see recovery frame values (target 1 distance ≈ 500mm) + recovery_received = collector.add_waiter( + lambda: ( + pytest.approx(500.0, abs=1.0) + in collector.sensor_states["target_1_distance"] + ) + ) + + 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) + + # Set up initial state helper + 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 the UART mock scenario now that we're subscribed + 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) + + # Wait for Phase 1 - all sensors and binary sensors have at least one value + try: + await collector.wait_for_all(timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for Phase 1 frame. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" binary_states: {collector.binary_states}\n" + f" text_states: {collector.text_sensor_states}" + ) + + # Phase 1 values: + # Target 1: X=-500, Y=1000, Speed=-50 (approaching), Res=320 + # Distance = sqrt(500²+1000²) ≈ 1118mm + assert collector.sensor_states["target_1_x"][0] == pytest.approx(-500.0) + assert collector.sensor_states["target_1_y"][0] == pytest.approx(1000.0) + assert collector.sensor_states["target_1_speed"][0] == pytest.approx(-50.0) + assert collector.sensor_states["target_1_resolution"][0] == pytest.approx(320.0) + # Distance computed from X/Y + assert collector.sensor_states["target_1_distance"][0] == pytest.approx( + 1118.0, abs=1.0 + ) + + # Target 2: X=200, Y=500, Speed=0 (stationary), Res=100 + # Distance = sqrt(200²+500²) ≈ 538mm + assert collector.sensor_states["target_2_x"][0] == pytest.approx(200.0) + assert collector.sensor_states["target_2_y"][0] == pytest.approx(500.0) + assert collector.sensor_states["target_2_speed"][0] == pytest.approx(0.0) + assert collector.sensor_states["target_2_distance"][0] == pytest.approx( + 538.0, abs=1.0 + ) + + # Target counts: 2 targets total, 1 moving, 1 still + assert collector.sensor_states["target_count"][0] == pytest.approx(2.0) + assert collector.sensor_states["moving_target_count"][0] == pytest.approx(1.0) + assert collector.sensor_states["still_target_count"][0] == pytest.approx(1.0) + + # Binary sensors: all true (targets detected) + assert collector.binary_states["has_target"][0] is True + assert collector.binary_states["has_moving_target"][0] is True + assert collector.binary_states["has_still_target"][0] is True + + # Direction text sensor: Target 1 is approaching (speed < 0) + assert collector.text_sensor_states["target_1_direction"][0] == "Approaching" + + # Wait for the recovery frame (Phase 5) to be parsed + # This proves the component survived garbage + truncated + overflow + try: + await asyncio.wait_for(recovery_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for recovery frame. Received:\n" + f" sensor_states: {collector.sensor_states}" + ) + + # Verify overflow warning was logged + assert overflow_seen.done(), ( + "Expected 'Max command length exceeded' warning in logs" + ) + + # Verify LD2450 sent setup commands (TX logging) + assert len(tx_log_lines) > 0, "Expected TX log lines from uart_mock" + tx_data = " ".join(tx_log_lines) + # Verify command frame header appears (FD:FC:FB:FA) + assert "FD:FC:FB:FA" in tx_data, ( + "Expected LD2450 command frame header FD:FC:FB:FA in TX log" + ) + # Verify command frame footer appears (04:03:02:01) + assert "04:03:02:01" in tx_data, ( + "Expected LD2450 command frame footer 04:03:02:01 in TX log" + ) + + # Recovery frame values (Phase 5, after overflow): + # Target 1: X=300, Y=400, Distance=500, Speed=30 (moving away) + # target_count=1, moving=1, still=0 + # + # Note: throttle filters cause sensor lists to have different lengths, + # so we check each value appeared somewhere rather than using a shared index. + assert ( + pytest.approx(500.0, abs=1.0) + in collector.sensor_states["target_1_distance"] + ) + assert pytest.approx(300.0) in collector.sensor_states["target_1_x"] + assert pytest.approx(400.0) in collector.sensor_states["target_1_y"] + assert pytest.approx(30.0) in collector.sensor_states["target_1_speed"] + assert pytest.approx(1.0) in collector.sensor_states["target_count"] + assert pytest.approx(1.0) in collector.sensor_states["moving_target_count"] + assert pytest.approx(0.0) in collector.sensor_states["still_target_count"] From b05dbfccd31ec769bfa873783506b469ac6def27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 14:52:21 -1000 Subject: [PATCH 10/11] [api] Bump noise-c to 0.1.11 (#14632) --- .clang-tidy.hash | 2 +- esphome/components/api/__init__.py | 2 +- platformio.ini | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.clang-tidy.hash b/.clang-tidy.hash index adcebadeb4..ff25675918 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -b6f8c16c1ddd222134bf4a71910b4c832e764e23caf49f9bce3280b079955fcf +e4b9c4b54e705d3c9400e1cdda8ba0b32634780cfa5f32271832e911bdcafe7e diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index dd99862cc2..c7dec6e78b 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -453,7 +453,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.10") + cg.add_library("esphome/noise-c", "0.1.11") else: cg.add_define("USE_API_PLAINTEXT") diff --git a/platformio.ini b/platformio.ini index 87f992759c..deee23d049 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,7 +46,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} - esphome/noise-c@0.1.10 ; api + esphome/noise-c@0.1.11 ; api improv/Improv@1.2.4 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -542,7 +542,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.10 ; used by api + esphome/noise-c@0.1.11 ; used by api build_flags = ${common.build_flags} -DUSE_HOST From 9547a54fac5de13f36a98d670e60ef18b1e88fe8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Mar 2026 14:52:40 -1000 Subject: [PATCH 11/11] [const] Move CONF_ENABLE_FULL_PRINTF to const.py (#14633) --- esphome/components/esp32/__init__.py | 2 +- esphome/components/esp8266/__init__.py | 2 +- esphome/components/esp8266/const.py | 1 - esphome/components/rp2040/__init__.py | 9 ++------- esphome/components/rp2040/const.py | 1 - esphome/const.py | 1 + 6 files changed, 5 insertions(+), 11 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8ad84656ed..52e70501dc 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_BOARD, CONF_COMPONENTS, CONF_DISABLED, + CONF_ENABLE_FULL_PRINTF, CONF_ENABLE_OTA_ROLLBACK, CONF_ESPHOME, CONF_FRAMEWORK, @@ -954,7 +955,6 @@ CONF_HEAP_IN_IRAM = "heap_in_iram" CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size" CONF_USE_FULL_CERTIFICATE_BUNDLE = "use_full_certificate_bundle" CONF_DISABLE_DEBUG_STUBS = "disable_debug_stubs" -CONF_ENABLE_FULL_PRINTF = "enable_full_printf" CONF_DISABLE_OCD_AWARE = "disable_ocd_aware" CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 1ef4f5e037..16043b6d69 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -6,6 +6,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BOARD, CONF_BOARD_FLASH_MODE, + CONF_ENABLE_FULL_PRINTF, CONF_FRAMEWORK, CONF_PLATFORM_VERSION, CONF_SOURCE, @@ -23,7 +24,6 @@ from esphome.helpers import copy_file_if_changed from .boards import BOARDS, ESP8266_LD_SCRIPTS from .const import ( CONF_EARLY_PIN_INIT, - CONF_ENABLE_FULL_PRINTF, CONF_ENABLE_SERIAL, CONF_ENABLE_SERIAL1, CONF_RESTORE_FROM_FLASH, diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 57eb54f0f8..229ac61f24 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -6,7 +6,6 @@ KEY_BOARD = "board" KEY_PIN_INITIAL_STATES = "pin_initial_states" CONF_RESTORE_FROM_FLASH = "restore_from_flash" CONF_EARLY_PIN_INIT = "early_pin_init" -CONF_ENABLE_FULL_PRINTF = "enable_full_printf" CONF_ENABLE_SERIAL = "enable_serial" CONF_ENABLE_SERIAL1 = "enable_serial1" KEY_FLASH_SIZE = "flash_size" diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 54e1db27aa..359337adfb 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -6,6 +6,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( CONF_BOARD, + CONF_ENABLE_FULL_PRINTF, CONF_FRAMEWORK, CONF_PLATFORM_VERSION, CONF_SOURCE, @@ -21,13 +22,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed -from .const import ( - CONF_ENABLE_FULL_PRINTF, - KEY_BOARD, - KEY_PIO_FILES, - KEY_RP2040, - rp2040_ns, -) +from .const import KEY_BOARD, KEY_PIO_FILES, KEY_RP2040, rp2040_ns # force import gpio to register pin schema from .gpio import rp2040_pin_to_code # noqa diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2040/const.py index 7eeddffc76..ab5f42d757 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2040/const.py @@ -1,6 +1,5 @@ import esphome.codegen as cg -CONF_ENABLE_FULL_PRINTF = "enable_full_printf" KEY_BOARD = "board" KEY_RP2040 = "rp2040" KEY_PIO_FILES = "pio_files" diff --git a/esphome/const.py b/esphome/const.py index 88e3c33fbc..d409514f3c 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -356,6 +356,7 @@ CONF_EFFECT = "effect" CONF_EFFECTS = "effects" CONF_ELSE = "else" CONF_ENABLE_BTM = "enable_btm" +CONF_ENABLE_FULL_PRINTF = "enable_full_printf" CONF_ENABLE_IPV6 = "enable_ipv6" CONF_ENABLE_ON_BOOT = "enable_on_boot" CONF_ENABLE_OTA_ROLLBACK = "enable_ota_rollback"