From 67307f0eeb1c1447cf547b0ad1adf3d756bbb6a7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:31:06 -0400 Subject: [PATCH 01/19] [ci] Select the feature-gated files the platform clang-tidy jobs were missing (#20743) --- .github/workflows/ci.yml | 10 +++++----- .../esp32_hosted/esp_now_hosted.cpp | 19 +++++++++++++------ esphome/components/ln882h_ble/ln882h_ble.cpp | 3 ++- .../components/shelly_dimmer/stm32flash.cpp | 4 ++-- .../socket/lwip_raw_common_impl.cpp | 7 +++---- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04df45d7df7..33534f1f63a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -680,7 +680,7 @@ jobs: include: - id: clang-tidy name: Run script/clang-tidy for ESP8266 - options: --environment esp8266-arduino-tidy --grep USE_ESP8266 + options: --environment esp8266-arduino-tidy --grep USE_ESP8266 --grep USE_SOCKET_IMPL_LWIP_TCP --grep USE_SHD_FIRMWARE_DATA pio_cache_key: tidyesp8266 - id: clang-tidy name: Run script/clang-tidy for ESP32 Arduino @@ -693,7 +693,7 @@ jobs: ignore_errors: false - id: clang-tidy name: Run script/clang-tidy for RP2 - options: --environment rp2-tidy --grep USE_RP2 + options: --environment rp2-tidy --grep USE_RP2 --grep USE_BLE_SCAN_RESPONSE_MERGER pio_cache_key: tidyrp2 - id: clang-tidy name: Run script/clang-tidy for host @@ -702,7 +702,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for LibreTiny environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy - options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X + options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X --grep USE_LN882H_BLE --grep USE_SOCKET_IMPL_LWIP_SOCKETS pio_cache_key: tidylibretiny steps: @@ -1003,14 +1003,14 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 S3 # yamllint disable-line rule:line-length - options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC + options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM tidy_environment: esp32s3-idf-tidy - id: clang-tidy name: Run script/clang-tidy for ESP32 P4 # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor, # so their code paths differ -- lint them under the P4 build too. # yamllint disable-line rule:line-length - options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE + options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep CONFIG_IDF_TARGET_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE tidy_environment: esp32p4-idf-tidy - id: clang-tidy name: Run script/clang-tidy for ESP32 C6 diff --git a/esphome/components/esp32_hosted/esp_now_hosted.cpp b/esphome/components/esp32_hosted/esp_now_hosted.cpp index 03b03fd983f..d226a83c1eb 100644 --- a/esphome/components/esp32_hosted/esp_now_hosted.cpp +++ b/esphome/components/esp32_hosted/esp_now_hosted.cpp @@ -42,6 +42,7 @@ extern "C" { } #include "esp_now_hosted_rpc.h" +#include "esphome/core/log.h" namespace { @@ -52,6 +53,8 @@ ESPHOME_LOG_TAG(TAG, "esp_now_hosted"); // single esp-hosted RPC RX thread. Serializing requests keeps the shared // response slot race-free; a sequence number stops a late/stale response from // being mistaken for ours. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables): the shim is +// C-ABI state shared between the main loop and the esp-hosted RX thread. SemaphoreHandle_t g_req_mutex = nullptr; SemaphoreHandle_t g_resp_sem = nullptr; // given when the matching RESP lands bool g_setup_done = false; // set only after setup fully succeeds @@ -85,6 +88,7 @@ constexpr size_t ESP_NOW_HOSTED_MAX_PEERS = 20; uint8_t g_peer_cache[ESP_NOW_HOSTED_MAX_PEERS][6]; size_t g_peer_count = 0; portMUX_TYPE g_peer_lock = portMUX_INITIALIZER_UNLOCKED; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) // Caller must hold g_peer_lock. int peer_cache_find_locked(const uint8_t *mac) { @@ -231,12 +235,14 @@ esp_err_t ensure_setup() { g_resp_sem = xSemaphoreCreateBinary(); if (g_req_mutex == nullptr || g_resp_sem == nullptr) return ESP_ERR_NO_MEM; - esp_err_t err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr)) != ESP_OK) + esp_err_t err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr); + if (err != ESP_OK) return err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr)) != ESP_OK) + err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr); + if (err != ESP_OK) return err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr)) != ESP_OK) + err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr); + if (err != ESP_OK) return err; g_setup_done = true; return ESP_OK; @@ -422,10 +428,11 @@ esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len uint8_t hdr[sizeof(esp_now_hosted_send_req_t)]; auto *s = reinterpret_cast(hdr); s->has_addr = peer_addr != nullptr ? 1 : 0; - if (peer_addr != nullptr) + if (peer_addr != nullptr) { memcpy(s->peer_addr, peer_addr, 6); - else + } else { memset(s->peer_addr, 0, 6); + } s->data_len = static_cast(len); // Fire-and-forget (wait=false): native esp_now_send returns once the frame is // queued, with the real TX result delivered later through the send callback. diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index 8f569086de2..b903b2752ed 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -246,7 +246,7 @@ static void ble_scan_callback(void *param) { memcpy(slot->data, info->data, slot->data_len); s_ble->push_scan_report(slot); -} +} // NOLINT(clang-analyzer-unix.Malloc) the slot is a pool entry handed to push_scan_report(), not a leak BLEScanReport *LN882HBLE::allocate_scan_report() { BLEScanReport *slot = this->report_pool_.allocate(); @@ -287,6 +287,7 @@ void LN882HBLE::enable() { return; this->state_ = BLEComponentState::ENABLING; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - MMIO register at a fixed address *reinterpret_cast(BLE_COEX_PTI_REG_ADDR) = BLE_COEX_PTI_ENABLE_ALL; soc_module_clk_gate_enable(CLK_G_BLE); diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index c33876e4943..5996929ab84 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -117,7 +117,7 @@ namespace esphome::shelly_dimmer { namespace { int flash_addr_to_page_ceil(const stm32_unique_ptr &stm, uint32_t addr) { - if (!(addr >= stm->dev->fl_start && addr <= stm->dev->fl_end)) + if (addr < stm->dev->fl_start || addr > stm->dev->fl_end) return 0; int page = 0; @@ -729,7 +729,7 @@ stm32_err_t stm32_write_memory(const stm32_unique_ptr &stm, uint32_t address, co uint8_t buf[256 + 2]; buf[0] = aligned_len - 1; - for (auto i = 0; i < len; i++) { + for (unsigned int i = 0; i < len; i++) { cs ^= data[i]; buf[i + 1] = data[i]; } diff --git a/esphome/components/socket/lwip_raw_common_impl.cpp b/esphome/components/socket/lwip_raw_common_impl.cpp index 4ec0a373550..52e9c2eec5e 100644 --- a/esphome/components/socket/lwip_raw_common_impl.cpp +++ b/esphome/components/socket/lwip_raw_common_impl.cpp @@ -81,14 +81,13 @@ bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { - if (!sockaddr_to_lwip(addr, addrlen, ip, port)) - return false; + const bool ok = sockaddr_to_lwip(addr, addrlen, ip, port); #if LWIP_IPV6 // Only promote wildcard binds — a specific address must keep filtering - if (family == AF_INET6 && ip_addr_isany_val(*ip)) + if (ok && family == AF_INET6 && ip_addr_isany_val(*ip)) IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); #endif - return true; + return ok; } int lwip_err_to_errno(err_t err) { From 91633594454b2d76be0cee83fc5187b016096da7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:31:39 -0400 Subject: [PATCH 02/19] [core] Analyze the ethernet component under ESP-IDF (#20745) --- esphome/core/defines.h | 4 ++-- esphome/idf_component.yml | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f589155c97f..3aa4c8e700b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -526,7 +526,8 @@ #endif #ifdef USE_ARDUINO -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 12) +#endif #define USE_ETHERNET #define USE_ETHERNET_LAN8720 #define USE_ETHERNET_RTL8201 @@ -557,7 +558,6 @@ #define USE_ETHERNET_DISCONNECT_TRIGGER #define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 #define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 -#endif #ifdef USE_ESP32 #define USE_MICRO_WAKE_WORD diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 87ec5cdcfc4..372059bd96f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -85,6 +85,9 @@ dependencies: version: "1.1.0" rules: - if: "idf_version >=6.0.0" + # Keep in sync with esphome/components/ethernet/__init__.py + espressif/ch390: + version: "0.3.0" espressif/esp_tinyusb: version: "2.2.1" rules: From 928459d16f13c9600ab91f2c41cd1bf0812fa049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Vo=C3=9F?= <46140304+mvoss96@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:33:11 +0200 Subject: [PATCH 03/19] [cse7766] Keep last power reading between measurements at low load (#20631) Co-authored-by: J. Nick Koston --- esphome/components/cse7766/cse7766.cpp | 26 +++- esphome/components/cse7766/cse7766.h | 4 +- tests/components/cse7766/common.h | 40 ++++++ .../cse7766/sensor/cse7766_test.cpp | 117 ++++++++++++++++++ 4 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 tests/components/cse7766/common.h create mode 100644 tests/components/cse7766/sensor/cse7766_test.cpp diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index bbe76855fb8..81f831672bd 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -8,6 +8,9 @@ namespace esphome::cse7766 { ESPHOME_LOG_TAG(TAG, "cse7766"); +// The chip only sends the time since the last pulse once a cycle is longer than 1 s (in us) +static constexpr uint32_t MIN_HELD_POWER_CYCLE = 1000000; + void CSE7766Component::loop() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_transmission_ >= 500) { @@ -154,18 +157,33 @@ void CSE7766Component::parse_data_() { if (power_cycle_exceeds_range) { // Datasheet: power cycle exceeding range means active power is 0 have_power = true; + this->last_power_cycle_ = 0; if (this->power_sensor_ != nullptr) { this->power_sensor_->publish_state(0.0f); } } else if (have_power) { power = power_coeff / float(power_cycle); + this->last_power_cycle_ = power_cycle; if (this->power_sensor_ != nullptr) { this->power_sensor_->publish_state(power); } - } else if (this->power_sensor_ != nullptr) { - // No valid power measurement from chip - publish 0W to avoid stale readings - // This typically happens when current is below the measurable threshold (~50mA) - this->power_sensor_->publish_state(0.0f); + } else if (this->last_power_cycle_ > MIN_HELD_POWER_CYCLE && power_cycle <= 2 * this->last_power_cycle_) { + // No new measurement in this frame. With cycles over 1 s the register holds the last + // cycle or the time elapsed since the last pulse. While that time is within twice the + // last cycle, the next pulse is not overdue, so the last measurement still applies. + // Shorter cycles never get here in normal operation and keep the previous behaviour. + have_power = true; + power = power_coeff / float(this->last_power_cycle_); + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(power); + } + } else { + // The next pulse is overdue: the load has dropped, report 0 W. A load that drops to less + // than half also reads 0 W here until its first new pulse arrives. + this->last_power_cycle_ = 0; + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(0.0f); + } } float current = 0.0f; diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index 8a57816a590..1206ad4e71f 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -36,6 +36,7 @@ class CSE7766Component final : public Component, public uart::UARTDevice { uint8_t raw_data_[CSE7766_RAW_DATA_SIZE]; uint8_t raw_data_index_{0}; + uint16_t cf_pulses_last_{0}; uint32_t last_transmission_{0}; sensor::Sensor *voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; @@ -45,7 +46,8 @@ class CSE7766Component final : public Component, public uart::UARTDevice { sensor::Sensor *reactive_power_sensor_{nullptr}; sensor::Sensor *power_factor_sensor_{nullptr}; uint32_t cf_pulses_total_{0}; - uint16_t cf_pulses_last_{0}; + // Power cycle of the last frame that carried a new power measurement, 0 if none + uint32_t last_power_cycle_{0}; }; } // namespace esphome::cse7766 diff --git a/tests/components/cse7766/common.h b/tests/components/cse7766/common.h new file mode 100644 index 00000000000..b69c04d3287 --- /dev/null +++ b/tests/components/cse7766/common.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::cse7766::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void push_rx(const std::vector &data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + // UARTComponent + 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 // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +} // namespace esphome::cse7766::testing diff --git a/tests/components/cse7766/sensor/cse7766_test.cpp b/tests/components/cse7766/sensor/cse7766_test.cpp new file mode 100644 index 00000000000..e5bcda8fa98 --- /dev/null +++ b/tests/components/cse7766/sensor/cse7766_test.cpp @@ -0,0 +1,117 @@ +#include + +#include +#include + +#include "esphome/components/cse7766/cse7766.h" +#include "esphome/components/sensor/sensor.h" +#include "../common.h" + +namespace esphome::cse7766::testing { + +namespace { + +// Coefficients and cycles taken from a Sonoff S60 (CSE7759B). With this power coefficient a power +// cycle of 2507613 is about 2.16 W, and the cycle register advances by about 1050000 per second. +constexpr uint32_t VOLTAGE_COEFF = 0x030BB0; +constexpr uint32_t VOLTAGE_CYCLE = 0x000352; +constexpr uint32_t CURRENT_COEFF = 0x004137; +constexpr uint32_t CURRENT_CYCLE = 0x053339; +constexpr uint32_t POWER_COEFF = 5425000; +constexpr uint32_t LOW_LOAD_CYCLE = 2507613; +constexpr uint32_t SHORT_CYCLE = 900000; // about 6 W, just under 1 s +constexpr uint32_t HIGH_LOAD_CYCLE = 2000; + +constexpr uint8_t HEADER_OK = 0x55; +constexpr uint8_t HEADER_POWER_CYCLE_EXCEEDS_RANGE = 0xF2; +constexpr uint8_t ADJ_NEW_POWER = 0x71; +constexpr uint8_t ADJ_NO_NEW_POWER = 0x61; + +void put24(std::vector &frame, uint32_t value) { + frame.push_back(value >> 16); + frame.push_back(value >> 8); + frame.push_back(value); +} + +std::vector make_frame(uint8_t header, uint32_t power_cycle, uint8_t adj) { + std::vector frame{header, 0x5A}; + put24(frame, VOLTAGE_COEFF); + put24(frame, VOLTAGE_CYCLE); + put24(frame, CURRENT_COEFF); + put24(frame, CURRENT_CYCLE); + put24(frame, POWER_COEFF); + put24(frame, power_cycle); + frame.push_back(adj); + frame.push_back(0x0B); + frame.push_back(0x81); + uint8_t checksum = 0; + for (size_t i = 2; i < frame.size(); i++) { + checksum += frame[i]; + } + frame.push_back(checksum); + return frame; +} + +class CSE7766Test : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_power_sensor(&this->power_); + } + + float feed_(uint8_t header, uint32_t power_cycle, uint8_t adj) { + this->uart_.push_rx(make_frame(header, power_cycle, adj)); + this->sut_.loop(); + return this->power_.state; + } + + MockUARTComponent uart_; + CSE7766Component sut_; + sensor::Sensor power_; +}; + +} // namespace + +TEST_F(CSE7766Test, NewMeasurementIsReported) { + EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER), 2.163f, 0.001f); +} + +// At low load one power cycle spans many frames. Frames in between carry the last cycle or the +// time since the last pulse, and must not be reported as 0 W. +TEST_F(CSE7766Test, LowLoadKeepsLastMeasurementBetweenPulses) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NO_NEW_POWER), 2.163f, 0.001f); + EXPECT_NEAR(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 2.163f, 0.001f); + EXPECT_NEAR(this->feed_(HEADER_OK, 4900000, ADJ_NO_NEW_POWER), 2.163f, 0.001f); +} + +// Below 1 s the chip does not send waiting times in normal operation, so such a frame +// means the pulse is overdue and 0 W is reported at once, as before +TEST_F(CSE7766Test, ShortCycleReportsZeroWithoutNewMeasurement) { + this->feed_(HEADER_OK, SHORT_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_OK, 1050000, ADJ_NO_NEW_POWER), 0.0f); +} + +TEST_F(CSE7766Test, OverduePulseReportsZero) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_OK, 2 * LOW_LOAD_CYCLE + 1, ADJ_NO_NEW_POWER), 0.0f); + // Stays 0 even when a later frame shows a short cycle again, until a new measurement arrives + EXPECT_EQ(this->feed_(HEADER_OK, 1000000, ADJ_NO_NEW_POWER), 0.0f); +} + +// A large load switching off: the first frame without a new measurement already shows about one +// second since the last pulse, far beyond the 2 ms cycle, so 0 W is reported at once. +TEST_F(CSE7766Test, HighLoadSwitchingOffReportsZeroAtOnce) { + EXPECT_NEAR(this->feed_(HEADER_OK, HIGH_LOAD_CYCLE, ADJ_NEW_POWER), 2712.5f, 0.1f); + EXPECT_EQ(this->feed_(HEADER_OK, 1072601, ADJ_NO_NEW_POWER), 0.0f); +} + +TEST_F(CSE7766Test, NoMeasurementYetReportsZero) { EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f); } + +TEST_F(CSE7766Test, PowerCycleExceedsRangeReportsZero) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_POWER_CYCLE_EXCEEDS_RANGE, 9000000, ADJ_NO_NEW_POWER), 0.0f); + EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f); +} + +} // namespace esphome::cse7766::testing From 7f144365ba6a7d9e7fff72f296b5f14c3240cb0d Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 14 Oct 2026 20:35:29 +0200 Subject: [PATCH 04/19] [fusb302b] Add USB PD sink protocol (#20632) Co-authored-by: Claude Opus 5.5 --- CODEOWNERS | 1 + esphome/components/fusb302b/__init__.py | 2 + esphome/components/fusb302b/pd.cpp | 330 ++++++++++++++++ esphome/components/fusb302b/pd.h | 178 +++++++++ tests/components/fusb302b/pd_test.cpp | 481 ++++++++++++++++++++++++ 5 files changed, 992 insertions(+) create mode 100644 esphome/components/fusb302b/__init__.py create mode 100644 esphome/components/fusb302b/pd.cpp create mode 100644 esphome/components/fusb302b/pd.h create mode 100644 tests/components/fusb302b/pd_test.cpp diff --git a/CODEOWNERS b/CODEOWNERS index 1282e9d66b9..7473c80cdc6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -219,6 +219,7 @@ esphome/components/friedrich/* @rwrozelle esphome/components/fs3000/* @kahrendt esphome/components/ft5x06/* @clydebarrow esphome/components/ft63x6/* @gpambrozio +esphome/components/fusb302b/* @remcom esphome/components/gcja5/* @gcormier esphome/components/gdk101/* @Szewcson esphome/components/gl_r01_i2c/* @pkejval diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py new file mode 100644 index 00000000000..3cf9043f418 --- /dev/null +++ b/esphome/components/fusb302b/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@remcom"] +DOMAIN = "fusb302b" diff --git a/esphome/components/fusb302b/pd.cpp b/esphome/components/fusb302b/pd.cpp new file mode 100644 index 00000000000..4b6931b38fa --- /dev/null +++ b/esphome/components/fusb302b/pd.cpp @@ -0,0 +1,330 @@ +#include "pd.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::fusb302b { + +ESPHOME_LOG_TAG(TAG, "fusb302b.pd"); + +static constexpr uint32_t AMS_TIMEOUT_MS = 2000; +// tSinkRequest: delay before repeating a request that the source answered with Wait +static constexpr uint32_t SINK_REQUEST_MS = 100; +static constexpr uint8_t MAX_WAIT_RETRIES = 10; + +// USB PD 6.4.1.3.1 Sink Fixed Supply Power Data Object flags; only the first (5 V) object carries them +static constexpr uint32_t SINK_PDO_HIGHER_CAPABILITY = 1u << 28; +static constexpr uint32_t SINK_PDO_USB_COMMS_CAPABLE = 1u << 26; +// Operational current advertised for every sink object: 3 A (300 x 10 mA) +static constexpr uint16_t SINK_PDO_CURRENT = 300; + +// USB PD 6.4.1.2.3 Source Fixed Supply Power Data Object; other PDO types only report their type +static PdContract parse_pdo(uint32_t pdo) { + PdContract info{}; + info.type = static_cast(pdo >> 30); + if (info.type == PD_PDO_TYPE_FIXED_SUPPLY) { + info.max_v = (pdo >> 10) & 0x3FF; + info.max_i = pdo & 0x3FF; + } + return info; +} + +// USB PD 6.4.1.3.1 Sink Fixed Supply Power Data Object; voltage in 50 mV units, current in 10 mA units +static constexpr uint32_t sink_fixed_pdo(uint16_t voltage, uint16_t current) { + return (static_cast(PD_PDO_TYPE_FIXED_SUPPLY) << 30) | (static_cast(voltage & 0x3FF) << 10) | + (current & 0x3FF); +} + +void PdMsg::set_header(uint16_t header) { + this->type = header & 0x1F; + this->spec_rev = static_cast((header >> 6) & 0x3); + this->id = (header >> 9) & 0x7; + this->num_of_obj = (header >> 12) & 0x7; + this->extended = (header >> 15) & 0x1; +} + +uint16_t PdMsg::get_coded_header() const { + // Data role UFP (bit 5 = 0), power role sink (bit 8 = 0) + return static_cast(this->type & 0x1F) | (static_cast(this->spec_rev) << 6) | + (static_cast(this->id & 0x7) << 9) | (static_cast(this->num_of_obj & 0x7) << 12) | + (static_cast(this->extended) << 15); +} + +PdMsg PowerDelivery::make_control_msg_(PdControlMsgType type) const { + PdMsg msg; + msg.type = type; + msg.id = this->msg_counter_ % 8; + return msg; +} + +PdMsg PowerDelivery::make_data_msg_(PdDataMsgType type, const uint32_t *objects, uint8_t len) const { + PdMsg msg; + msg.type = type; + msg.id = this->msg_counter_ % 8; + msg.num_of_obj = len; + memcpy(msg.data_objects, objects, len * sizeof(uint32_t)); + return msg; +} + +void PowerDelivery::reset_protocol_() { + this->last_received_msg_id_ = 255; + this->msg_counter_ = 0; + this->active_ams_ = false; + this->ps_rdy_expected_ = false; + this->retry_request_pending_ = false; + this->wait_retries_ = 0; +} + +void PowerDelivery::set_state_(PdState state) { + if (this->state_.exchange(state) != state) + this->on_state_changed(); +} + +void PowerDelivery::set_contract_(const PdContract &contract) { + this->accepted_contract_ = contract; + const uint32_t packed = PackedContract::from(contract).value; + if (this->contract_.exchange(packed) != packed) + this->on_state_changed(); +} + +void PowerDelivery::clear_contract_() { + this->accepted_contract_ = {}; + if (this->contract_.exchange(0) != 0) + this->on_state_changed(); +} + +void PowerDelivery::set_state_and_contract_(PdState state, const PdContract &contract) { + this->accepted_contract_ = contract; + const uint32_t packed = PackedContract::from(contract).value; + // The contract is stored first, so a reader that sees the new state also sees the new contract + const bool contract_changed = this->contract_.exchange(packed) != packed; + const bool state_changed = this->state_.exchange(state) != state; + if (contract_changed || state_changed) + this->on_state_changed(); +} + +void PowerDelivery::set_ams_(bool active) { + this->active_ams_ = active; + // A new sequence has no accepted request yet, and an ended one can no longer complete + this->ps_rdy_expected_ = false; + if (active) + this->ams_start_ = millis(); +} + +bool PowerDelivery::check_ams_() { + if (this->active_ams_ && millis() - this->ams_start_ > AMS_TIMEOUT_MS) + this->set_ams_(false); + return this->active_ams_; +} + +void PowerDelivery::check_request_retry_() { + if (!this->retry_request_pending_ || millis() - this->wait_received_ms_ < SINK_REQUEST_MS) + return; + if (this->send_request_()) { + this->retry_request_pending_ = false; + } else if (this->wait_retries_ < MAX_WAIT_RETRIES) { + // Try again after another tSinkRequest; failed attempts count against the same limit as Wait replies + this->wait_retries_++; + this->wait_received_ms_ = millis(); + } else { + ESP_LOGW(TAG, "Giving up repeating the request"); + this->retry_request_pending_ = false; + this->request_failed_(); + } +} + +bool PowerDelivery::send_request_() { + this->set_ams_(true); + if (this->send_message(this->make_data_msg_(PD_DATA_REQUEST, &this->last_rdo_, 1))) + return true; + ESP_LOGW(TAG, "Sending the request failed"); + this->set_ams_(false); + return false; +} + +void PowerDelivery::request_failed_() { + // A contract already in place stays valid. Without one, report that negotiation is over at default power. + if (this->state_ == PdState::PD_STATE_DEFAULT_CONTRACT) + this->set_state_(PdState::PD_STATE_PD_TIMEOUT); +} + +void PowerDelivery::check_transition_timeout_() { + // The AMS that sent the request only ends on PS_RDY, so an ended AMS while in transition means a timeout + if (this->state_ != PdState::PD_STATE_TRANSITION || this->check_ams_()) + return; + ESP_LOGW(TAG, "Source did not report the new supply ready, falling back to default power"); + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->on_transition_timeout(); +} + +void PowerDelivery::handle_transmit_failed_() { + // USB PD 6.12.2.2: the counter also moves on after a failed transmission. The source may have received the + // message and only its GoodCRC was lost, so reusing the MessageID would make it drop the next one as a repeat. + ESP_LOGD(TAG, "Message %u not acknowledged", this->msg_counter_ % 8); + this->msg_counter_++; +} + +void PowerDelivery::handle_message_(const PdMsg &msg) { + if (msg.num_of_obj == 0 && msg.type == PD_CNTRL_GOODCRC) { + // A GoodCRC carries the MessageID it acknowledges; a duplicate or stray one must not advance the counter + if (msg.id != this->msg_counter_ % 8) { + ESP_LOGD(TAG, "Ignoring GoodCRC for MessageID %u, expected %u", msg.id, this->msg_counter_ % 8); + return; + } + // Our last message was received, so the next one gets a new MessageID + this->msg_counter_++; + return; + } + // Soft_Reset always has MessageID 0, so it must not be dropped as a retransmission of the last message + if (msg.num_of_obj == 0 && msg.type == PD_CNTRL_SOFT_RESET) { + this->handle_soft_reset_(msg); + return; + } + // Retransmissions repeat the MessageID of a message that was already handled + if (msg.id == this->last_received_msg_id_) + return; + this->last_received_msg_id_ = msg.id; + + if (msg.num_of_obj == 0) { + this->handle_control_message_(msg); + } else { + this->handle_data_message_(msg); + } +} + +void PowerDelivery::handle_data_message_(const PdMsg &msg) { + if (msg.extended || msg.type != PD_DATA_SOURCE_CAP) { + // BIST and Vendor_Defined messages must not be answered with Reject, so unsupported data messages are ignored + ESP_LOGV(TAG, "Ignoring data message type %u%s", msg.type, + msg.extended ? LOG_STR_LITERAL(" (extended)") : LOG_STR_LITERAL("")); + return; + } + this->on_source_caps_received(); + this->respond_to_source_caps_(msg); +} + +void PowerDelivery::handle_control_message_(const PdMsg &msg) { + switch (msg.type) { + case PD_CNTRL_ACCEPT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Accept outside a request"); + break; + } + this->ps_rdy_expected_ = true; + if (this->requested_contract_ != this->accepted_contract_) + this->set_state_and_contract_(PdState::PD_STATE_TRANSITION, this->requested_contract_); + break; + case PD_CNTRL_PS_RDY: + // check_ams_() first, so a PS_RDY that arrives after the sequence timed out is ignored too + if (!this->check_ams_() || !this->ps_rdy_expected_) { + ESP_LOGD(TAG, "Ignoring PS_RDY without an accepted request"); + break; + } + this->set_ams_(false); + this->set_state_(PdState::PD_STATE_EXPLICIT_CONTRACT); + break; + case PD_CNTRL_REJECT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Reject outside a request"); + break; + } + ESP_LOGW(TAG, "Source rejected the request"); + this->set_ams_(false); + this->request_failed_(); + break; + case PD_CNTRL_WAIT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Wait outside a request"); + break; + } + this->set_ams_(false); + if (this->wait_retries_ < MAX_WAIT_RETRIES) { + ESP_LOGD(TAG, "Source asked to wait, repeating the request"); + this->wait_retries_++; + this->retry_request_pending_ = true; + this->wait_received_ms_ = millis(); + } else { + ESP_LOGW(TAG, "Source kept answering Wait, giving up"); + this->request_failed_(); + } + break; + case PD_CNTRL_PING: + case PD_CNTRL_NOT_SUPPORTED: + break; + case PD_CNTRL_GET_SINK_CAP: { + // 5 V first, then the requested voltage. Higher Capability tells the source that 5 V is not enough for + // full operation. + const uint16_t request_v = this->request_voltage_ * 20; // 50 mV units + uint32_t pdos[2]; + uint8_t count = 0; + pdos[count++] = sink_fixed_pdo(100, SINK_PDO_CURRENT) | SINK_PDO_USB_COMMS_CAPABLE | + (request_v > 100 ? SINK_PDO_HIGHER_CAPABILITY : 0); + if (request_v > 100) + pdos[count++] = sink_fixed_pdo(request_v, SINK_PDO_CURRENT); + this->send_message(this->make_data_msg_(PD_DATA_SINK_CAP, pdos, count)); + break; + } + default: + // Messages are sent as PD 2.0, which has no Not_Supported message + this->send_message(this->make_control_msg_(PD_CNTRL_REJECT)); + break; + } +} + +void PowerDelivery::handle_soft_reset_(const PdMsg &msg) { + this->reset_protocol_(); + this->last_received_msg_id_ = msg.id; + // If this fails the source escalates to a hard reset. The hook still runs, so the PHY starts waiting for source + // capabilities and recovers on its own if neither follows. + if (!this->send_message(this->make_control_msg_(PD_CNTRL_ACCEPT))) { + ESP_LOGW(TAG, "Accepting the soft reset failed"); + } + // A soft reset keeps the power level, but the outcome of a transition in progress is unknown + if (this->state_ == PdState::PD_STATE_TRANSITION) + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->on_soft_reset_received(); +} + +void PowerDelivery::respond_to_source_caps_(const PdMsg &msg) { + // Pick the highest fixed supply PDO that does not exceed the requested voltage. + // The first PDO is always the 5 V fixed supply. + const uint16_t request_v = this->request_voltage_ * 20; // 50 mV units + PdContract selected{}; + uint8_t position = 0; // 1-based object position, 0 = none + for (uint8_t idx = 0; idx < msg.num_of_obj; idx++) { + PdContract info = parse_pdo(msg.data_objects[idx]); + if (info.type != PD_PDO_TYPE_FIXED_SUPPLY) + continue; + if (position == 0 || (info.max_v <= request_v && info.max_v > selected.max_v)) { + selected = info; + position = idx + 1; + } + } + + // USB PD 6.4.2 Request Data Object: no USB suspend (bit 24), USB communications capable (bit 25) + uint32_t rdo = (1u << 24) | (1u << 25); + if (position == 0) { + ESP_LOGW(TAG, "No usable PDO in source capabilities, requesting 5 V"); + // Object 1 (5 V fixed supply), 300 mA maximum, 100 mA operating current + selected = PdContract{PD_PDO_TYPE_FIXED_SUPPLY, 100, 30}; + position = 1; + rdo |= (30u << 0) | (10u << 10); + } else { + // Request the full current the source offers + const uint32_t current = selected.max_i; + rdo |= (current << 0) | (current << 10); + } + rdo |= static_cast(position) << 28; + ESP_LOGD(TAG, "Requesting PDO %u: %.2f V", position, selected.max_v * 0.05f); + this->requested_contract_ = selected; + this->last_rdo_ = rdo; + this->retry_request_pending_ = false; + this->wait_retries_ = 0; + // A repeat would come too late: the source resets if it gets no Request within tSenderResponse (24-30 ms) + if (!this->send_request_()) + this->request_failed_(); +} + +} // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/pd.h b/esphome/components/fusb302b/pd.h new file mode 100644 index 00000000000..26c75239152 --- /dev/null +++ b/esphome/components/fusb302b/pd.h @@ -0,0 +1,178 @@ +#pragma once + +#include +#include +#include +#include + +namespace esphome::fusb302b { + +static constexpr uint8_t PD_MAX_NUM_DATA_OBJECTS = 7; +// Standard Power Range fixed supplies, which are the only ones this sink negotiates +static constexpr uint8_t MIN_REQUEST_VOLTAGE = 5; +static constexpr uint8_t MAX_REQUEST_VOLTAGE = 20; + +enum PdSpecRevision : uint8_t { + PD_SPEC_REV_1 = 0, + PD_SPEC_REV_2 = 1, + PD_SPEC_REV_3 = 2, +}; + +enum PdDataMsgType : uint8_t { + PD_DATA_SOURCE_CAP = 0x01, + PD_DATA_REQUEST = 0x02, + PD_DATA_SINK_CAP = 0x04, +}; + +enum PdControlMsgType : uint8_t { + PD_CNTRL_GOODCRC = 0x01, + PD_CNTRL_ACCEPT = 0x03, + PD_CNTRL_REJECT = 0x04, + PD_CNTRL_PING = 0x05, + PD_CNTRL_PS_RDY = 0x06, + PD_CNTRL_GET_SINK_CAP = 0x08, + PD_CNTRL_WAIT = 0x0C, + PD_CNTRL_SOFT_RESET = 0x0D, + PD_CNTRL_NOT_SUPPORTED = 0x10, +}; + +enum PdPdoType : uint8_t { + PD_PDO_TYPE_FIXED_SUPPLY = 0, + PD_PDO_TYPE_BATTERY = 1, + PD_PDO_TYPE_VARIABLE_SUPPLY = 2, + PD_PDO_TYPE_AUGMENTED = 3, +}; + +enum class PdState : uint8_t { + PD_STATE_DISCONNECTED, + /// Negotiation is over without a PD contract (no capabilities, or the request was not accepted); the source + /// supplies Type-C default power. + PD_STATE_PD_TIMEOUT, + PD_STATE_DEFAULT_CONTRACT, + PD_STATE_TRANSITION, + PD_STATE_EXPLICIT_CONTRACT, + PD_STATE_ERROR, +}; + +/// Returns true for the states in which a source is attached and supplying power. +inline bool is_connected_state(PdState state) { + return state == PdState::PD_STATE_DEFAULT_CONTRACT || state == PdState::PD_STATE_TRANSITION || + state == PdState::PD_STATE_EXPLICIT_CONTRACT || state == PdState::PD_STATE_PD_TIMEOUT; +} + +struct PdContract { + PdPdoType type{PD_PDO_TYPE_FIXED_SUPPLY}; + uint16_t max_v{0}; // 50 mV units + uint16_t max_i{0}; // 10 mA units + + bool operator==(const PdContract &other) const { + return this->max_v == other.max_v && this->max_i == other.max_i && this->type == other.type; + } + bool operator!=(const PdContract &other) const { return !(*this == other); } +}; + +// Type-C default power: 5 V (100 x 50 mV) at 500 mA (50 x 10 mA) +static constexpr PdContract DEFAULT_CONTRACT{PD_PDO_TYPE_FIXED_SUPPLY, 100, 50}; + +/// A contract packed into one word so it can be shared between tasks as an atomic. +/// 0 means there is no contract (nothing attached). +struct PackedContract { + uint32_t value{0}; + + static PackedContract from(const PdContract &contract) { + return {(static_cast(contract.max_v) << 16) | contract.max_i}; + } + bool is_set() const { return this->value != 0; } + float voltage() const { return (this->value >> 16) * 0.05f; } + float current() const { return (this->value & 0xFFFF) * 0.01f; } +}; + +struct PdMsg { + uint8_t type{0}; + PdSpecRevision spec_rev{PD_SPEC_REV_2}; + uint8_t id{0}; + uint8_t num_of_obj{0}; + bool extended{false}; + uint32_t data_objects[PD_MAX_NUM_DATA_OBJECTS]{}; + + void set_header(uint16_t header); + uint16_t get_coded_header() const; +}; + +/// USB-PD sink protocol logic, independent of the PHY chip. +/// Everything except the getters and set_request_voltage() runs in the PHY task, which must also call +/// check_transition_timeout_() and check_request_retry_() regularly; the protocol has no timers of its own. +class PowerDelivery { + public: + void set_request_voltage(uint8_t voltage) { + this->request_voltage_ = std::clamp(voltage, MIN_REQUEST_VOLTAGE, MAX_REQUEST_VOLTAGE); + } + uint8_t get_request_voltage() const { return this->request_voltage_; } + + PdState get_state() const { return this->state_; } + bool is_connected() const { return is_connected_state(this->state_); } + + protected: + /// Returns false when the message could not be handed to the chip; the PHY logs the failure. + virtual bool send_message(const PdMsg &msg) = 0; + /// Called after state_ or contract_ changed, to let the main loop publish it. + virtual void on_state_changed() = 0; + /// Called after the source soft reset the protocol; it sends new source capabilities next. + virtual void on_soft_reset_received() = 0; + /// Called when source capabilities arrive, before the request for them is sent. + virtual void on_source_caps_received() = 0; + /// Called after an accepted request was not followed by PS_RDY in time and the state fell back to default power; + /// the PHY sends a soft reset so the source offers its capabilities again. + virtual void on_transition_timeout() = 0; + + void handle_message_(const PdMsg &msg); + /// Called by the PHY when the chip gave up on a message after its retries without receiving a GoodCRC. + void handle_transmit_failed_(); + void reset_protocol_(); + void set_state_(PdState state); + void set_contract_(const PdContract &contract); + void clear_contract_(); + /// Stores both before calling on_state_changed() once, so the main loop never sees the new state with the old + /// contract. + void set_state_and_contract_(PdState state, const PdContract &contract); + void set_ams_(bool active); + /// Returns true while an atomic message sequence is in progress; ends it after a timeout. + bool check_ams_(); + /// Falls back to default power when the source accepted a request but did not report the new supply ready in time. + void check_transition_timeout_(); + /// Sends the last request again once tSinkRequest has passed after the source answered Wait. + void check_request_retry_(); + + PdMsg make_control_msg_(PdControlMsgType type) const; + PdMsg make_data_msg_(PdDataMsgType type, const uint32_t *objects, uint8_t len) const; + + // Shared with the main loop + std::atomic state_{PdState::PD_STATE_DISCONNECTED}; + std::atomic contract_{0}; + std::atomic request_voltage_{5}; + + // PHY task only + uint32_t ams_start_{0}; + uint32_t wait_received_ms_{0}; + bool retry_request_pending_{false}; + + private: + void handle_data_message_(const PdMsg &msg); + void handle_control_message_(const PdMsg &msg); + void handle_soft_reset_(const PdMsg &msg); + void respond_to_source_caps_(const PdMsg &msg); + bool send_request_(); + void request_failed_(); + + PdContract requested_contract_{}; + PdContract accepted_contract_{}; + uint32_t last_rdo_{0}; + bool active_ams_{false}; + // Set by Accept; PS_RDY is only valid after the source accepted the request of the current sequence + bool ps_rdy_expected_{false}; + uint8_t last_received_msg_id_{255}; + uint8_t msg_counter_{0}; + uint8_t wait_retries_{0}; +}; + +} // namespace esphome::fusb302b diff --git a/tests/components/fusb302b/pd_test.cpp b/tests/components/fusb302b/pd_test.cpp new file mode 100644 index 00000000000..c122d59b039 --- /dev/null +++ b/tests/components/fusb302b/pd_test.cpp @@ -0,0 +1,481 @@ +#include +#include + +#include "esphome/components/fusb302b/pd.h" + +namespace esphome::fusb302b::testing { + +static constexpr uint32_t fixed_pdo(uint16_t mv, uint16_t ma) { return ((mv / 50u) << 10) | (ma / 10u); } +// An augmented (PPS) object; only its type matters to the sink +static constexpr uint32_t AUGMENTED_PDO = static_cast(PD_PDO_TYPE_AUGMENTED) << 30; +// Unknown to the sink, so it must be answered with Reject +static constexpr uint8_t PD_CNTRL_DR_SWAP = 0x09; + +class FakePhy : public PowerDelivery { + public: + using PowerDelivery::ams_start_; + using PowerDelivery::check_ams_; + using PowerDelivery::check_transition_timeout_; + using PowerDelivery::check_request_retry_; + using PowerDelivery::contract_; + using PowerDelivery::handle_message_; + using PowerDelivery::handle_transmit_failed_; + using PowerDelivery::retry_request_pending_; + using PowerDelivery::set_state_and_contract_; + using PowerDelivery::wait_received_ms_; + + std::vector sent; + bool send_ok{true}; + int send_attempts{0}; + int state_changes{0}; + int soft_resets{0}; + int source_caps{0}; + int transition_timeouts{0}; + + void receive(uint8_t type, uint8_t id, std::vector objects = {}) { + PdMsg msg; + msg.type = type; + msg.id = id; + msg.num_of_obj = objects.size(); + for (size_t i = 0; i < objects.size(); i++) + msg.data_objects[i] = objects[i]; + this->handle_message_(msg); + } + void receive_source_caps(uint8_t id) { + this->receive(PD_DATA_SOURCE_CAP, id, + {fixed_pdo(5000, 3000), fixed_pdo(9000, 3000), fixed_pdo(15000, 3000), fixed_pdo(20000, 2250)}); + } + PackedContract contract() const { return PackedContract{this->contract_}; } + // What the PHY does when a source is attached + void attach() { this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); } + // Moves the timestamps back instead of sleeping, since millis() is real time on the host + void advance_ms(uint32_t ms) { + this->ams_start_ -= ms; + this->wait_received_ms_ -= ms; + } + + protected: + bool send_message(const PdMsg &msg) override { + this->send_attempts++; + if (!this->send_ok) + return false; + this->sent.push_back(msg); + return true; + } + void on_state_changed() override { this->state_changes++; } + void on_soft_reset_received() override { this->soft_resets++; } + void on_source_caps_received() override { + // The hook runs before the request is sent + EXPECT_EQ(this->sent.size(), static_cast(this->source_caps)); + this->source_caps++; + } + void on_transition_timeout() override { this->transition_timeouts++; } +}; + +TEST(FUSB302BPd, HeaderRoundTrip) { + PdMsg msg; + msg.type = PD_DATA_REQUEST; + msg.spec_rev = PD_SPEC_REV_2; + msg.id = 5; + msg.num_of_obj = 1; + // Type 2, spec revision 1 at bit 6, MessageID 5 at bit 9, one object at bit 12 + EXPECT_EQ(msg.get_coded_header(), 0x1A42); + + PdMsg decoded; + decoded.set_header(0x1A42 | (1u << 15)); + EXPECT_EQ(decoded.type, PD_DATA_REQUEST); + EXPECT_EQ(decoded.spec_rev, PD_SPEC_REV_2); + EXPECT_EQ(decoded.id, 5); + EXPECT_EQ(decoded.num_of_obj, 1); + EXPECT_TRUE(decoded.extended); +} + +TEST(FUSB302BPd, RequestsHighestVoltageNotAboveLimit) { + FakePhy pd; + pd.set_request_voltage(18); + pd.receive_source_caps(0); + EXPECT_EQ(pd.source_caps, 1); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_DATA_REQUEST); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u); // 15 V is object 3 + EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 300u); +} + +TEST(FUSB302BPd, LowestRequestPicksFirstObject) { + FakePhy pd; + pd.set_request_voltage(5); + pd.receive_source_caps(0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 1u); +} + +TEST(FUSB302BPd, NonFixedObjectKeepsItsPosition) { + FakePhy pd; + pd.set_request_voltage(9); + pd.receive(PD_DATA_SOURCE_CAP, 0, {fixed_pdo(5000, 3000), AUGMENTED_PDO, fixed_pdo(9000, 2000)}); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u); + EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 200u); +} + +TEST(FUSB302BPd, NoFixedObjectFallsBackToObjectOne) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive(PD_DATA_SOURCE_CAP, 0, {AUGMENTED_PDO, AUGMENTED_PDO}); + ASSERT_EQ(pd.sent.size(), 1u); + const uint32_t rdo = pd.sent[0].data_objects[0]; + EXPECT_EQ(rdo >> 28, 1u); + EXPECT_EQ(rdo & 0x3FF, 30u); // 300 mA maximum + EXPECT_EQ((rdo >> 10) & 0x3FF, 10u); // 100 mA operating +} + +TEST(FUSB302BPd, AcceptThenPsRdyReachesExplicitContract) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 2.25f); +} + +TEST(FUSB302BPd, AcceptPublishesStateAndContractOnce) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.state_changes = 0; + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); +} + +TEST(FUSB302BPd, AcceptForSameContractSkipsTransition) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.receive(PD_CNTRL_PS_RDY, 2); + pd.receive_source_caps(3); + pd.receive(PD_CNTRL_ACCEPT, 4); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + pd.receive(PD_CNTRL_PS_RDY, 5); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FALSE(pd.check_ams_()); +} + +TEST(FUSB302BPd, PsRdyWithoutAcceptIsIgnored) { + FakePhy pd; + pd.receive(PD_CNTRL_PS_RDY, 0); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); + + pd.receive_source_caps(1); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); +} + +TEST(FUSB302BPd, PsRdyAfterRejectIsIgnored) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + EXPECT_FALSE(pd.check_ams_()); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); +} + +TEST(FUSB302BPd, PsRdyAfterSequenceTimeoutIsIgnored) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.advance_ms(2001); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); +} + +TEST(FUSB302BPd, TransitionTimeoutFallsBackToDefault) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.check_transition_timeout_(); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + EXPECT_EQ(pd.transition_timeouts, 0); + + pd.advance_ms(2001); + pd.state_changes = 0; + pd.check_transition_timeout_(); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.transition_timeouts, 1); + + // Reported once, and a late PS_RDY does not bring back the unconfirmed contract + pd.check_transition_timeout_(); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.transition_timeouts, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); +} + +TEST(FUSB302BPd, RequestVoltageIsClamped) { + FakePhy pd; + pd.set_request_voltage(255); + EXPECT_EQ(pd.get_request_voltage(), MAX_REQUEST_VOLTAGE); + pd.set_request_voltage(0); + EXPECT_EQ(pd.get_request_voltage(), MIN_REQUEST_VOLTAGE); +} + +TEST(FUSB302BPd, WaitRepeatsRequestAfterSinkRequestTime) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + EXPECT_FALSE(pd.check_ams_()); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 1u); + pd.advance_ms(101); + pd.check_request_retry_(); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].type, PD_DATA_REQUEST); + EXPECT_EQ(pd.sent[1].data_objects[0], pd.sent[0].data_objects[0]); + EXPECT_TRUE(pd.check_ams_()); +} + +TEST(FUSB302BPd, WaitRetriesStopAtLimit) { + FakePhy pd; + pd.attach(); + pd.receive_source_caps(0); + for (uint8_t i = 1; i <= 10; i++) { + pd.receive(PD_CNTRL_WAIT, i % 8); + pd.advance_ms(101); + pd.check_request_retry_(); + } + EXPECT_EQ(pd.sent.size(), 11u); + pd.receive(PD_CNTRL_WAIT, 3); + EXPECT_FALSE(pd.retry_request_pending_); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 11u); +} + +TEST(FUSB302BPd, FailedRequestEndsNegotiation) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.send_ok = false; + pd.receive_source_caps(0); + EXPECT_EQ(pd.send_attempts, 1); + EXPECT_FALSE(pd.check_ams_()); + EXPECT_FALSE(pd.retry_request_pending_); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); +} + +TEST(FUSB302BPd, FailedRetryIsTriedAgain) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + pd.send_ok = false; + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.send_attempts, 2); + EXPECT_TRUE(pd.retry_request_pending_); + EXPECT_FALSE(pd.check_ams_()); + + pd.send_ok = true; + pd.check_request_retry_(); + EXPECT_EQ(pd.send_attempts, 2); // waits another tSinkRequest first + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 2u); + EXPECT_FALSE(pd.retry_request_pending_); +} + +TEST(FUSB302BPd, FailedRetriesStopAtLimit) { + FakePhy pd; + pd.attach(); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + pd.send_ok = false; + for (int i = 0; i < 20 && pd.retry_request_pending_; i++) { + pd.advance_ms(101); + pd.check_request_retry_(); + } + EXPECT_FALSE(pd.retry_request_pending_); + // The first request, then one attempt for each of the 10 allowed repeats + EXPECT_EQ(pd.send_attempts, 11); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); +} + +TEST(FUSB302BPd, RejectedFirstRequestEndsNegotiation) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); +} + +TEST(FUSB302BPd, RejectedRenegotiationKeepsContract) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.receive(PD_CNTRL_PS_RDY, 2); + pd.set_request_voltage(9); + pd.receive_source_caps(3); + pd.receive(PD_CNTRL_REJECT, 4); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); +} + +TEST(FUSB302BPd, RejectAndWaitOutsideRequestAreIgnored) { + FakePhy pd; + pd.attach(); + pd.receive(PD_CNTRL_REJECT, 0); + pd.receive(PD_CNTRL_WAIT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FALSE(pd.retry_request_pending_); +} + +TEST(FUSB302BPd, UnsupportedDataMessageIsIgnored) { + FakePhy pd; + pd.receive(PD_DATA_REQUEST, 0, {0}); + EXPECT_TRUE(pd.sent.empty()); + EXPECT_EQ(pd.source_caps, 0); +} + +TEST(FUSB302BPd, AcceptOutsideSequenceIsIgnored) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + pd.receive(PD_CNTRL_ACCEPT, 2); + pd.receive(PD_CNTRL_PS_RDY, 3); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); + EXPECT_FALSE(pd.contract().is_set()); +} + +TEST(FUSB302BPd, GoodCrcMustMatchMessageId) { + FakePhy pd; + pd.receive(PD_CNTRL_DR_SWAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].id, 0); + + pd.receive(PD_CNTRL_GOODCRC, 1); + pd.receive(PD_CNTRL_DR_SWAP, 1); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].id, 0); + + pd.receive(PD_CNTRL_GOODCRC, 0); + pd.receive(PD_CNTRL_GOODCRC, 0); // duplicate + pd.receive(PD_CNTRL_DR_SWAP, 2); + ASSERT_EQ(pd.sent.size(), 3u); + EXPECT_EQ(pd.sent[2].id, 1); +} + +TEST(FUSB302BPd, FailedTransmitAdvancesMessageId) { + FakePhy pd; + pd.receive_source_caps(0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].id, 0); + pd.handle_transmit_failed_(); + // A late GoodCRC for the failed message must not advance the counter a second time + pd.receive(PD_CNTRL_GOODCRC, 0); + pd.receive(PD_CNTRL_DR_SWAP, 1); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].id, 1); +} + +TEST(FUSB302BPd, RetransmissionIsIgnored) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive_source_caps(0); + EXPECT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.source_caps, 1); +} + +TEST(FUSB302BPd, SoftResetIsAccepted) { + FakePhy pd; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_CNTRL_ACCEPT); + EXPECT_EQ(pd.soft_resets, 1); +} + +TEST(FUSB302BPd, SoftResetHookRunsWhenAcceptFails) { + FakePhy pd; + pd.send_ok = false; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + EXPECT_EQ(pd.send_attempts, 1); + EXPECT_EQ(pd.soft_resets, 1); +} + +TEST(FUSB302BPd, SoftResetDuringTransitionFallsBackToDefault) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.state_changes = 0; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f); +} + +TEST(FUSB302BPd, UnknownControlMessageIsRejected) { + FakePhy pd; + pd.receive(PD_CNTRL_DR_SWAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_CNTRL_REJECT); +} + +TEST(FUSB302BPd, SinkCapAt5VHasOneObject) { + FakePhy pd; + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_DATA_SINK_CAP); + ASSERT_EQ(pd.sent[0].num_of_obj, 1); + const uint32_t pdo = pd.sent[0].data_objects[0]; + EXPECT_EQ((pdo >> 10) & 0x3FF, 100u); + EXPECT_EQ(pdo & 0x3FF, 300u); + EXPECT_FALSE(pdo & (1u << 28)); + EXPECT_TRUE(pdo & (1u << 26)); +} + +TEST(FUSB302BPd, SinkCapListsRequestedVoltage) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + ASSERT_EQ(pd.sent[0].num_of_obj, 2); + EXPECT_TRUE(pd.sent[0].data_objects[0] & (1u << 28)); + const uint32_t pdo = pd.sent[0].data_objects[1]; + EXPECT_EQ(pdo >> 30, 0u); + EXPECT_EQ((pdo >> 10) & 0x3FF, 400u); + EXPECT_EQ(pdo & 0x3FF, 300u); + EXPECT_EQ(pdo >> 25, 0u); // capability flags are only valid in the first object +} + +TEST(FUSB302BPd, SinkCapForOutOfRangeVoltageStaysValid) { + FakePhy pd; + pd.set_request_voltage(255); + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + ASSERT_EQ(pd.sent[0].num_of_obj, 2); + const uint32_t pdo = pd.sent[0].data_objects[1]; + EXPECT_EQ((pdo >> 10) & 0x3FF, 400u); + EXPECT_EQ(pdo >> 20, 0u); // nothing spills into the peak current or flag bits +} + +} // namespace esphome::fusb302b::testing From a1f740b71bda88bcf060d9f5ce05491e0ef5ba0d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:36:17 -0400 Subject: [PATCH 05/19] [lvgl] Keep LV_CONF_PATH one argument when the build path has a space (#20758) --- esphome/components/lvgl/__init__.py | 14 +++++-- .../lvgl/test_lv_conf_path_flag.py | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/test_lv_conf_path_flag.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6e3087ba9f0..6806eab0cf3 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -335,6 +335,16 @@ def final_validation(config_list): widget_type.final_validate(name, conf, widget_conf, path[1:]) +def lv_conf_path_flag(lv_conf_h_file: Path) -> str: + """The ``-DLV_CONF_PATH`` build flag, decoded identically by the shell + lexers (PlatformIO, host, ESP8266) and the quoted CMake arguments + (ESP-IDF, Zephyr) the build flags pass through.""" + # forward slashes for Windows, then escape what shlex or a quoted CMake + # argument would not pass through literally + path = re.sub(r"[^\w/.:+=,-]", r"\\\g<0>", Path(lv_conf_h_file).as_posix()) + return f'-DLV_CONF_PATH=\\"{path}\\"' + + async def to_code(configs): config_0 = configs[0] # Global configuration @@ -563,9 +573,7 @@ async def to_code(configs): if write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()): clean_build(clear_pio_cache=False) cg.add_build_flag("-DLV_CONF_H=1") - # handle windows paths in a way that doesn't break the generated C++ - lv_conf_h_path = Path(lv_conf_h_file).as_posix() - cg.add_build_flag(f'-DLV_CONF_PATH=\\"{lv_conf_h_path}\\"') + cg.add_build_flag(lv_conf_path_flag(lv_conf_h_file)) cg.add_build_flag("-DLV_KCONFIG_IGNORE") for prop in df.get_remapped_uses(): diff --git a/tests/component_tests/lvgl/test_lv_conf_path_flag.py b/tests/component_tests/lvgl/test_lv_conf_path_flag.py new file mode 100644 index 00000000000..e13a0bcc376 --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf_path_flag.py @@ -0,0 +1,39 @@ +"""The ``-DLV_CONF_PATH`` build flag carries a path. PlatformIO, the host +build and the native ESP8266 build shell-lex build flags, while the ESP-IDF +and Zephyr builds write each flag into a quoted CMake argument; both must +decode it to one ``-DLV_CONF_PATH="..."`` argument, including when the +project path contains a space.""" + +from __future__ import annotations + +from pathlib import Path +import re +import shlex + +import pytest + +from esphome.components.lvgl import lv_conf_path_flag + + +def _cmake_quoted_argument(flag: str) -> str: + """Decode ``flag`` as CMake decodes a quoted argument: a backslash escapes the next character.""" + return re.sub(r"\\(.)", r"\1", flag) + + +@pytest.mark.parametrize( + "build_path", + [ + "/home/user/.esphome/build/node", + "/Users/me/with space/.esphome/build/node", + "/srv/odd 'quote' $(and)/build", + "C:/Users/Kris Maddens/esphome/.esphome/build/node", + ], + ids=["plain", "space", "shell-specials", "windows"], +) +def test_flag_is_one_argument_everywhere(build_path: str) -> None: + lv_conf_h_file = Path(build_path) / "src" / "lv_conf.h" + flag = lv_conf_path_flag(lv_conf_h_file) + expected = f'-DLV_CONF_PATH="{lv_conf_h_file.as_posix()}"' + + assert shlex.split(flag) == [expected] + assert _cmake_quoted_argument(flag) == expected From b994e081c30b21a8ecf222e794030bc439353e67 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:42:47 +0100 Subject: [PATCH 06/19] [bm8563] Convert the weekday to and from the chip's 0-6 range (#20737) --- esphome/components/bm8563/bm8563.cpp | 5 +- tests/components/bm8563/__init__.py | 6 ++ tests/components/bm8563/test_bm8563.cpp | 77 +++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/components/bm8563/__init__.py create mode 100644 tests/components/bm8563/test_bm8563.cpp diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index df1cf96d698..de6b51ab1f5 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -100,7 +100,8 @@ void BM8563::get_date_(ESPTime &time) { this->read_register(DATE_FIRST_REG, buf, sizeof(buf)); time.day_of_month = this->bcd2_to_byte_(buf[0] & 0x3f); - time.day_of_week = this->bcd2_to_byte_(buf[1] & 0x07); + // The chip counts weekdays 0-6 from Sunday (not BCD), ESPTime counts 1-7 + time.day_of_week = (buf[1] & 0x07) + 1; time.month = this->bcd2_to_byte_(buf[2] & 0x1f); uint8_t year_byte = this->bcd2_to_byte_(buf[3] & 0xff); @@ -115,7 +116,7 @@ void BM8563::get_date_(ESPTime &time) { void BM8563::set_date_(const ESPTime &time) { uint8_t buf[4] = { this->byte_to_bcd2_(time.day_of_month), - this->byte_to_bcd2_(time.day_of_week), + static_cast(time.day_of_week - 1), this->byte_to_bcd2_(time.month), this->byte_to_bcd2_(time.year % 100), }; diff --git a/tests/components/bm8563/__init__.py b/tests/components/bm8563/__init__.py new file mode 100644 index 00000000000..9465ea845e2 --- /dev/null +++ b/tests/components/bm8563/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the time platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/bm8563/test_bm8563.cpp b/tests/components/bm8563/test_bm8563.cpp new file mode 100644 index 00000000000..3514702e3b4 --- /dev/null +++ b/tests/components/bm8563/test_bm8563.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include + +#include "esphome/components/bm8563/bm8563.h" +#include "esphome/core/time.h" + +namespace esphome::bm8563::testing { + +static constexpr uint8_t ADDRESS = 0x51; +static constexpr uint8_t WEEKDAYS_REG = 0x06; +static constexpr uint8_t CHIP_DAYS_PER_WEEK = 7; // The chip counts weekdays 0-6, Sunday = 0 + +// The chip's 16 registers behind an address pointer that moves on after every byte. +class RegisterBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (size != 0) { + this->pointer_ = data[0]; + for (size_t i = 1; i < size; i++) + this->registers[this->next_()] = data[i]; + } + for (size_t i = 0; i < out_size; i++) + out[i] = this->registers[this->next_()]; + return i2c::ERROR_OK; + } + + std::array registers{}; + + protected: + size_t next_() { return this->pointer_++ % this->registers.size(); } + + uint8_t pointer_{0}; +}; + +// write_time() takes the host's current time, so reading it back never changes the host clock. +class BM8563Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { ++this->syncs_; }); + this->rtc_.setup(); + } + + RegisterBus bus_; + BM8563 rtc_; + unsigned syncs_{0}; +}; + +TEST_F(BM8563Test, WriteTimeStoresTheChipsWeekday) { + // Read the clock on both sides of the write, in case UTC midnight falls in between. + const int before = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + this->rtc_.write_time(); + const int after = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + const int stored = this->bus_.registers[WEEKDAYS_REG]; + EXPECT_TRUE(stored == before || stored == after) << "stored " << stored << ", expected " << before; +} + +// After a write the chip keeps counting on its own and goes from Saturday (6) back to Sunday (0) at midnight. +// Seven steps from the written value visit every weekday once, and each has to read back as a valid time. +// Only the weekday moves, so the time read back is still the host's time. +TEST_F(BM8563Test, WeekdayRoundTripsThroughSundayAndSaturday) { + this->rtc_.write_time(); + uint8_t &weekday = this->bus_.registers[WEEKDAYS_REG]; + for (uint8_t day = 0; day < CHIP_DAYS_PER_WEEK; day++) { + weekday = (weekday + 1) % CHIP_DAYS_PER_WEEK; + const unsigned syncs_before = this->syncs_; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, syncs_before + 1) << "chip weekday " << unsigned(weekday); + } +} + +} // namespace esphome::bm8563::testing From fee47fe2f16340a5f39f50c556fe65575f9acdaa Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:43:23 +0100 Subject: [PATCH 07/19] [pcf85063] Convert the weekday to and from the chip's 0-6 range (#20736) --- esphome/components/pcf85063/pcf85063.cpp | 5 +- tests/components/pcf85063/__init__.py | 6 ++ tests/components/pcf85063/test_pcf85063.cpp | 77 +++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/components/pcf85063/__init__.py create mode 100644 tests/components/pcf85063/test_pcf85063.cpp diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index 8ce76e9781c..bafe19ed2a4 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -37,7 +37,8 @@ void PCF85063Component::read_time() { .second = uint8_t(pcf85063_.reg.second + 10 * pcf85063_.reg.second_10), .minute = uint8_t(pcf85063_.reg.minute + 10u * pcf85063_.reg.minute_10), .hour = uint8_t(pcf85063_.reg.hour + 10u * pcf85063_.reg.hour_10), - .day_of_week = uint8_t(pcf85063_.reg.weekday), + // The chip counts weekdays 0-6 from Sunday, ESPTime counts 1-7 + .day_of_week = uint8_t(this->pcf85063_.reg.weekday + 1), .day_of_month = uint8_t(pcf85063_.reg.day + 10u * pcf85063_.reg.day_10), .month = uint8_t(pcf85063_.reg.month + 10u * pcf85063_.reg.month_10), .year = uint16_t(pcf85063_.reg.year + 10u * pcf85063_.reg.year_10 + 2000), @@ -62,7 +63,7 @@ void PCF85063Component::write_time() { pcf85063_.reg.month_10 = now.month / 10; pcf85063_.reg.day = now.day_of_month % 10; pcf85063_.reg.day_10 = now.day_of_month / 10; - pcf85063_.reg.weekday = now.day_of_week; + this->pcf85063_.reg.weekday = now.day_of_week - 1; pcf85063_.reg.hour = now.hour % 10; pcf85063_.reg.hour_10 = now.hour / 10; pcf85063_.reg.minute = now.minute % 10; diff --git a/tests/components/pcf85063/__init__.py b/tests/components/pcf85063/__init__.py new file mode 100644 index 00000000000..9465ea845e2 --- /dev/null +++ b/tests/components/pcf85063/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the time platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/pcf85063/test_pcf85063.cpp b/tests/components/pcf85063/test_pcf85063.cpp new file mode 100644 index 00000000000..0e5c5e6c20d --- /dev/null +++ b/tests/components/pcf85063/test_pcf85063.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include + +#include "esphome/components/pcf85063/pcf85063.h" +#include "esphome/core/time.h" + +namespace esphome::pcf85063::testing { + +static constexpr uint8_t ADDRESS = 0x51; +static constexpr uint8_t WEEKDAYS_REG = 0x08; +static constexpr uint8_t CHIP_DAYS_PER_WEEK = 7; // The chip counts weekdays 0-6, Sunday = 0 + +// The chip's 18 registers behind an address pointer that moves on after every byte. +class RegisterBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (size != 0) { + this->pointer_ = data[0]; + for (size_t i = 1; i < size; i++) + this->registers[this->next_()] = data[i]; + } + for (size_t i = 0; i < out_size; i++) + out[i] = this->registers[this->next_()]; + return i2c::ERROR_OK; + } + + std::array registers{}; + + protected: + size_t next_() { return this->pointer_++ % this->registers.size(); } + + uint8_t pointer_{0}; +}; + +// write_time() takes the host's current time, so reading it back never changes the host clock. +class PCF85063Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { ++this->syncs_; }); + this->rtc_.setup(); + } + + RegisterBus bus_; + PCF85063Component rtc_; + unsigned syncs_{0}; +}; + +TEST_F(PCF85063Test, WriteTimeStoresTheChipsWeekday) { + // Read the clock on both sides of the write, in case UTC midnight falls in between. + const int before = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + this->rtc_.write_time(); + const int after = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + const int stored = this->bus_.registers[WEEKDAYS_REG]; + EXPECT_TRUE(stored == before || stored == after) << "stored " << stored << ", expected " << before; +} + +// After a write the chip keeps counting on its own and goes from Saturday (6) back to Sunday (0) at midnight. +// Seven steps from the written value visit every weekday once, and each has to read back as a valid time. +// Only the weekday moves, so the time read back is still the host's time. +TEST_F(PCF85063Test, WeekdayRoundTripsThroughSundayAndSaturday) { + this->rtc_.write_time(); + uint8_t &weekday = this->bus_.registers[WEEKDAYS_REG]; + for (uint8_t day = 0; day < CHIP_DAYS_PER_WEEK; day++) { + weekday = (weekday + 1) % CHIP_DAYS_PER_WEEK; + const unsigned syncs_before = this->syncs_; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, syncs_before + 1) << "chip weekday " << unsigned(weekday); + } +} + +} // namespace esphome::pcf85063::testing From 56f1a2bf460c8cdf3e19efad97c03689b9259d19 Mon Sep 17 00:00:00 2001 From: Fexiven <48439988+Fexiven@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:45:21 +0200 Subject: [PATCH 08/19] [midea_portasplit] Add Midea PortaSplit climate component (#18033) Co-authored-by: Claude Opus 4.8 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/midea_portasplit/__init__.py | 1 + .../components/midea_portasplit/climate.py | 30 ++ .../midea_portasplit/midea_portasplit.cpp | 337 ++++++++++++++++++ .../midea_portasplit/midea_portasplit.h | 63 ++++ tests/components/midea_portasplit/common.yaml | 4 + .../midea_portasplit/test.esp32-idf.yaml | 3 + .../midea_portasplit/test.esp8266-ard.yaml | 3 + 8 files changed, 442 insertions(+) create mode 100644 esphome/components/midea_portasplit/__init__.py create mode 100644 esphome/components/midea_portasplit/climate.py create mode 100644 esphome/components/midea_portasplit/midea_portasplit.cpp create mode 100644 esphome/components/midea_portasplit/midea_portasplit.h create mode 100644 tests/components/midea_portasplit/common.yaml create mode 100644 tests/components/midea_portasplit/test.esp32-idf.yaml create mode 100644 tests/components/midea_portasplit/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 7473c80cdc6..56cb685ec53 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -387,6 +387,7 @@ esphome/components/microphone/* @jesserockz @kahrendt esphome/components/mics_4514/* @jesserockz esphome/components/midea/* @dudanov esphome/components/midea_ir/* @dudanov +esphome/components/midea_portasplit/* @Fexiven esphome/components/mipi_dsi/* @clydebarrow esphome/components/mipi_rgb/* @clydebarrow esphome/components/mipi_spi/* @clydebarrow diff --git a/esphome/components/midea_portasplit/__init__.py b/esphome/components/midea_portasplit/__init__.py new file mode 100644 index 00000000000..e94ead74d25 --- /dev/null +++ b/esphome/components/midea_portasplit/__init__.py @@ -0,0 +1 @@ +DOMAIN = "midea_portasplit" diff --git a/esphome/components/midea_portasplit/climate.py b/esphome/components/midea_portasplit/climate.py new file mode 100644 index 00000000000..efac42a403b --- /dev/null +++ b/esphome/components/midea_portasplit/climate.py @@ -0,0 +1,30 @@ +import esphome.codegen as cg +from esphome.components import climate, uart +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@Fexiven"] +DEPENDENCIES = ["uart"] + +midea_portasplit_ns = cg.esphome_ns.namespace("midea_portasplit") +PortaSplitClimate = midea_portasplit_ns.class_( + "PortaSplitClimate", climate.Climate, uart.UARTDevice, cg.Component +) + +CONFIG_SCHEMA = ( + climate.climate_schema(PortaSplitClimate) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, baud_rate=9600, require_tx=True, require_rx=True +) + + +async def to_code(config: ConfigType) -> None: + var = await climate.new_climate(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/midea_portasplit/midea_portasplit.cpp b/esphome/components/midea_portasplit/midea_portasplit.cpp new file mode 100644 index 00000000000..a1f22226d9c --- /dev/null +++ b/esphome/components/midea_portasplit/midea_portasplit.cpp @@ -0,0 +1,337 @@ +#include "midea_portasplit.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::midea_portasplit { + +ESPHOME_LOG_TAG(TAG, "midea_portasplit"); + +static constexpr uint32_t FOLLOWUP_TIMEOUT_ID = 0; + +static constexpr uint32_t STATUS_INTERVAL_MS = 30000; +static constexpr uint32_t NETWORK_INTERVAL_MS = 10000; +static constexpr uint32_t FOLLOWUP_DELAY_MS = 1500; +// At 9600 baud bytes within a frame arrive about 1 ms apart. +static constexpr uint32_t RX_TIMEOUT_MS = 100; + +// Ten header bytes plus CRC8 and checksum. +static constexpr size_t FRAME_OVERHEAD = 12; +static constexpr size_t MAX_TX_PAYLOAD = 24; + +static constexpr float MIN_TARGET_TEMP = 16.0f; +static constexpr float MAX_TARGET_TEMP = 30.0f; + +// The AC expects its Wi-Fi module to answer the 0x63 keepalive poll and to send periodic 0x0D network status +// frames. These frames use the XOR sync byte (0x1E ^ 0xAC = 0xB2). The AC does not use the IP address; the +// fixed frame below was captured from the original module. +static constexpr uint8_t MODULE_FRAME[31] PROGMEM = {0xAA, 0x1E, 0xAC, 0xB2, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, + 0x01, 0x04, 0xC0, 0xA8, 0xB2, 0x72, 0x00, 0x00, 0x00, 0x01, 0x01, + 0x01, 0x28, 0x09, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00}; +static constexpr uint8_t STATUS_QUERY[12] PROGMEM = {0x41, 0x21, 0x00, 0xFF, 0x03, 0xFF, + 0x00, 0x02, 0x00, 0x00, 0x00, 0x00}; +// Same capability queries the original module sends at boot. +static constexpr uint8_t CAPS_0[] PROGMEM = {0xB5, 0x01, 0x00}; +static constexpr uint8_t CAPS_1[] PROGMEM = {0xB5, 0x01, 0x01, 0x01}; + +// Two's complement of the sum of all bytes after the 0xAA start marker. +static uint8_t checksum(const uint8_t *frame, size_t len) { return (uint8_t) -sum_bytes(frame + 1, len - 1); } + +static uint8_t climate_mode_to_midea(climate::ClimateMode mode) { + switch (mode) { + case climate::CLIMATE_MODE_COOL: + return 2; + case climate::CLIMATE_MODE_DRY: + return 3; + case climate::CLIMATE_MODE_HEAT: + return 4; + case climate::CLIMATE_MODE_FAN_ONLY: + return 5; + case climate::CLIMATE_MODE_HEAT_COOL: + default: + return 1; + } +} + +static climate::ClimateMode midea_mode_to_climate(uint8_t mode) { + switch (mode) { + case 2: + return climate::CLIMATE_MODE_COOL; + case 3: + return climate::CLIMATE_MODE_DRY; + case 4: + return climate::CLIMATE_MODE_HEAT; + case 5: + return climate::CLIMATE_MODE_FAN_ONLY; + case 1: + default: + return climate::CLIMATE_MODE_HEAT_COOL; + } +} + +static uint8_t fan_mode_to_midea(climate::ClimateFanMode mode) { + switch (mode) { + case climate::CLIMATE_FAN_QUIET: + return 20; + case climate::CLIMATE_FAN_LOW: + return 40; + case climate::CLIMATE_FAN_MEDIUM: + return 60; + case climate::CLIMATE_FAN_HIGH: + return 80; + default: + return FAN_AUTO; + } +} + +// The AC reports its own fan steps (30 for low, 50 for medium), so classify by range. +static climate::ClimateFanMode midea_fan_to_climate(uint8_t fan) { + if (fan > 100) + return climate::CLIMATE_FAN_AUTO; + if (fan <= 30) + return climate::CLIMATE_FAN_QUIET; + if (fan <= 50) + return climate::CLIMATE_FAN_LOW; + if (fan <= 70) + return climate::CLIMATE_FAN_MEDIUM; + return climate::CLIMATE_FAN_HIGH; +} + +void PortaSplitClimate::setup() { + // The status interval starts with a random offset of up to 5 s, which also serves as the boot query. + this->set_timeout(10000, [this]() { this->send_capability_queries_(); }); + this->set_interval(STATUS_INTERVAL_MS, [this]() { this->send_status_query_(); }); + this->set_interval(NETWORK_INTERVAL_MS, [this]() { this->send_module_frame_(0x0D, this->net_counter_++, 0x08); }); +} + +void PortaSplitClimate::loop() { + const uint32_t now = App.get_loop_component_start_time(); + uint8_t buf[32]; + size_t n; + while ((n = std::min((size_t) this->available(), sizeof(buf))) != 0 && this->read_array(buf, n)) { + for (size_t i = 0; i < n; i++) + this->rx_feed_(buf[i]); + this->rx_last_byte_ = now; + } + // The line went quiet mid-frame; drop it so its bytes cannot swallow the next one. + if (this->rx_len_ != 0 && now - this->rx_last_byte_ > RX_TIMEOUT_MS) + this->rx_len_ = 0; +} + +void PortaSplitClimate::dump_config() { + ESP_LOGCONFIG(TAG, "Midea PortaSplit:"); + this->dump_traits_(TAG); +} + +climate::ClimateTraits PortaSplitClimate::traits() { + auto traits = climate::ClimateTraits(); + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_visual_min_temperature(MIN_TARGET_TEMP); + traits.set_visual_max_temperature(MAX_TARGET_TEMP); + traits.set_visual_temperature_step(0.5f); + traits.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_DRY, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_FAN_ONLY, + }); + traits.set_supported_fan_modes({ + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + }); + traits.set_supported_swing_modes({ + climate::CLIMATE_SWING_OFF, + climate::CLIMATE_SWING_VERTICAL, + }); + traits.set_supported_presets({ + climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_BOOST, + climate::CLIMATE_PRESET_ECO, + climate::CLIMATE_PRESET_AWAY, // Frost protection (8 degC heating) + }); + return traits; +} + +void PortaSplitClimate::control(const climate::ClimateCall &call) { + if (call.get_mode().has_value()) { + const climate::ClimateMode mode = *call.get_mode(); + this->st_power_ = mode != climate::CLIMATE_MODE_OFF; + if (this->st_power_) + this->st_mode_ = climate_mode_to_midea(mode); + // Frost protection only runs while heating. + if (mode != climate::CLIMATE_MODE_HEAT) + this->st_frost_ = false; + } + if (call.get_target_temperature().has_value()) { + const float target = clamp(*call.get_target_temperature(), MIN_TARGET_TEMP, MAX_TARGET_TEMP); + this->st_target_half_ = (uint8_t) lroundf(target * 2.0f); + } + if (call.get_fan_mode().has_value()) + this->st_fan_ = fan_mode_to_midea(*call.get_fan_mode()); + if (call.get_swing_mode().has_value()) + this->st_swing_ = *call.get_swing_mode() == climate::CLIMATE_SWING_VERTICAL; + if (call.get_preset().has_value()) { + const climate::ClimatePreset preset = *call.get_preset(); + this->st_turbo_ = preset == climate::CLIMATE_PRESET_BOOST; + this->st_sleep_ = preset == climate::CLIMATE_PRESET_SLEEP; + this->st_eco_ = preset == climate::CLIMATE_PRESET_ECO; + this->st_frost_ = preset == climate::CLIMATE_PRESET_AWAY; + if (this->st_frost_) { + this->st_mode_ = climate_mode_to_midea(climate::CLIMATE_MODE_HEAT); + this->st_power_ = true; + } + } + this->send_set_command_(); +} + +void PortaSplitClimate::send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len) { + uint8_t f[FRAME_OVERHEAD + MAX_TX_PAYLOAD] = {0}; + const size_t total = FRAME_OVERHEAD + len; + f[0] = 0xAA; + f[1] = (uint8_t) (total - 1); + f[2] = 0xAC; + // f[3] is the sync byte and must stay 0x00 on the PortaSplit; f[4..7] are reserved. + f[8] = proto; + f[9] = type; + progmem_memcpy(&f[10], payload, len); + 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 +} + +void PortaSplitClimate::send_set_command_() { + uint8_t p[MAX_TX_PAYLOAD] = {0}; + const uint8_t whole = this->st_target_half_ >> 1; + const bool half = this->st_target_half_ & 1; + p[0] = 0x40; + p[1] = 0x42; // 0x02 is mandatory, 0x40 enables the beeper + if (this->st_power_) + p[1] |= 0x01; + p[2] = (uint8_t) ((this->st_mode_ << 5) | (half ? 0x10 : 0x00) | ((whole - 16) & 0x0F)); + p[3] = this->st_fan_; + p[4] = 0x7F; + p[5] = 0x7F; + p[6] = 0xF0; + p[7] = this->st_swing_ ? 0x3C : 0x30; + p[8] = this->st_turbo_ ? 0x20 : 0x00; + p[9] = (uint8_t) ((this->st_ion_ ? 0x20 : 0x00) | (this->st_eco_ ? 0x80 : 0x00)); + p[10] = this->st_sleep_ ? 0x01 : 0x00; + p[18] = (uint8_t) ((whole - 12) & 0x1F); + p[19] = 0x80; + p[21] = 0x0A; + p[22] = this->st_frost_ ? 0x80 : 0x00; + p[23] = this->msg_id_++; + this->send_frame_(0x02, 0x02, p, sizeof(p)); + // Read back the state the AC actually applied. + this->set_timeout(FOLLOWUP_TIMEOUT_ID, FOLLOWUP_DELAY_MS, [this]() { this->send_status_query_(); }); +} + +void PortaSplitClimate::send_status_query_() { + uint8_t p[sizeof(STATUS_QUERY)]; + progmem_memcpy(p, STATUS_QUERY, sizeof(p)); + p[11] = this->msg_id_++; + this->send_frame_(0x00, 0x03, p, sizeof(p)); +} + +void PortaSplitClimate::send_capability_queries_() { + this->send_frame_(0x03, 0x03, CAPS_0, sizeof(CAPS_0)); + this->set_timeout(1000, [this]() { this->send_frame_(0x03, 0x03, CAPS_1, sizeof(CAPS_1)); }); +} + +void PortaSplitClimate::send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags) { + uint8_t f[sizeof(MODULE_FRAME)]; + progmem_memcpy(f, MODULE_FRAME, sizeof(f)); + f[6] = counter; + f[9] = type; + f[25] = wifi_flags; + f[30] = checksum(f, 30); + this->write_array(f, sizeof(f)); +} + +void PortaSplitClimate::rx_feed_(uint8_t b) { + if (this->rx_len_ == 0 && b != 0xAA) + return; + this->rx_[this->rx_len_++] = b; + if (this->rx_len_ < 3) + return; + const size_t total = (size_t) this->rx_[1] + 1; + // Every frame carries the appliance type 0xAC; anything else is a false start. + if (this->rx_len_ == 3 && (total < FRAME_OVERHEAD || total > MAX_FRAME || this->rx_[2] != 0xAC)) { + this->rx_len_ = 0; + return; + } + if (this->rx_len_ < total) + return; + this->rx_len_ = 0; + if (checksum(this->rx_.data(), total - 1) != this->rx_[total - 1]) { + 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 + this->handle_frame_(this->rx_.data(), total); +} + +void PortaSplitClimate::handle_frame_(const uint8_t *f, size_t len) { + if (f[9] == 0x63) { + this->send_module_frame_(0x63, 0, 0x00); + } else if (f[9] == 0x05 && f[10] == 0xB5) { + // The original module acknowledges telemetry pushes by echoing them back. + this->write_array(f, len); + } else if (f[10] == 0xC0) { + this->handle_c0_(f, len); + } +} + +void PortaSplitClimate::handle_c0_(const uint8_t *f, size_t len) { + if (len < 26) + return; + const uint8_t mode = (f[12] >> 5) & 0x07; + const uint8_t target_half = (uint8_t) ((((f[12] & 0x0F) + 16) << 1) | ((f[12] & 0x10) ? 1 : 0)); + const climate::ClimateFanMode fan = midea_fan_to_climate(f[13]); + + this->st_power_ = f[11] & 0x01; + if (mode >= 1 && mode <= 5) + this->st_mode_ = mode; + this->st_target_half_ = target_half; + this->st_fan_ = fan_mode_to_midea(fan); + this->st_swing_ = (f[17] & 0x0F) == 0x0C; + this->st_turbo_ = f[18] & 0x20; + this->st_ion_ = f[19] & 0x20; + this->st_eco_ = f[19] & 0x10; // Set command uses bit 7, readback uses bit 4. + this->st_sleep_ = f[20] & 0x01; + this->st_frost_ = len > 31 && (f[31] & 0x80); + + this->mode = this->st_power_ ? midea_mode_to_climate(this->st_mode_) : climate::CLIMATE_MODE_OFF; + this->target_temperature = target_half / 2.0f; + // 0x00 and 0xFF mean the indoor probe gave no reading. + const uint8_t raw_temp = f[21]; + this->current_temperature = (raw_temp == 0x00 || raw_temp == 0xFF) ? NAN : ((float) raw_temp - 50.0f) / 2.0f; + this->fan_mode = fan; + this->swing_mode = this->st_swing_ ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + if (this->st_frost_) { + this->preset = climate::CLIMATE_PRESET_AWAY; + } else if (this->st_turbo_) { + this->preset = climate::CLIMATE_PRESET_BOOST; + } else if (this->st_sleep_) { + this->preset = climate::CLIMATE_PRESET_SLEEP; + } else if (this->st_eco_) { + this->preset = climate::CLIMATE_PRESET_ECO; + } else { + this->preset = climate::CLIMATE_PRESET_NONE; + } + this->publish_state(); +} + +} // namespace esphome::midea_portasplit diff --git a/esphome/components/midea_portasplit/midea_portasplit.h b/esphome/components/midea_portasplit/midea_portasplit.h new file mode 100644 index 00000000000..22e71a42793 --- /dev/null +++ b/esphome/components/midea_portasplit/midea_portasplit.h @@ -0,0 +1,63 @@ +#pragma once + +// Midea PortaSplit climate component. +// +// The PortaSplit uses sync byte 0x00 for climate frames and silently drops the +// XOR sync byte (LEN ^ 0xAC) frames that the `midea` component sends, which is +// why the stock `midea` platform cannot drive this unit. +// +// Frame: AA | LEN | AC | SYNC | 00 00 00 00 | PROTO | TYPE | PAYLOAD | CRC8 | CS + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/climate/climate.h" + +#include + +namespace esphome::midea_portasplit { + +static constexpr uint8_t FAN_AUTO = 102; + +class PortaSplitClimate : public climate::Climate, public uart::UARTDevice, public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + + climate::ClimateTraits traits() override; + void control(const climate::ClimateCall &call) override; + + protected: + static constexpr size_t MAX_FRAME = 128; + + // The payload may live in PROGMEM. + void send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len); + void send_set_command_(); + void send_status_query_(); + void send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags); + void send_capability_queries_(); + + void rx_feed_(uint8_t b); + void handle_frame_(const uint8_t *f, size_t len); + void handle_c0_(const uint8_t *f, size_t len); + + uint32_t rx_last_byte_{0}; + // Last known device state, mirrored from C0 so a set command does not clobber it. + uint8_t st_target_half_{48}; // Target temperature in half degrees + uint8_t st_mode_{1}; + uint8_t st_fan_{FAN_AUTO}; + bool st_power_ : 1 {false}; + bool st_swing_ : 1 {false}; + bool st_turbo_ : 1 {false}; + bool st_ion_ : 1 {false}; + bool st_sleep_ : 1 {false}; + bool st_eco_ : 1 {false}; + bool st_frost_ : 1 {false}; + + uint8_t msg_id_{1}; + uint8_t net_counter_{0}; + uint8_t rx_len_{0}; + std::array rx_{}; +}; + +} // namespace esphome::midea_portasplit diff --git a/tests/components/midea_portasplit/common.yaml b/tests/components/midea_portasplit/common.yaml new file mode 100644 index 00000000000..8e5c9519479 --- /dev/null +++ b/tests/components/midea_portasplit/common.yaml @@ -0,0 +1,4 @@ +climate: + - platform: midea_portasplit + id: midea_portasplit_unit + name: PortaSplit diff --git a/tests/components/midea_portasplit/test.esp32-idf.yaml b/tests/components/midea_portasplit/test.esp32-idf.yaml new file mode 100644 index 00000000000..844ec272d2c --- /dev/null +++ b/tests/components/midea_portasplit/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea_portasplit: !include common.yaml diff --git a/tests/components/midea_portasplit/test.esp8266-ard.yaml b/tests/components/midea_portasplit/test.esp8266-ard.yaml new file mode 100644 index 00000000000..a84a5a57722 --- /dev/null +++ b/tests/components/midea_portasplit/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + midea_portasplit: !include common.yaml From 7afc2768f84fb68bdd44a9ab2991030fb782fe8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 08:45:51 -1000 Subject: [PATCH 09/19] [esp8266] Check that every component's saved values fit together (#20686) --- esphome/components/esp8266/__init__.py | 11 ++ esphome/components/esp8266/const.py | 87 +++++++++++++- esphome/components/esp8266/crash_handler.cpp | 12 +- esphome/components/esp8266/preferences.cpp | 30 +++-- esphome/components/esp8266/rtc_layout.h | 18 +++ esphome/core/defines.h | 3 + esphome/preferences.py | 14 +++ .../components/esp8266/test_preferences.py | 107 ++++++++++++++++++ 8 files changed, 257 insertions(+), 25 deletions(-) create mode 100644 esphome/components/esp8266/rtc_layout.h create mode 100644 tests/unit_tests/components/esp8266/test_preferences.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index d95cbd239b5..27c6fb1958d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -53,10 +53,14 @@ from .const import ( KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, + PREFERENCES_RTC_EBOOT_WORDS, + PREFERENCES_RTC_NORMAL_WORDS, THROW_STUBS_HEADER, + check_preferences_fit, enable_serial, enable_serial1, esp8266_ns, + preferences_flash_words, ) from .gpio import PinInitialState, add_pin_initial_states_array @@ -467,6 +471,12 @@ async def to_code(config: ConfigType) -> None: # Other projects like Tasmota & ESPEasy also use this cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + cg.add_define("ESP8266_PREFERENCES_RTC_NORMAL_WORDS", PREFERENCES_RTC_NORMAL_WORDS) + cg.add_define("ESP8266_PREFERENCES_RTC_EBOOT_WORDS", PREFERENCES_RTC_EBOOT_WORDS) + cg.add_define( + "ESP8266_PREFERENCES_FLASH_WORDS", + preferences_flash_words(config[CONF_RESTORE_FROM_FLASH]), + ) if config[CONF_RESTORE_FROM_FLASH]: cg.add_define("USE_ESP8266_PREFERENCES_FLASH") @@ -528,6 +538,7 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(add_pin_initial_states_array) CORE.add_job(finalize_waveform_config) CORE.add_job(finalize_serial_config) + CORE.add_job(check_preferences_fit) @coroutine_with_priority(CoroPriority.WORKAROUNDS) diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index fb50b6a5ca2..177bf540473 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -1,9 +1,13 @@ +import logging + import esphome.codegen as cg # Re-exported from the shared definition; here it indexes the BOARDS # metadata dicts, whose entries in boards.py spell the literal. from esphome.const import KEY_FLASH_SIZE # noqa: F401 # pylint: disable=unused-import -from esphome.core import CORE +from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority + +_LOGGER = logging.getLogger(__name__) KEY_ESP8266 = "esp8266" KEY_BOARD = "board" @@ -24,6 +28,87 @@ KEY_LDSCRIPT = "ldscript" # Crash-decode tools, resolved once per run by __init__ KEY_DECODE_TOOLS = "decode_tools" +# Saved-settings space in 4-byte words; preferences.cpp gets these as defines. A value cannot span the two +# RTC regions, and the flash store is smaller when only explicit flash preferences use it. +PREFERENCES_RTC_NORMAL_WORDS = 78 +PREFERENCES_RTC_EBOOT_WORDS = 32 +PREFERENCES_RTC_WORDS = PREFERENCES_RTC_NORMAL_WORDS + PREFERENCES_RTC_EBOOT_WORDS +PREFERENCES_FLASH_WORDS = 128 +PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE = 64 +KEY_PREFERENCES = "preferences" + + +def preferences_flash_words(restore_from_flash: bool) -> int: + """Size of the flash store for this configuration.""" + return ( + PREFERENCES_FLASH_WORDS + if restore_from_flash + else PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE + ) + + +def preference_words(value_bytes: int) -> int: + """Words a saved value takes, checksum word included (rtc_pref_bytes_to_words plus one).""" + return (value_bytes + 3) // 4 + 1 + + +def reserve_preferences( + count: int, value_bytes: int, in_flash: bool | None = None +) -> None: + """Record saved values a component creates; check_preferences_fit() adds them all up. + + in_flash is the value's own storage, as passed to make_preference(); None is the two-argument + form, which follows restore_from_flash. + """ + if in_flash is None: + in_flash = CORE.config[KEY_ESP8266][CONF_RESTORE_FROM_FLASH] + reservations = CORE.data.setdefault(KEY_ESP8266, {}).setdefault(KEY_PREFERENCES, []) + reservations.extend([(preference_words(value_bytes), in_flash)] * count) + + +def _rtc_values_can_split(values: list[int]) -> bool: + """Whether some order fills the two RTC regions without a value spanning them.""" + total = sum(values) + if total <= PREFERENCES_RTC_NORMAL_WORDS: + return True + # Bit i of reachable is set when some subset of the values sums to i + reachable = 1 + for words in values: + reachable |= reachable << words + lowest = max(0, total - PREFERENCES_RTC_EBOOT_WORDS) + highest = min(total, PREFERENCES_RTC_NORMAL_WORDS) + return any(reachable >> normal & 1 for normal in range(lowest, highest + 1)) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def check_preferences_fit() -> None: + """Fail the build when the reserved saved values cannot fit the space preferences.cpp has.""" + flash_store = preferences_flash_words( + CORE.config[KEY_ESP8266][CONF_RESTORE_FROM_FLASH] + ) + reservations = CORE.data[KEY_ESP8266].get(KEY_PREFERENCES, []) + flash_words = sum(w for w, in_flash in reservations if in_flash) + rtc_values = [w for w, in_flash in reservations if not in_flash] + if flash_words > flash_store: + raise EsphomeError( + f"Saved values need {flash_words} words but the ESP8266 flash store has {flash_store}; " + "use fewer or smaller saved values" + ) + if not _rtc_values_can_split(rtc_values): + raise EsphomeError( + f"Saved values need {sum(rtc_values)} words but ESP8266 RTC memory has {PREFERENCES_RTC_WORDS} in " + f"regions of {PREFERENCES_RTC_NORMAL_WORDS} and {PREFERENCES_RTC_EBOOT_WORDS} that a value cannot " + "span; use fewer or smaller saved values, or store them in flash" + ) + if sum(rtc_values) > PREFERENCES_RTC_NORMAL_WORDS: + _LOGGER.warning( + "Saved values reserve %d of %d words of ESP8266 RTC memory; the fit depends on setup order, and " + "saved settings that do not reserve are not counted", + sum(rtc_values), + PREFERENCES_RTC_WORDS, + ) + + # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index 1ba96eaebb2..8513d213155 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -4,6 +4,7 @@ #ifdef USE_ESP8266_CRASH_HANDLER #include "crash_handler.h" +#include "rtc_layout.h" #include "esphome/core/log.h" #include @@ -46,11 +47,8 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t val) { // Recover the actual code address from a windowed-ABI return address on the stack. static inline uint32_t IRAM_ATTR recover_code_addr(uint32_t val) { return (val & XTENSA_ADDR_MASK) | XTENSA_CODE_BASE; } -// RTC user memory layout for crash backtrace data. -// User-accessible RTC memory: blocks 64-191 (each block = 4 bytes). -// We use blocks 174-191 (last 18 blocks, 72 bytes) to minimize conflicts. +// Crash data lives in the last esphome::esp8266::RTC_CRASH_WORDS of user RTC memory (rtc_layout.h). // Store 16 raw candidates, filter to real return addresses at log time. -static constexpr uint8_t RTC_CRASH_BASE = 174; static constexpr size_t MAX_BACKTRACE = 16; // Magic word packs sentinel, version, and count into one uint32_t: @@ -78,7 +76,7 @@ struct RtcCrashData { uint32_t backtrace[MAX_BACKTRACE]; uint32_t epc1; // Fault PC, used to filter duplicates }; -static_assert(sizeof(RtcCrashData) == 72, "RtcCrashData must fit in 18 RTC blocks"); +static_assert(sizeof(RtcCrashData) == esphome::esp8266::RTC_CRASH_WORDS * 4, "RtcCrashData must fill its RTC words"); namespace esphome::esp8266 { @@ -129,7 +127,7 @@ static const LogString *get_reset_reason(uint32_t reason) { // Returns the number of valid backtrace entries (0 if no data found). static uint8_t read_rtc_backtrace(uint32_t *backtrace, size_t max_entries) { RtcCrashData rtc_data; - if (!system_rtc_mem_read(RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data))) + if (!system_rtc_mem_read(esphome::esp8266::RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data))) return 0; uint32_t magic = rtc_data.magic; if ((magic & CRASH_SENTINEL_MASK) != CRASH_SENTINEL || (magic & CRASH_VERSION_MASK) != CRASH_VERSION) @@ -233,7 +231,7 @@ extern "C" void IRAM_ATTR custom_crash_callback(struct rst_info *rst_info, uint3 data.epc1 = epc1; data.magic = CRASH_SENTINEL | CRASH_VERSION | count; - system_rtc_mem_write(RTC_CRASH_BASE, &data, sizeof(data)); + system_rtc_mem_write(esphome::esp8266::RTC_CRASH_BASE, &data, sizeof(data)); } #endif // USE_ESP8266_CRASH_HANDLER diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index b731187f9c3..3c1e89673ea 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -6,6 +6,7 @@ extern "C" { } #include "preferences.h" +#include "rtc_layout.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences_rtc.h" @@ -17,31 +18,26 @@ namespace esphome::esp8266 { ESPHOME_LOG_TAG(TAG, "preferences"); static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; -static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; +static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = RTC_USER_MEM_WORDS; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; -// RTC memory layout: +// RTC memory layout (sizes come from const.py through codegen): // - Eboot region: RTC words 0-31 (reserved, mapped from preference offset 78-109) // - Normal region: RTC words 32-109 (mapped from preference offset 0-77) // - Crash handler: RTC words 110-127 (reserved for crash_handler.cpp backtrace data) -static constexpr uint32_t RTC_EBOOT_REGION_WORDS = 32; // Words 0-31 reserved for eboot -static constexpr uint32_t RTC_NORMAL_REGION_WORDS = 78; // Words 32-109 for normal prefs +static constexpr uint32_t RTC_EBOOT_REGION_WORDS = ESP8266_PREFERENCES_RTC_EBOOT_WORDS; +static constexpr uint32_t RTC_NORMAL_REGION_WORDS = ESP8266_PREFERENCES_RTC_NORMAL_WORDS; static constexpr uint32_t PREF_TOTAL_WORDS = RTC_EBOOT_REGION_WORDS + RTC_NORMAL_REGION_WORDS; // 110 +static_assert(PREF_TOTAL_WORDS <= RTC_CRASH_BASE - RTC_SYSTEM_USER_BASE, + "preference regions overlap the crash handler's RTC words"); // Maximum preference size in words (limited by uint8_t length_words field) static constexpr uint32_t MAX_PREFERENCE_WORDS = 255; #define ESP_RTC_USER_MEM ((uint32_t *) ESP_RTC_USER_MEM_START) -// Flash storage size depends on esp8266 -> restore_from_flash YAML option (default: false). -// When enabled (USE_ESP8266_PREFERENCES_FLASH), all preferences default to flash and need -// 128 words (512 bytes). When disabled, only explicit flash prefs use this storage so -// 64 words (256 bytes) suffices since most preferences go to RTC memory instead. -#ifdef USE_ESP8266_PREFERENCES_FLASH -static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 128; -#else -static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 64; -#endif +// Smaller without restore_from_flash, when only explicit flash preferences live here +static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = ESP8266_PREFERENCES_FLASH_WORDS; static uint32_t s_flash_storage[ESP8266_FLASH_STORAGE_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -60,7 +56,7 @@ static inline bool esp_rtc_user_mem_write(uint32_t index, uint32_t value) { if (index >= ESP_RTC_USER_MEM_SIZE_WORDS) { return false; } - if (index < 32 && s_prevent_write) { + if (index < RTC_EBOOT_REGION_WORDS && s_prevent_write) { return false; } @@ -123,7 +119,7 @@ static bool load_from_rtc(size_t offset, uint32_t *data, size_t len) { // Maximum buffer for any single preference - bounded by storage sizes. // Flash prefs: bounded by ESP8266_FLASH_STORAGE_SIZE (128 or 64 words). -// RTC prefs: bounded by RTC_NORMAL_REGION_WORDS (96) - a single pref can't span both RTC regions. +// RTC prefs: bounded by RTC_NORMAL_REGION_WORDS (78) - a single pref can't span both RTC regions. static constexpr size_t PREF_MAX_BUFFER_WORDS = ESP8266_FLASH_STORAGE_SIZE > RTC_NORMAL_REGION_WORDS ? ESP8266_FLASH_STORAGE_SIZE : RTC_NORMAL_REGION_WORDS; @@ -180,8 +176,8 @@ ESPPreferenceObject ESP8266Preferences::make_preference(size_t length, uint32_t } else { uint32_t start = this->current_offset; bool in_normal = start < RTC_NORMAL_REGION_WORDS; - // Normal: offset 0-95 maps to RTC offset 32-127 - // Eboot: offset 96-127 maps to RTC offset 0-31 + // Normal: offset 0-77 maps to RTC offset 32-109 + // Eboot: offset 78-109 maps to RTC offset 0-31 if (in_normal && start + total_words > RTC_NORMAL_REGION_WORDS) { // start is in normal but end is not -> switch to Eboot this->current_offset = start = RTC_NORMAL_REGION_WORDS; diff --git a/esphome/components/esp8266/rtc_layout.h b/esphome/components/esp8266/rtc_layout.h new file mode 100644 index 00000000000..0320d67a198 --- /dev/null +++ b/esphome/components/esp8266/rtc_layout.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef USE_ESP8266 + +#include + +namespace esphome::esp8266 { + +// User RTC memory is system blocks 64-191, one 4-byte word each. preferences.cpp owns the first +// words, crash_handler.cpp the last; const.py hands preferences.cpp its region sizes. +static constexpr uint32_t RTC_SYSTEM_USER_BASE = 64; +static constexpr uint32_t RTC_USER_MEM_WORDS = 128; +static constexpr uint32_t RTC_CRASH_WORDS = 18; +static constexpr uint32_t RTC_CRASH_BASE = RTC_SYSTEM_USER_BASE + RTC_USER_MEM_WORDS - RTC_CRASH_WORDS; + +} // namespace esphome::esp8266 + +#endif // USE_ESP8266 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3aa4c8e700b..50d8285cc1c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -607,6 +607,9 @@ #define USE_ESP8266_LOGGER_SERIAL #define USE_ESP8266_LOGGER_SERIAL1 #define USE_ESP8266_PREFERENCES_FLASH +#define ESP8266_PREFERENCES_FLASH_WORDS 128 +#define ESP8266_PREFERENCES_RTC_EBOOT_WORDS 32 +#define ESP8266_PREFERENCES_RTC_NORMAL_WORDS 78 #define USE_ESP8266_UART_SERIAL #define USE_ESP8266_UART_SERIAL1 #define USE_HTTP_REQUEST_ESP8266_HTTPS diff --git a/esphome/preferences.py b/esphome/preferences.py index 6e7e4eda33d..c9a465d6252 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -102,3 +102,17 @@ def is_in_flash(value: str) -> bool: if not in_flash: request_rtc_storage() return in_flash + + +def reserve_preferences( + count: int, value_bytes: int, in_flash: bool | None = None +) -> None: + """Reserve saved values from to_code so a platform with a small store can check they all fit. + + in_flash is the value's own storage (is_in_flash(config[CONF_STORAGE])); leave it None for a + component that calls the two-argument make_preference() and takes the platform default. + """ + if CORE.is_esp8266: + from esphome.components.esp8266.const import reserve_preferences as reserve + + reserve(count, value_bytes, in_flash) diff --git a/tests/unit_tests/components/esp8266/test_preferences.py b/tests/unit_tests/components/esp8266/test_preferences.py new file mode 100644 index 00000000000..2111e068245 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_preferences.py @@ -0,0 +1,107 @@ +"""The config-time fit check must agree with make_preference() in preferences.cpp.""" + +import asyncio + +import pytest + +from esphome import preferences +from esphome.components.esp8266.const import ( + KEY_ESP8266, + KEY_PREFERENCES, + check_preferences_fit, + preference_words, + reserve_preferences, +) +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def esp8266_core() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp8266"} + CORE.data[KEY_ESP8266] = {} + CORE.config = {KEY_ESP8266: {"restore_from_flash": False}} + + +def _check() -> None: + asyncio.run(check_preferences_fit()) + + +@pytest.mark.parametrize( + ("value_bytes", "words"), [(0, 1), (1, 2), (4, 2), (5, 3), (94, 25)] +) +def test_preference_words_rounds_up_and_adds_the_checksum( + value_bytes: int, words: int +) -> None: + assert preference_words(value_bytes) == words + + +@pytest.mark.parametrize( + ("restore_from_flash", "in_flash", "fitting", "match"), + [ + # 25 words each: 3 in the 78-word region and 1 in the 32-word one + (False, False, 4, "RTC memory has 110"), + (False, None, 4, "RTC memory has 110"), + # an explicit RTC value stays in RTC whatever restore_from_flash says + (True, False, 4, "RTC memory has 110"), + # 125 of 128 flash words, by the two-argument default or explicitly + (True, None, 5, "flash store has 128"), + (True, True, 5, "flash store has 128"), + # explicit flash values without restore_from_flash use the 64-word store + (False, True, 2, "flash store has 64"), + ], +) +def test_reservations_add_up_against_the_store_they_land_in( + restore_from_flash: bool, in_flash: bool | None, fitting: int, match: str +) -> None: + CORE.config[KEY_ESP8266]["restore_from_flash"] = restore_from_flash + reserve_preferences(fitting - 1, 94, in_flash) # one component + reserve_preferences(1, 94, in_flash) # another component + _check() + reserve_preferences(1, 94, in_flash) + with pytest.raises(EsphomeError, match=match): + _check() + + +def test_values_that_cannot_split_between_the_regions_fail() -> None: + # 5 x 20 words is 100 of 110, but the normal region takes 3 and the eboot region 1 + reserve_preferences(5, 76) + with pytest.raises(EsphomeError, match="cannot span"): + _check() + + +def test_a_value_larger_than_either_region_fails() -> None: + reserve_preferences(1, 400) + with pytest.raises(EsphomeError, match="cannot span"): + _check() + + +def test_a_split_that_works_in_some_order_passes_with_a_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + # 78 + 32: one value fills each region exactly + reserve_preferences(1, 308) + reserve_preferences(1, 124) + _check() + assert "depends on setup order" in caplog.text + + +def test_nothing_reserved_passes_quietly(caplog: pytest.LogCaptureFixture) -> None: + _check() + assert caplog.text == "" + + +def test_the_check_runs_as_a_scheduled_job() -> None: + reserve_preferences(3, 400) + CORE.add_job(check_preferences_fit) + with pytest.raises(EsphomeError, match="cannot span"): + CORE.flush_tasks() + + +def test_components_reserve_through_esphome_preferences() -> None: + preferences.reserve_preferences(2, 94, in_flash=True) + assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(25, True), (25, True)] + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP8266] = {} + preferences.reserve_preferences(2, 94) + assert KEY_PREFERENCES not in CORE.data[KEY_ESP8266] From 163b09a473def4aea19488a7363564bf32a812c4 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 14 Oct 2026 14:47:25 -0400 Subject: [PATCH 10/19] [microphone] Speed up MicrophoneSource audio conversion (#20755) --- .../microphone/microphone_source.cpp | 73 ++++++++----------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/esphome/components/microphone/microphone_source.cpp b/esphome/components/microphone/microphone_source.cpp index 288e9f8c8e5..8ef091d3a40 100644 --- a/esphome/components/microphone/microphone_source.cpp +++ b/esphome/components/microphone/microphone_source.cpp @@ -1,9 +1,20 @@ #include "microphone_source.h" +#include + +#include // esp-audio-libs +#include // esp-audio-libs + namespace esphome::microphone { -static const int32_t Q25_MAX_VALUE = (1 << 25) - 1; -static const int32_t Q25_MIN_VALUE = ~Q25_MAX_VALUE; +// Builds the exact LinearGain for an integer factor without the float dB conversion: the factor is +// mantissa / 2^31 * 2^exponent, with the exponent set to the factor's bit width. +static esp_audio_libs::gain::LinearGain factor_to_linear_gain(uint32_t factor) { + esp_audio_libs::gain::LinearGain gain; + gain.exponent = 32 - __builtin_clz(factor); + gain.mantissa_q31 = static_cast(factor << (31 - gain.exponent)); + return gain; +} audio::AudioStreamInfo MicrophoneSource::get_audio_stream_info() { return audio::AudioStreamInfo(this->bits_per_sample_, this->channels_.count(), @@ -26,50 +37,26 @@ void MicrophoneSource::stop() { } void MicrophoneSource::process_audio_(const std::vector &data, std::vector &filtered_data) { - // - Bit depth conversions are obtained by truncating bits or padding with zeros - no dithering is applied. - // - In the comments, Qxx refers to a fixed point number with xx bits of precision for representing fractional values. - // For example, audio with a bit depth of 16 can store a sample in a int16, which can be considered a Q15 number. - // - All samples are converted to Q25 before applying the gain factor - this results in a small precision loss for - // data with 32 bits per sample. Since the maximum gain factor is 64 = (1<<6), this ensures that applying the gain - // will never overflow a 32 bit signed integer. This still retains more bit depth than what is audibly noticeable. - // - Loops for reading/writing data buffers are unrolled, assuming little endian, for a small performance increase. + const audio::AudioStreamInfo source_info = this->mic_->get_audio_stream_info(); + const uint32_t total_frames = source_info.bytes_to_frames(data.size()); + const uint8_t target_bytes_per_sample = (this->bits_per_sample_ + 7) / 8; - const size_t source_bytes_per_sample = this->mic_->get_audio_stream_info().samples_to_bytes(1); - const uint32_t source_channels = this->mic_->get_audio_stream_info().get_channels(); - - const size_t source_bytes_per_frame = this->mic_->get_audio_stream_info().frames_to_bytes(1); - - const uint32_t total_frames = this->mic_->get_audio_stream_info().bytes_to_frames(data.size()); - const size_t target_bytes_per_sample = (this->bits_per_sample_ + 7) / 8; - const size_t target_bytes_per_frame = target_bytes_per_sample * this->channels_.count(); - - filtered_data.resize(target_bytes_per_frame * total_frames); - - uint8_t *current_data = filtered_data.data(); - - for (uint32_t frame_index = 0; frame_index < total_frames; ++frame_index) { - for (uint32_t channel_index = 0; channel_index < source_channels; ++channel_index) { - if (this->channels_.test(channel_index)) { - // Channel's current sample is included in the target mask. Convert bits per sample, if necessary. - - const uint32_t sample_index = frame_index * source_bytes_per_frame + channel_index * source_bytes_per_sample; - - int32_t sample = audio::unpack_audio_sample_to_q31(&data[sample_index], source_bytes_per_sample); // Q31 - sample >>= 6; // Q31 -> Q25 - - // Apply gain using multiplication - sample *= this->gain_factor_; // Q25 - - // Clamp ``sample`` in case gain multiplication overflows 25 bits - sample = clamp(sample, Q25_MIN_VALUE, Q25_MAX_VALUE); // Q25 - - sample *= (1 << 6); // Q25 -> Q31 - - audio::pack_q31_as_audio_sample(sample, current_data, target_bytes_per_sample); - current_data = current_data + target_bytes_per_sample; - } + // Enabled channels in ascending order, which is their order in each output frame + std::array channel_map; + uint8_t target_channels = 0; + for (size_t channel = 0; channel < this->channels_.size(); ++channel) { + if (this->channels_.test(channel)) { + channel_map[target_channels++] = static_cast(channel); } } + + filtered_data.resize(static_cast(total_frames) * target_bytes_per_sample * target_channels); + + // Applies the gain at full precision before narrowing; samples saturate at full scale, and narrowing rounds + esp_audio_libs::pcm_convert::copy_frames_with_gain(data.data(), filtered_data.data(), source_info.samples_to_bytes(1), + source_info.get_channels(), target_bytes_per_sample, + channel_map.data(), target_channels, + factor_to_linear_gain(this->gain_factor_), total_frames); } } // namespace esphome::microphone From 3fa72e61c60b55a3ba2570edf2dd084baa9aa58f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 08:55:39 -1000 Subject: [PATCH 11/19] [mqtt] Build the mqtt triggers with build_trigger_callback (#20433) --- esphome/components/mqtt/__init__.py | 85 ++++++++-------- esphome/components/mqtt/mqtt_client.cpp | 96 ++++++++----------- esphome/components/mqtt/mqtt_client.h | 90 +++++++++-------- esphome/components/mqtt/mqtt_component.cpp | 8 -- esphome/components/mqtt/mqtt_component.h | 8 +- esphome/core/helpers.h | 35 ++++++- tests/components/core/test_callback.cpp | 92 ++++++++++++++++++ tests/components/mqtt/common-triggers.yaml | 25 +++++ .../mqtt/test-triggers.esp32-idf.yaml | 3 + 9 files changed, 284 insertions(+), 158 deletions(-) create mode 100644 tests/components/core/test_callback.cpp create mode 100644 tests/components/mqtt/common-triggers.yaml create mode 100644 tests/components/mqtt/test-triggers.esp32-idf.yaml diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 826d4e32ef2..fe426827ce6 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -53,7 +53,6 @@ from esphome.const import ( CONF_SUBSCRIBE_QOS, CONF_TOPIC, CONF_TOPIC_PREFIX, - CONF_TRIGGER_ID, CONF_USE_ABBREVIATIONS, CONF_USERNAME, CONF_WILL_MESSAGE, @@ -119,18 +118,6 @@ MQTTMessage = mqtt_ns.struct("MQTTMessage") MQTTClientDisconnectReason = mqtt_ns.enum("MQTTClientDisconnectReason") MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component) MQTTPublishJsonAction = mqtt_ns.class_("MQTTPublishJsonAction", automation.Action) -MQTTMessageTrigger = mqtt_ns.class_( - "MQTTMessageTrigger", automation.Trigger.template(cg.std_string), cg.Component -) -MQTTJsonMessageTrigger = mqtt_ns.class_( - "MQTTJsonMessageTrigger", automation.Trigger.template(cg.JsonObjectConst) -) -MQTTConnectTrigger = mqtt_ns.class_( - "MQTTConnectTrigger", automation.Trigger.template(cg.bool_) -) -MQTTDisconnectTrigger = mqtt_ns.class_( - "MQTTDisconnectTrigger", automation.Trigger.template(MQTTClientDisconnectReason) -) MQTTComponent = mqtt_ns.class_("MQTTComponent", cg.Component) MQTTAlarmControlPanelComponent = mqtt_ns.class_( @@ -229,7 +216,6 @@ JSON_MESSAGE_AUTOMATION_SCHEMA = automation.validate_synchronous_automation( "on_json_message", "the parsed JSON document", { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTJsonMessageTrigger), cv.Required(CONF_TOPIC): cv.subscribe_topic, cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, }, @@ -295,21 +281,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_REBOOT_TIMEOUT, default="15min" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_CONNECT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTConnectTrigger), - } - ), - cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - MQTTDisconnectTrigger - ), - } - ), + cv.Optional(CONF_ON_CONNECT): automation.validate_automation(), + cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(), cv.Optional(CONF_ON_MESSAGE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTMessageTrigger), cv.Required(CONF_TOPIC): cv.subscribe_topic, cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, cv.Optional(CONF_PAYLOAD): cv.string_strict, @@ -346,6 +321,18 @@ def exp_mqtt_message(config): ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_CONNECT, "set_on_connect", [(cg.bool_, "session_present")] + ), + automation.CallbackAutomation( + CONF_ON_DISCONNECT, + "set_on_disconnect", + [(MQTTClientDisconnectReason, "reason")], + ), +) + + @coroutine_with_priority(CoroPriority.WEB) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -460,29 +447,37 @@ async def to_code(config): cg.add_define("USE_MQTT_IDF_ENQUEUE") # end esp-idf + # The client queues subscriptions until it connects, so they can be made at construction. for conf in config.get(CONF_ON_MESSAGE, []): - trig = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf[CONF_TOPIC]) - cg.add(trig.set_qos(conf[CONF_QOS])) - if CONF_PAYLOAD in conf: - cg.add(trig.set_payload(conf[CONF_PAYLOAD])) - await cg.register_component(trig, conf) - await automation.build_automation(trig, [(cg.std_string, "x")], conf) + callback = await automation.build_trigger_callback( + [(cg.std_string, "x")], + conf, + params=[(cg.std_string, "topic"), (cg.std_string, "payload")], + forward=["payload"], + # The length is compared first; the literal stays in flash on ESP8266. + when=automation.ApplyCall( + "StringRef(payload) == {}", + ((CONF_PAYLOAD, cg.std_string, automation.string_ref_literal),), + ), + ) + cg.add( + var.subscribe(cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS]) + ) for conf in config.get(CONF_ON_JSON_MESSAGE, []): - trig = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf[CONF_TOPIC], conf[CONF_QOS]) - await automation.build_automation(trig, [(cg.JsonObjectConst, "x")], conf) - - for conf in config.get(CONF_ON_CONNECT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.bool_, "session_present")], conf + callback = await automation.build_trigger_callback( + [(cg.JsonObjectConst, "x")], + conf, + params=[(cg.std_string, "topic"), (cg.JsonObject, "root")], + forward=["root"], + ) + cg.add( + var.subscribe_json( + cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS] + ) ) - for conf in config.get(CONF_ON_DISCONNECT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(MQTTClientDisconnectReason, "reason")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_publish_nan_as_none(config[CONF_PUBLISH_NAN_AS_NONE])) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 6e5fad3efc7..803f486333a 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -461,34 +461,18 @@ void MQTTClientComponent::resubscribe_subscriptions_() { } } -void MQTTClientComponent::subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos) { - MQTTSubscription subscription{ - .topic = topic, +void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, + uint8_t qos) { + auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{ + .topic = std::move(topic), .qos = qos, - .callback = std::move(callback), .subscribed = false, + .boxed = boxed, + .removed = false, + .callback = callback, .resubscribe_timeout = 0, - }; + }); this->resubscribe_subscription_(&subscription); - this->subscriptions_.push_back(subscription); -} - -void MQTTClientComponent::subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos) { - auto f = [callback](const std::string &topic, const std::string &payload) { - json::parse_json(payload, [topic, callback](JsonObject root) -> bool { - callback(topic, root); - return true; - }); - }; - MQTTSubscription subscription{ - .topic = topic, - .qos = qos, - .callback = f, - .subscribed = false, - .resubscribe_timeout = 0, - }; - this->resubscribe_subscription_(&subscription); - this->subscriptions_.push_back(subscription); } void MQTTClientComponent::unsubscribe(const std::string &topic) { @@ -502,10 +486,25 @@ void MQTTClientComponent::unsubscribe(const std::string &topic) { this->status_momentary_warning(1000); } - auto it = subscriptions_.begin(); - while (it != subscriptions_.end()) { - if (it->topic == topic) { - it = subscriptions_.erase(it); + for (auto &subscription : this->subscriptions_) { + if (subscription.topic == topic) + subscription.removed = true; + } + // A callback may be running; the outermost dispatch erases them once it returns. + if (this->dispatching_) { + this->removed_while_dispatching_ = true; + } else { + this->erase_removed_subscriptions_(); + } +} + +void MQTTClientComponent::erase_removed_subscriptions_() { + auto it = this->subscriptions_.begin(); + while (it != this->subscriptions_.end()) { + if (it->removed) { + if (it->boxed) + it->callback.free_boxed(); + it = this->subscriptions_.erase(it); } else { ++it; } @@ -660,9 +659,21 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string // in simple tests but will cause crashes with complex automations. this->defer([this, topic, payload]() { #endif - for (auto &subscription : this->subscriptions_) { - if (topic_match(topic.c_str(), subscription.topic.c_str())) - subscription.callback(topic, payload); + // Indexed with the count taken up front: a callback may subscribe, which can reallocate the + // vector, and the new entry waits for the next message. + // Restored rather than cleared, so only the outermost dispatch erases what was unsubscribed meanwhile. + const bool was_dispatching = this->dispatching_; + this->dispatching_ = true; + const size_t count = this->subscriptions_.size(); + for (size_t i = 0; i < count; i++) { + const auto &subscription = this->subscriptions_[i]; + if (!subscription.removed && topic_match(topic.c_str(), subscription.topic.c_str())) + subscription.callback.call(topic, payload); + } + this->dispatching_ = was_dispatching; + if (!was_dispatching && this->removed_while_dispatching_) { + this->removed_while_dispatching_ = false; + this->erase_removed_subscriptions_(); } #ifdef USE_ESP8266 }); @@ -762,29 +773,6 @@ void MQTTClientComponent::set_on_disconnect(mqtt_on_disconnect_callback_t &&call MQTTClientComponent *global_mqtt_client = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -// MQTTMessageTrigger -MQTTMessageTrigger::MQTTMessageTrigger(std::string topic) : topic_(std::move(topic)) {} -void MQTTMessageTrigger::setup() { - global_mqtt_client->subscribe( - this->topic_, - [this](const std::string &topic, const std::string &payload) { - if (this->payload_.has_value() && payload != *this->payload_) { - return; - } - - this->trigger(payload); - }, - this->qos_); -} -void MQTTMessageTrigger::dump_config() { - ESP_LOGCONFIG(TAG, - "MQTT Message Trigger:\n" - " Topic: '%s'\n" - " QoS: %u", - this->topic_.c_str(), this->qos_); -} -float MQTTMessageTrigger::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } - } // namespace esphome::mqtt #endif // USE_MQTT diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index ced9c84e100..30edb812975 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -31,19 +31,24 @@ namespace esphome::mqtt { using mqtt_on_connect_callback_t = std::function; using mqtt_on_disconnect_callback_t = std::function; -/** Callback for MQTT subscriptions. - * - * First parameter is the topic, the second one is the payload. - */ -using mqtt_callback_t = std::function; -using mqtt_json_callback_t = std::function; +/// Stored callback of an MQTT subscription: the topic, then the payload. +using mqtt_subscription_callback_t = Callback; + +// Remove before 2027.5.0 +using mqtt_callback_t ESPDEPRECATED("subscribe() takes any callable; pass the lambda directly. Removed in 2027.5.0", + "2026.11.0") = std::function; +using mqtt_json_callback_t ESPDEPRECATED( + "subscribe_json() takes any callable; pass the lambda directly. Removed in 2027.5.0", + "2026.11.0") = std::function; /// internal struct for MQTT subscriptions. struct MQTTSubscription { std::string topic; uint8_t qos; - mqtt_callback_t callback; bool subscribed; + bool boxed; // made by create_boxed(), freed on unsubscribe + bool removed; // unsubscribed while a callback ran, erased once dispatch returns + mqtt_subscription_callback_t callback; uint32_t resubscribe_timeout; }; @@ -165,12 +170,21 @@ class MQTTClientComponent final : public Component { bool is_log_message_enabled() const; /** Subscribe to an MQTT topic and call callback when a message is received. + * + * A callback that fits in a pointer (capture at most `this`) is stored inline; a larger one is + * boxed on the heap and freed by unsubscribe(). * * @param topic The topic. Wildcards are currently not supported. * @param callback The callback function. * @param qos The QoS of this subscription. */ - void subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos = 0); + template void subscribe(std::string topic, F &&callback, uint8_t qos = 0) { + constexpr bool boxed = !mqtt_subscription_callback_t::fits_inline(); + this->add_subscription_(std::move(topic), + boxed ? mqtt_subscription_callback_t::create_boxed(std::forward(callback)) + : mqtt_subscription_callback_t::create(std::forward(callback)), + boxed, qos); + } /** Subscribe to a MQTT topic and automatically parse JSON payload. * @@ -181,11 +195,21 @@ class MQTTClientComponent final : public Component { * received. * @param qos The QoS of this subscription. */ - void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); + template void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) { + static_assert(std::is_invocable_v &, const std::string &, JsonObject>, + "subscribe_json() needs a callback that does not mutate its captures"); + this->subscribe( + std::move(topic), + [cb = std::forward(callback)](const std::string &topic, const std::string &payload) { + call_json(cb, topic, payload); + }, + qos); + } /** Unsubscribe from an MQTT topic. * * If multiple existing subscriptions to the same topic exist, all of them will be removed. + * From inside a subscription callback they stop receiving messages at once and are erased once it returns. * * @param topic The topic to unsubscribe from. * Must match the topic in the original subscribe or subscribe_json call exactly. @@ -285,6 +309,14 @@ class MQTTClientComponent final : public Component { bool subscribe_(const char *topic, uint8_t qos); void resubscribe_subscription_(MQTTSubscription *sub); + template static void call_json(const F &callback, const std::string &topic, const std::string &payload) { + JsonDocument doc = json::parse_json(payload); + if (doc.overflowed() || doc.isNull()) + return; + callback(topic, doc.as()); + } + void erase_removed_subscriptions_(); + void add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos); void resubscribe_subscriptions_(); MQTTCredentials credentials_; @@ -336,48 +368,12 @@ class MQTTClientComponent final : public Component { bool publish_nan_as_none_{false}; bool wait_for_connection_{false}; + bool dispatching_{false}; + bool removed_while_dispatching_{false}; }; extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class MQTTMessageTrigger final : public Trigger, public Component { - public: - explicit MQTTMessageTrigger(std::string topic); - - void set_qos(uint8_t qos) { this->qos_ = qos; } - void set_payload(const std::string &payload) { this->payload_ = payload; } - void setup() override; - void dump_config() override; - float get_setup_priority() const override; - - protected: - std::string topic_; - uint8_t qos_{0}; - optional payload_; -}; - -class MQTTJsonMessageTrigger final : public Trigger { - public: - explicit MQTTJsonMessageTrigger(const std::string &topic, uint8_t qos) { - global_mqtt_client->subscribe_json( - topic, [this](const std::string &topic, JsonObject root) { this->trigger(root); }, qos); - } -}; - -class MQTTConnectTrigger final : public Trigger { - public: - explicit MQTTConnectTrigger(MQTTClientComponent *client) { - client->set_on_connect([this](bool session_present) { this->trigger(session_present); }); - } -}; - -class MQTTDisconnectTrigger final : public Trigger { - public: - explicit MQTTDisconnectTrigger(MQTTClientComponent *client) { - client->set_on_disconnect([this](MQTTClientDisconnectReason reason) { this->trigger(reason); }); - } -}; - template class MQTTPublishJsonAction final : public Action { public: MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {} diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index adb5a958287..7e4b8be725d 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -342,14 +342,6 @@ bool MQTTComponent::is_discovery_enabled() const { return this->discovery_enabled_ && global_mqtt_client->is_discovery_enabled(); } -void MQTTComponent::subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos) { - global_mqtt_client->subscribe(topic, std::move(callback), qos); -} - -void MQTTComponent::subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos) { - global_mqtt_client->subscribe_json(topic, callback, qos); -} - MQTTComponent::MQTTComponent() = default; float MQTTComponent::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 141b5f12b38..461db5d3e76 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -259,7 +259,9 @@ class MQTTComponent : public Component { * @param callback The callback that will be called when a message with matching topic is received. * @param qos The MQTT quality of service. Defaults to 0. */ - void subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos = 0); + template void subscribe(std::string topic, F &&callback, uint8_t qos = 0) { + global_mqtt_client->subscribe(std::move(topic), std::forward(callback), qos); + } /** Subscribe to a MQTT topic and automatically parse JSON payload. * @@ -270,7 +272,9 @@ class MQTTComponent : public Component { * received. * @param qos The MQTT quality of service. Defaults to 0. */ - void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); + template void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) { + global_mqtt_client->subscribe_json(std::move(topic), std::forward(callback), qos); + } protected: static constexpr uint32_t SEND_DEFER_ID = 0; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 15f247cadbc..e628b02ac94 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1725,7 +1725,8 @@ enum class TemperatureUnit : uint8_t { /// No null check, no exceptions, no heap allocation for small trivially-copyable callables. /// /// With C++20 if constexpr, automatically detects [this] lambdas (sizeof <= sizeof(void*), -/// trivially copyable) and stores them inline. Larger callables are heap-allocated. +/// trivially copyable, callable as const) and stores them inline. Larger callables, and mutable +/// ones whose state must survive between calls, are heap-allocated. template struct Callback; template struct Callback { @@ -1740,11 +1741,19 @@ template struct Callback { /// Invoke the callback. Only valid on Callbacks created via create(), never on default-constructed instances. void call(Ts... args) const { this->fn_(this->ctx_, std::forward(args)...); } + /// Whether create() stores F inline in the ctx pointer. The inline path invokes a copy, so a + /// callable that mutates its captures (a mutable lambda) is kept whole on the heap instead. + template static constexpr bool fits_inline() { + using DecayF = std::decay_t; + return sizeof(DecayF) <= sizeof(void *) && std::is_trivially_copyable_v && + std::is_invocable_v; + } + /// Create from any callable. Small trivially-copyable callables (like [this] lambdas) /// are stored inline in the ctx pointer without heap allocation. template static Callback create(F &&callable) { using DecayF = std::decay_t; - if constexpr (sizeof(DecayF) <= sizeof(void *) && std::is_trivially_copyable_v) { + if constexpr (fits_inline()) { // Small trivial callable (e.g. [this]() { this->method(); }) - store inline in ctx. // Safe under C++20 (P0593R6): byte copy into aligned storage implicitly // creates objects of implicit-lifetime types (trivially copyable qualifies). @@ -1767,6 +1776,28 @@ template struct Callback { return {[](void *c, Ts... args) { (*static_cast(c))(args...); }, static_cast(stored)}; } } + + /// Heap home of a callable stored by create_boxed(); the header knows how to free it. + struct Box { + void (*free)(Box *box); + }; + + /// Store any callable on the heap with a deleter, for an owner that can later free_boxed() it. + template static Callback create_boxed(F &&callable) { + struct Boxed : Box { + std::decay_t fn; + }; + auto *box = new Boxed{{[](Box *b) { delete static_cast(b); }}, // NOLINT(cppcoreguidelines-owning-memory) + std::forward(callable)}; + return {[](void *c, Ts... args) { static_cast(static_cast(c))->fn(args...); }, + static_cast(box)}; + } + + /// Free the callable of a Callback made by create_boxed(); the Callback must not be called afterwards. + void free_boxed() const { + auto *box = static_cast(this->ctx_); + box->free(box); + } }; /// Grow a CallbackManager's backing array to exactly size+1. Defined in helpers.cpp. diff --git a/tests/components/core/test_callback.cpp b/tests/components/core/test_callback.cpp new file mode 100644 index 00000000000..7cf969734ad --- /dev/null +++ b/tests/components/core/test_callback.cpp @@ -0,0 +1,92 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +using IntCallback = Callback; + +// Counts destructions of the copies a callable carries, to check a boxed capture is freed exactly once. +struct DestroyCounter { + int *destroyed; + bool live{true}; + explicit DestroyCounter(int *count) : destroyed(count) {} + DestroyCounter(const DestroyCounter &other) = delete; + DestroyCounter(DestroyCounter &&other) noexcept : destroyed(other.destroyed) { other.live = false; } + DestroyCounter &operator=(const DestroyCounter &other) = delete; + DestroyCounter &operator=(DestroyCounter &&other) = delete; + ~DestroyCounter() { + if (this->live) + (*this->destroyed)++; + } +}; + +template bool fits_inline(const F & /*callable*/) { return IntCallback::fits_inline(); } + +TEST(CallbackFitsInline, PointerSizedConstCallableFits) { + int value = 0; + EXPECT_TRUE(fits_inline([&value](int x) { value = x; })); +} + +TEST(CallbackFitsInline, MutableCallableDoesNotFit) { + int calls = 0; + EXPECT_FALSE(fits_inline([calls](int) mutable { calls++; })); +} + +TEST(CallbackFitsInline, LargeCallableDoesNotFit) { + int a = 0; + int b = 0; + EXPECT_FALSE(fits_inline([&a, &b](int x) { a = b = x; })); +} + +TEST(CallbackCreate, InlineCallableIsCalled) { + int value = 0; + auto cb = IntCallback::create([&value](int x) { value = x; }); + cb.call(42); + EXPECT_EQ(value, 42); +} + +int mutable_seen = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// A pointer-sized mutable lambda used to be stored inline and run on a copy, losing its state. +TEST(CallbackCreate, MutableStateIsKeptAcrossCalls) { + auto counter = [n = 0](int x) mutable { mutable_seen = (n += x); }; + static_assert(sizeof(counter) <= sizeof(void *) && std::is_trivially_copyable_v); + // create() never frees its heap path; held for the whole run like a callback registered in setup(). + static auto cb = IntCallback::create(counter); + cb.call(1); + cb.call(2); + EXPECT_EQ(mutable_seen, 3); +} + +TEST(CallbackCreateBoxed, MutableStateIsKeptAcrossCalls) { + int seen = 0; + auto cb = IntCallback::create_boxed([count = 0, &seen](int x) mutable { + count += x; + seen = count; + }); + cb.call(1); + cb.call(2); + cb.call(3); + EXPECT_EQ(seen, 6); + cb.free_boxed(); +} + +TEST(CallbackCreateBoxed, FreeDestroysCaptureExactlyOnce) { + int destroyed = 0; + int calls = 0; + { + auto cb = IntCallback::create_boxed([counter = DestroyCounter(&destroyed), &calls](int) { calls++; }); + EXPECT_EQ(destroyed, 0); + cb.call(0); + cb.call(0); + EXPECT_EQ(destroyed, 0); + cb.free_boxed(); + EXPECT_EQ(destroyed, 1); + } + // The Callback itself owns nothing, so going out of scope frees nothing more + EXPECT_EQ(destroyed, 1); + EXPECT_EQ(calls, 2); +} + +} // namespace esphome::core::testing diff --git a/tests/components/mqtt/common-triggers.yaml b/tests/components/mqtt/common-triggers.yaml new file mode 100644 index 00000000000..3bb22c7c53c --- /dev/null +++ b/tests/components/mqtt/common-triggers.yaml @@ -0,0 +1,25 @@ +# on_message with a payload filter, a subscription callback larger than a pointer with its unsubscribe, +# a callback that unsubscribes itself, and a mutable callback, which keeps its state instead of being copied per call. +mqtt: + id: mqtt_client + on_message: + - topic: livingroom/ota_mode + payload: "ON" + qos: 1 + then: + - logger.log: Got livingroom/ota_mode ON + +esphome: + on_boot: + - lambda: |- + std::string tag = "boxed"; + id(mqtt_client).subscribe("some/topic/boxed", [tag](const std::string &topic, const std::string &payload) { + ESP_LOGD("test", "%s %s", tag.c_str(), payload.c_str()); + }); + id(mqtt_client).unsubscribe("some/topic/boxed"); + id(mqtt_client).subscribe("some/topic/once", [](const std::string &topic, const std::string &payload) { + id(mqtt_client).unsubscribe(topic); + }); + id(mqtt_client).subscribe("some/topic/counted", [n = 0](const std::string &topic, const std::string &payload) mutable { + ESP_LOGD("test", "message %d: %s", ++n, payload.c_str()); + }); diff --git a/tests/components/mqtt/test-triggers.esp32-idf.yaml b/tests/components/mqtt/test-triggers.esp32-idf.yaml new file mode 100644 index 00000000000..b34863e8737 --- /dev/null +++ b/tests/components/mqtt/test-triggers.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + common: !include common.yaml + triggers: !include common-triggers.yaml From 15fb7f775e67cf500707de098597de4ef660a56c Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 14 Oct 2026 20:59:21 +0200 Subject: [PATCH 12/19] [tmc2209] Add chopper and PWM tuning actions (#20753) --- esphome/components/tmc22xx/__init__.py | 77 ++++++++++++++++++++++++++ esphome/components/tmc22xx/tmc22xx.cpp | 7 +++ esphome/components/tmc22xx/tmc22xx.h | 24 ++++++++ tests/components/tmc2209/common.yaml | 15 +++++ 4 files changed, 123 insertions(+) diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index da760dde2c5..82ac29c7d5b 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -24,7 +24,9 @@ CONF_CLOCK_FREQUENCY = "clock_frequency" CONF_DIAG_PIN = "diag_pin" CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" CONF_ENN_PIN = "enn_pin" +CONF_HEND = "hend" CONF_HOLD_CURRENT = "hold_current" +CONF_HSTRT = "hstrt" CONF_IHOLD = "ihold" CONF_IHOLDDELAY = "iholddelay" CONF_INDEX_PIN = "index_pin" @@ -34,9 +36,18 @@ CONF_IRUN = "irun" CONF_MICROSTEPS = "microsteps" CONF_ON_STATUS = "on_status" CONF_OTTRIM = "ottrim" +CONF_PWM_AUTOGRAD = "pwm_autograd" +CONF_PWM_AUTOSCALE = "pwm_autoscale" +CONF_PWM_FREQ = "pwm_freq" +CONF_PWM_GRAD = "pwm_grad" +CONF_PWM_LIM = "pwm_lim" +CONF_PWM_OFS = "pwm_ofs" +CONF_PWM_REG = "pwm_reg" CONF_RSENSE = "rsense" CONF_RUN_CURRENT = "run_current" CONF_STANDSTILL_MODE = "standstill_mode" +CONF_TBL = "tbl" +CONF_TOFF = "toff" CONF_TPOWERDOWN = "tpowerdown" CONF_TPWM_THRESHOLD = "tpwm_threshold" CONF_VSENSE = "vsense" @@ -110,6 +121,29 @@ def _validate_analog_current_scale(config: ConfigType) -> ConfigType: return config +def _validate_chopconf(config: ConfigType) -> ConfigType: + # Only values given as constants can be checked here, not lambdas + toff, tbl = config.get(CONF_TOFF), config.get(CONF_TBL) + # TBL is never written at setup, so the chip may still hold a blank time below 2 + if toff == 1 and (tbl is None or (isinstance(tbl, int) and tbl < 2)): + raise cv.Invalid( + f"'{CONF_TOFF}: 1' needs '{CONF_TBL}' set to 2 or higher in the same " + "action", + path=[CONF_TOFF], + ) + hend, hstrt = config.get(CONF_HEND), config.get(CONF_HSTRT) + # Register values are offsets: HEND 0-15 is -3 to 12, HSTRT 0-7 is 1 to 8 + if isinstance(hend, int) and isinstance(hstrt, int): + hysteresis = (hend - 3) + (hstrt + 1) + if hysteresis > 16: + raise cv.Invalid( + f"'{CONF_HEND}' and '{CONF_HSTRT}' give a hysteresis of " + f"{hysteresis}, the limit is 16", + path=[CONF_HEND], + ) + return config + + def final_validate(config: ConfigType) -> ConfigType: """Reject two drivers of the family with the same address on one UART bus.""" for other in fv.full_config.get().get("stepper", []): @@ -225,3 +259,46 @@ def register_actions(domain: str, cls: MockObjClass) -> None: CONF_STANDSTILL_MODE, "set_standstill_mode", StandstillMode ), ) + + automation.register_apply_action( + f"{domain}.chopconf", + cv.All( + cv.Schema( + { + **id_schema, + cv.Optional(CONF_TOFF): cv.templatable(cv.int_range(1, 15)), + cv.Optional(CONF_TBL): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_HEND): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_HSTRT): cv.templatable(cv.int_range(0, 7)), + } + ), + _validate_chopconf, + ), + automation.ApplyField(CONF_TOFF, "set_toff", cg.uint8), + automation.ApplyField(CONF_TBL, "set_tbl", cg.uint8), + automation.ApplyField(CONF_HEND, "set_hend", cg.uint8), + automation.ApplyField(CONF_HSTRT, "set_hstrt", cg.uint8), + ) + + automation.register_apply_action( + f"{domain}.pwmconf", + cv.Schema( + { + **id_schema, + cv.Optional(CONF_PWM_OFS): cv.templatable(cv.uint8_t), + cv.Optional(CONF_PWM_GRAD): cv.templatable(cv.uint8_t), + cv.Optional(CONF_PWM_FREQ): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_PWM_AUTOSCALE): cv.templatable(cv.boolean), + cv.Optional(CONF_PWM_AUTOGRAD): cv.templatable(cv.boolean), + cv.Optional(CONF_PWM_REG): cv.templatable(cv.int_range(1, 15)), + cv.Optional(CONF_PWM_LIM): cv.templatable(cv.int_range(0, 15)), + } + ), + automation.ApplyField(CONF_PWM_OFS, "set_pwm_ofs", cg.uint8), + automation.ApplyField(CONF_PWM_GRAD, "set_pwm_grad", cg.uint8), + automation.ApplyField(CONF_PWM_FREQ, "set_pwm_freq", cg.uint8), + automation.ApplyField(CONF_PWM_AUTOSCALE, "set_pwm_autoscale", cg.bool_), + automation.ApplyField(CONF_PWM_AUTOGRAD, "set_pwm_autograd", cg.bool_), + automation.ApplyField(CONF_PWM_REG, "set_pwm_reg", cg.uint8), + automation.ApplyField(CONF_PWM_LIM, "set_pwm_lim", cg.uint8), + ) diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 2aed97bea10..7bb756819bd 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -362,6 +362,13 @@ void TMC22XXStepper::set_enabled(bool enabled) { this->enabled_ = enabled; } +void TMC22XXStepper::set_toff(uint8_t toff) { + this->toff_ = toff; + // Without an ENN pin, TOFF=0 is what keeps the outputs disabled + if (this->enabled_ || this->enn_pin_ != nullptr) + this->write_field(TOFF, toff); +} + void TMC22XXStepper::set_microsteps(uint16_t microsteps) { for (uint8_t mres = 0; mres <= 8; mres++) { if ((256u >> mres) == microsteps) { diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 19e4813b70d..2e8644de53a 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -57,12 +57,22 @@ static constexpr RegisterField TPOWERDOWN{REG_TPOWERDOWN, 0, 8, false}; static constexpr RegisterField TPWMTHRS{REG_TPWMTHRS, 0, 20, false}; static constexpr RegisterField VACTUAL{REG_VACTUAL, 0, 24, true}; static constexpr RegisterField TOFF{REG_CHOPCONF, 0, 4, false}; +static constexpr RegisterField HSTRT{REG_CHOPCONF, 4, 3, false}; +static constexpr RegisterField HEND{REG_CHOPCONF, 7, 4, false}; +static constexpr RegisterField TBL{REG_CHOPCONF, 15, 2, false}; static constexpr RegisterField VSENSE{REG_CHOPCONF, 17, 1, false}; static constexpr RegisterField MRES{REG_CHOPCONF, 24, 4, false}; static constexpr RegisterField INTPOL{REG_CHOPCONF, 28, 1, false}; static constexpr RegisterField DEDGE{REG_CHOPCONF, 29, 1, false}; static constexpr RegisterField CS_ACTUAL{REG_DRV_STATUS, 16, 5, false}; +static constexpr RegisterField PWM_OFS{REG_PWMCONF, 0, 8, false}; +static constexpr RegisterField PWM_GRAD{REG_PWMCONF, 8, 8, false}; +static constexpr RegisterField PWM_FREQ{REG_PWMCONF, 16, 2, false}; +static constexpr RegisterField PWM_AUTOSCALE{REG_PWMCONF, 18, 1, false}; +static constexpr RegisterField PWM_AUTOGRAD{REG_PWMCONF, 19, 1, false}; static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; +static constexpr RegisterField PWM_REG{REG_PWMCONF, 24, 4, false}; +static constexpr RegisterField PWM_LIM{REG_PWMCONF, 28, 4, false}; static constexpr RegisterField PWM_SCALE_SUM{REG_PWM_SCALE, 0, 8, false}; static constexpr RegisterField PWM_SCALE_AUTO{REG_PWM_SCALE, 16, 9, true}; static constexpr RegisterField PWM_OFS_AUTO{REG_PWM_AUTO, 0, 8, false}; @@ -160,6 +170,20 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U void set_iholddelay(uint8_t delay) { this->write_field(IHOLDDELAY, delay); } void set_tpowerdown(uint8_t tpowerdown) { this->write_field(TPOWERDOWN, tpowerdown); } void set_standstill_mode(StandstillMode mode) { this->write_field(FREEWHEEL, mode); } + + /// Chopper off time (1-15). TOFF=0 is used to disable the outputs, so this is applied on the next enable. + void set_toff(uint8_t toff); + void set_tbl(uint8_t tbl) { this->write_field(TBL, tbl); } + void set_hend(uint8_t hend) { this->write_field(HEND, hend); } + void set_hstrt(uint8_t hstrt) { this->write_field(HSTRT, hstrt); } + + void set_pwm_ofs(uint8_t ofs) { this->write_field(PWM_OFS, ofs); } + void set_pwm_grad(uint8_t grad) { this->write_field(PWM_GRAD, grad); } + void set_pwm_freq(uint8_t freq) { this->write_field(PWM_FREQ, freq); } + void set_pwm_autoscale(bool enable) { this->write_field(PWM_AUTOSCALE, enable); } + void set_pwm_autograd(bool enable) { this->write_field(PWM_AUTOGRAD, enable); } + void set_pwm_reg(uint8_t reg) { this->write_field(PWM_REG, reg); } + void set_pwm_lim(uint8_t lim) { this->write_field(PWM_LIM, lim); } /// Convert a current scale (0-31) to A RMS, without analog current scale. float scale_to_current(uint8_t scale); diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index c39d3fd8ba2..8714db23f42 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -78,6 +78,21 @@ esphome: seup: 1 sedn: 0 seimin: true + - tmc2209.chopconf: + id: tmc2209_step_dir + toff: 4 + tbl: 2 + hend: 0 + hstrt: !lambda "return 5;" + - tmc2209.pwmconf: + id: tmc2209_serial + pwm_ofs: 36 + pwm_grad: 14 + pwm_freq: 1 + pwm_autoscale: true + pwm_autograd: true + pwm_reg: 4 + pwm_lim: 12 - tmc2209.enable: tmc2209_serial - stepper.set_target: id: tmc2209_serial From c5a624a1f2e9be7985bd03bd81ae8c3f0eb36568 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:21:35 -0400 Subject: [PATCH 13/19] [tests] Count OTA key probe retries on a fake clock (#20761) --- tests/unit_tests/test_espota2_noise.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 1adace67395..f447e03986e 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -858,16 +858,23 @@ def test_probe_ota_key_resolution_failure( ) -> None: """A name that never resolves is retried until the deadline, then reported.""" pytest.importorskip("aioesphomeapi.noise") + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + with ( patch("esphome.espota2.resolve_ip_address", **kwargs) as resolve, - patch("time.sleep"), + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), caplog.at_level(logging.WARNING), ): assert ( - espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=0.2) + espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=10) is not ProbeResult.ACCEPTED ) - assert resolve.call_count > 1 + # Looked up at 0, 3, 6 and 9 s; the last pause stops at the deadline + assert resolve.call_count == 4 assert any("failed, unreachable" in r.message for r in caplog.records) From e1a996435dc1a3373a72e1c8740449179510b07c Mon Sep 17 00:00:00 2001 From: Fabio Pugliese Ornellas Date: Wed, 14 Oct 2026 20:29:16 +0100 Subject: [PATCH 14/19] [ld2450] Add polygon zones evaluated on the device (#20154) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/ld2450/__init__.py | 11 + esphome/components/ld2450/binary_sensor.py | 36 ++- esphome/components/ld2450/ld2450.cpp | 34 +- esphome/components/ld2450/ld2450.h | 13 +- esphome/components/ld2450/polygon.cpp | 123 +++++++ esphome/components/ld2450/polygon.h | 53 ++++ esphome/components/ld2450/polygon_zone.cpp | 60 ++++ esphome/components/ld2450/polygon_zone.h | 63 ++++ esphome/components/ld2450/sensor.py | 2 +- esphome/components/ld2450/text.py | 120 +++++++ esphome/components/ld2450/text_sensor.py | 2 +- esphome/core/defines.h | 1 + tests/component_tests/ld2450/__init__.py | 0 .../ld2450/config/esp8266_zones.yaml | 31 ++ .../ld2450/test_polygon_pattern.py | 64 ++++ .../ld2450/test_polygon_zone_presence.py | 45 +++ .../ld2450/test_polygon_zones.py | 163 ++++++++++ tests/components/ld2450/__init__.py | 9 + tests/components/ld2450/common.yaml | 14 + tests/components/ld2450/ld2450_polygon.cpp | 162 ++++++++++ .../uart_mock_ld2450_has_target_only.yaml | 248 +++++++++++++++ ...uart_mock_ld2450_polygon_zone_restore.yaml | 55 ++++ ...uart_mock_ld2450_polygon_zone_timeout.yaml | 300 ++++++++++++++++++ .../uart_mock_ld2450_polygon_zones.yaml | 246 ++++++++++++++ .../test_uart_mock_ld2450_has_target_only.py | 64 ++++ ...t_uart_mock_ld2450_polygon_zone_restore.py | 117 +++++++ ...t_uart_mock_ld2450_polygon_zone_timeout.py | 102 ++++++ .../test_uart_mock_ld2450_polygon_zones.py | 160 ++++++++++ 28 files changed, 2291 insertions(+), 7 deletions(-) create mode 100644 esphome/components/ld2450/polygon.cpp create mode 100644 esphome/components/ld2450/polygon.h create mode 100644 esphome/components/ld2450/polygon_zone.cpp create mode 100644 esphome/components/ld2450/polygon_zone.h create mode 100644 esphome/components/ld2450/text.py create mode 100644 tests/component_tests/ld2450/__init__.py create mode 100644 tests/component_tests/ld2450/config/esp8266_zones.yaml create mode 100644 tests/component_tests/ld2450/test_polygon_pattern.py create mode 100644 tests/component_tests/ld2450/test_polygon_zone_presence.py create mode 100644 tests/component_tests/ld2450/test_polygon_zones.py create mode 100644 tests/components/ld2450/__init__.py create mode 100644 tests/components/ld2450/ld2450_polygon.cpp create mode 100644 tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml create mode 100644 tests/integration/test_uart_mock_ld2450_has_target_only.py create mode 100644 tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py create mode 100644 tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py create mode 100644 tests/integration/test_uart_mock_ld2450_polygon_zones.py diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index a54e83f2875..066ace871f8 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -1,6 +1,7 @@ from esphome import automation 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.types import ConfigType @@ -16,6 +17,8 @@ LD2450Component = ld2450_ns.class_("LD2450Component", cg.Component, uart.UARTDev CONF_LD2450_ID = "ld2450_id" +CONF_POLYGON_ZONE_ID = "polygon_zone_id" +CONF_POLYGON_ZONES = "polygon_zones" CONFIG_SCHEMA = cv.All( cv.Schema( @@ -50,6 +53,14 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"), ) +# Polygon zone sources build only when zones are configured +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "polygon.cpp": "LD2450_POLYGON_ZONE_COUNT", + "polygon_zone.cpp": "LD2450_POLYGON_ZONE_COUNT", + } +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index c7c1b6b46af..41f514472e5 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import ( + CONF_FILTERS, CONF_HAS_MOVING_TARGET, CONF_HAS_STILL_TARGET, CONF_HAS_TARGET, @@ -11,7 +12,14 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import ( # noqa: F401 pylint: disable=unused-import + CONF_LD2450_ID, + CONF_POLYGON_ZONE_ID, + CONF_POLYGON_ZONES, + FILTER_SOURCE_FILES, + LD2450Component, +) +from .text import PolygonZone DEPENDENCIES = ["ld2450"] @@ -19,6 +27,18 @@ ICON_MEDITATION = "mdi:meditation" ICON_SHIELD_ACCOUNT = "mdi:shield-account" ICON_TARGET_ACCOUNT = "mdi:target-account" + +def _reject_filters(config: ConfigType) -> ConfigType: + # Clearing a polygon makes presence unknown without going through filters, so they would get out of step + if CONF_FILTERS in config: + raise cv.Invalid( + "Polygon zone presence binary sensors can't have filters; use the ld2450 number platform's " + "'presence_timeout' to keep presence on after targets leave", + path=[CONF_FILTERS], + ) + return config + + CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), @@ -37,6 +57,16 @@ CONFIG_SCHEMA = { filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], icon=ICON_MEDITATION, ), + cv.Optional(CONF_POLYGON_ZONES): cv.ensure_list( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ).extend( + { + cv.Required(CONF_POLYGON_ZONE_ID): cv.use_id(PolygonZone), + } + ), + _reject_filters, + ), } @@ -46,3 +76,7 @@ async def to_code(config: ConfigType) -> None: await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) + for presence_conf in config.get(CONF_POLYGON_ZONES, []): + presence = await binary_sensor.new_binary_sensor(presence_conf) + zone = await cg.get_variable(presence_conf[CONF_POLYGON_ZONE_ID]) + cg.add(zone.set_presence_binary_sensor(presence)) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index b43493c26e9..d2a0e04d122 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -179,6 +179,11 @@ void LD2450Component::setup() { this->pref_ = this->presence_timeout_number_->make_entity_preference(); this->set_presence_timeout(); } +#endif +#ifdef LD2450_POLYGON_ZONE_COUNT + for (PolygonZone *zone : this->polygon_zones_) { + zone->setup(); + } #endif this->restart_and_read_all_info(); } @@ -265,6 +270,12 @@ void LD2450Component::dump_config() { LOG_BUTTON(" ", "FactoryReset", this->factory_reset_button_); LOG_BUTTON(" ", "Restart", this->restart_button_); #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + ESP_LOGCONFIG(TAG, "Polygon Zones:"); + for (PolygonZone *zone : this->polygon_zones_) { + zone->dump_config(); + } +#endif } void LD2450Component::loop() { @@ -465,15 +476,15 @@ void LD2450Component::handle_periodic_data_() { #if defined(USE_BINARY_SENSOR) || defined(USE_SENSOR) || defined(USE_TEXT_SENSOR) // Loop thru targets for (index = 0; index < MAX_TARGETS; index++) { -#ifdef USE_SENSOR // X start = TARGET_X + index * 8; is_moving = false; - // tx is used for further calculations, so always needs to be populated + // tx and ty are used for further calculations, so always need to be populated tx = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); // Y start = TARGET_Y + index * 8; ty = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); +#ifdef USE_SENSOR // RESOLUTION start = TARGET_RESOLUTION + index * 8; res = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start]; @@ -542,6 +553,23 @@ void LD2450Component::handle_periodic_data_() { still_target_count = target_count - moving_target_count; #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + const uint32_t now = App.get_loop_component_start_time(); + for (PolygonZone *zone : this->polygon_zones_) { + if (!zone->has_polygon()) + continue; + bool target_inside = false; + for (const Target &target : this->target_info_) { + // Untracked targets are stored as (0, 0) + if ((target.x != 0 || target.y != 0) && zone->contains(target.x, target.y)) { + target_inside = true; + break; + } + } + zone->update(target_inside, now, this->get_timeout_status_(zone->get_last_inside_ms())); + } +#endif + #ifdef USE_SENSOR // Loop thru zones uint8_t zone_still_targets = 0; @@ -602,7 +630,7 @@ void LD2450Component::handle_periodic_data_() { } } #endif -#ifdef USE_SENSOR +#ifdef USE_BINARY_SENSOR // For presence timeout check if (target_count > 0) { this->presence_millis_ = App.get_loop_component_start_time(); diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index d6285cce8af..f85a26ba675 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -24,6 +24,10 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef LD2450_POLYGON_ZONE_COUNT +#include "polygon_zone.h" +#endif + #include "esphome/components/ld24xx/ld24xx.h" #include "esphome/components/uart/uart.h" #include "esphome/core/helpers.h" @@ -137,6 +141,9 @@ class LD2450Component : public Component, public uart::UARTDevice { void set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s); void set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s); void set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s); +#endif +#ifdef LD2450_POLYGON_ZONE_COUNT + void register_polygon_zone(PolygonZone *zone) { this->polygon_zones_.push_back(zone); } #endif void reset_radar_zone(); void set_radar_zone(int32_t zone_type, int32_t zone1_x1, int32_t zone1_y1, int32_t zone1_x2, int32_t zone1_y2, @@ -167,7 +174,7 @@ class LD2450Component : public Component, public uart::UARTDevice { uint32_t presence_millis_ = 0; uint32_t still_presence_millis_ = 0; uint32_t moving_presence_millis_ = 0; - uint32_t timeout_ = 5; + uint32_t timeout_ = DEFAULT_PRESENCE_TIMEOUT * 1000; // presence_timeout, in ms uint8_t buffer_data_[MAX_LINE_LENGTH]; uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; @@ -200,6 +207,10 @@ class LD2450Component : public Component, public uart::UARTDevice { std::array direction_text_sensors_{}; #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + StaticVector polygon_zones_; +#endif + LazyCallbackManager data_callback_; }; diff --git a/esphome/components/ld2450/polygon.cpp b/esphome/components/ld2450/polygon.cpp new file mode 100644 index 00000000000..46d607d3519 --- /dev/null +++ b/esphome/components/ld2450/polygon.cpp @@ -0,0 +1,123 @@ +#include "polygon.h" + +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ld2450 { + +namespace { + +class Scanner { + public: + Scanner(const char *str, size_t len) : pos_(str), end_(str + len) {} + + bool at_end() const { return this->pos_ == this->end_; } + + void skip_spaces() { + while (this->pos_ != this->end_ && *this->pos_ == ' ') + this->pos_++; + } + + bool consume(char c) { + this->skip_spaces(); + if (this->pos_ != this->end_ && *this->pos_ == c) { + this->pos_++; + return true; + } + return false; + } + + /// Read an integer in [min, max]; returns false if there is none or it is out of range. + bool read_int(int16_t min, int16_t max, int16_t &out) { + bool negative = this->consume('-'); + int32_t value = 0; + uint8_t digits = 0; + while (this->pos_ != this->end_ && *this->pos_ >= '0' && *this->pos_ <= '9') { + // Six digits is already beyond any valid coordinate; stop before value can overflow + if (++digits > 6) + return false; + value = value * 10 + (*this->pos_ - '0'); + this->pos_++; + } + if (digits == 0) + return false; + if (negative) + value = -value; + if (value < min || value > max) + return false; + out = static_cast(value); + return true; + } + + protected: + const char *pos_; + const char *end_; +}; + +} // namespace + +bool Polygon::is_valid() const { + if (this->count == 0) + return true; + if (this->count < MIN_POLYGON_POINTS || this->count > MAX_POLYGON_POINTS) + return false; + for (uint8_t i = 0; i < this->count; i++) { + const Point &p = this->points[i]; + if (p.x < POLYGON_MIN_X || p.x > POLYGON_MAX_X || p.y < POLYGON_MIN_Y || p.y > POLYGON_MAX_Y) + return false; + } + return true; +} + +bool Polygon::contains(int16_t x, int16_t y) const { + bool inside = false; + for (uint8_t i = 0, j = this->count - 1; i < this->count; j = i++) { + const Point &a = this->points[i]; + const Point &b = this->points[j]; + if ((a.y > y) == (b.y > y)) + continue; + // Does the edge a-b cross the ray going from (x, y) towards +x? Compare + // x - a.x < (y - a.y) * (b.x - a.x) / (b.y - a.y) without division; values fit in 32 bits. + const int32_t dy = static_cast(b.y) - a.y; + const int32_t lhs = (static_cast(x) - a.x) * dy; + const int32_t rhs = (static_cast(y) - a.y) * (static_cast(b.x) - a.x); + if (dy > 0 ? lhs < rhs : lhs > rhs) + inside = !inside; + } + return inside; +} + +bool Polygon::parse(const char *str, size_t len) { + Polygon result; + Scanner scanner(str, len); + scanner.skip_spaces(); + while (!scanner.at_end()) { + if (result.count == MAX_POLYGON_POINTS) + return false; + Point &p = result.points[result.count]; + if (!scanner.read_int(POLYGON_MIN_X, POLYGON_MAX_X, p.x) || !scanner.consume(',') || + !scanner.read_int(POLYGON_MIN_Y, POLYGON_MAX_Y, p.y)) + return false; + result.count++; + bool separator = scanner.consume(';'); + scanner.skip_spaces(); + if (!separator && !scanner.at_end()) + return false; + } + if (!result.is_valid()) + return false; + *this = result; + return true; +} + +void Polygon::format(char *buf) const { + size_t pos = 0; + buf[0] = '\0'; + for (uint8_t i = 0; i < this->count; i++) { + pos = buf_append_printf(buf, POLYGON_STR_SIZE, pos, "%s%d,%d", i == 0 ? "" : ";", this->points[i].x, + this->points[i].y); + } +} + +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/polygon.h b/esphome/components/ld2450/polygon.h new file mode 100644 index 00000000000..24e5be8855b --- /dev/null +++ b/esphome/components/ld2450/polygon.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +namespace esphome::ld2450 { + +static constexpr uint8_t MIN_POLYGON_POINTS = 3; +// The most points whose text always fits Home Assistant's 255-character limit: 23 x "-4860,7560" + 22 x ';' = 252 +static constexpr uint8_t MAX_POLYGON_POINTS = 23; +// Coordinate limits of the radar detection area, in mm +static constexpr int16_t POLYGON_MIN_X = -4860; +static constexpr int16_t POLYGON_MAX_X = 4860; +static constexpr int16_t POLYGON_MIN_Y = 0; +static constexpr int16_t POLYGON_MAX_Y = 7560; +// Longest point is "-4860,7560;" (11 characters), plus the null terminator +static constexpr size_t POLYGON_STR_SIZE = MAX_POLYGON_POINTS * 11 + 1; + +struct Point { + int16_t x; + int16_t y; +}; + +/// A polygon in radar coordinates. Trivially copyable so it can be saved to preferences as-is. +struct Polygon { + std::array points{}; + uint8_t count{0}; + + /// True when the polygon has no points, which disables the zone. + bool empty() const { return this->count == 0; } + + /// True when the polygon is empty, or has a valid number of points that are all inside the radar area. + bool is_valid() const; + + /// Return whether the point is inside the polygon (even-odd rule). Always false for an empty polygon. + bool contains(int16_t x, int16_t y) const; + + /** Parse "x,y;x,y;..." (mm) into this polygon. + * + * Spaces around numbers and a trailing ';' are allowed. An empty or all-space string gives an empty polygon. + * @return false if the text is invalid; the polygon is then left unchanged. + */ + bool parse(const char *str, size_t len); + + /// Write the polygon as "x,y;x,y;..." into buf, which must hold at least POLYGON_STR_SIZE bytes. + void format(char *buf) const; +}; + +// text.py counts on this size +static_assert(sizeof(Polygon) == 94, "Polygon layout changed; update POLYGON_SAVED_BYTES in text.py"); + +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/polygon_zone.cpp b/esphome/components/ld2450/polygon_zone.cpp new file mode 100644 index 00000000000..ecafd40360b --- /dev/null +++ b/esphome/components/ld2450/polygon_zone.cpp @@ -0,0 +1,60 @@ +#include "polygon_zone.h" +#ifdef LD2450_POLYGON_ZONE_COUNT + +#include "esphome/core/log.h" + +namespace esphome::ld2450 { + +ESPHOME_LOG_TAG(TAG, "ld2450.polygon_zone"); + +void PolygonZone::setup() { + // Bump the version when Polygon's layout changes + this->pref_ = this->make_entity_preference(1); + Polygon saved; + if (this->pref_.load(&saved)) { + if (saved.is_valid()) { + this->polygon_ = saved; + } else { + ESP_LOGW(TAG, "'%s': saved polygon is invalid; zone disabled", this->get_name().c_str()); + } + } + // Presence stays unknown until the first radar frame + this->publish_polygon_(); +} + +void PolygonZone::dump_config() { + char polygon_s[POLYGON_STR_SIZE]; + this->polygon_.format(polygon_s); + LOG_TEXT(" ", "PolygonZone", this); + ESP_LOGCONFIG(TAG, " Polygon: '%s'", polygon_s); + LOG_BINARY_SENSOR(" ", "Presence", this->presence_); +} + +void PolygonZone::control(const std::string &value) { + Polygon polygon; + if (!polygon.parse(value.c_str(), value.size())) { + ESP_LOGW(TAG, "'%s': invalid polygon '%s'; expected 'x,y;x,y;...' with %u to %u points", this->get_name().c_str(), + value.c_str(), MIN_POLYGON_POINTS, MAX_POLYGON_POINTS); + } else if (!this->pref_.save(&polygon)) { + // On ESP8266, the space for saved settings is small and can run out with several zones + ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", this->get_name().c_str()); + } else { + this->polygon_ = polygon; + // A target seen inside the previous polygon must not keep the new one on + this->last_inside_ms_ = 0; + if (this->polygon_.empty()) + this->presence_->invalidate_state(); + } + // On error this publishes the previous polygon again, so the frontend shows the value in use + this->publish_polygon_(); +} + +void PolygonZone::publish_polygon_() { + char polygon_s[POLYGON_STR_SIZE]; + this->polygon_.format(polygon_s); + this->publish_state(polygon_s); +} + +} // namespace esphome::ld2450 + +#endif // LD2450_POLYGON_ZONE_COUNT diff --git a/esphome/components/ld2450/polygon_zone.h b/esphome/components/ld2450/polygon_zone.h new file mode 100644 index 00000000000..5a6caaa3282 --- /dev/null +++ b/esphome/components/ld2450/polygon_zone.h @@ -0,0 +1,63 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef LD2450_POLYGON_ZONE_COUNT + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/text/text.h" +#include "esphome/core/preferences.h" +#include "polygon.h" + +namespace esphome::ld2450 { + +/** A zone with any polygon shape. The text state holds the polygon, so it can be edited and read back from HA. + * + * The polygon is in the radar's raw coordinates (mm), and targets are checked against it as decoded from each + * radar frame, before any sensor filter. Filters on the target X/Y sensors change only what they publish, so a + * filter that changes their values (multiply, offset, lambda, ...) puts them in a different frame from the polygon. + * + * Like the hub's has_target binary sensors, presence stays on until presence_timeout has passed since a target was + * last inside the zone. + */ +class PolygonZone : public text::Text { + public: + /// Config validation guarantees every zone gets exactly one presence binary sensor. + void set_presence_binary_sensor(binary_sensor::BinarySensor *presence) { this->presence_ = presence; } + + /// Load the saved polygon and publish it. Called from the hub's setup(). + void setup(); + void dump_config(); + /** Publish presence for one radar frame. A zone without a polygon keeps presence unknown. + * + * @param target_inside whether any target is inside the zone in this frame. + * @param now the frame's time, in ms. + * @param timed_out whether presence_timeout has passed since get_last_inside_ms(). + */ + void update(bool target_inside, uint32_t now, bool timed_out) { + if (this->polygon_.empty()) + return; + if (target_inside) { + this->last_inside_ms_ = now; + this->presence_->publish_state(true); + } else if (timed_out) { + this->presence_->publish_state(false); + } + } + /// When a target was last inside the zone, in ms; 0 if never since the polygon was set. + uint32_t get_last_inside_ms() const { return this->last_inside_ms_; } + bool contains(int16_t x, int16_t y) const { return this->polygon_.contains(x, y); } + bool has_polygon() const { return !this->polygon_.empty(); } + + protected: + void control(const std::string &value) override; + void publish_polygon_(); + + binary_sensor::BinarySensor *presence_{nullptr}; + Polygon polygon_; + ESPPreferenceObject pref_; + uint32_t last_inside_ms_{0}; +}; + +} // namespace esphome::ld2450 + +#endif // LD2450_POLYGON_ZONE_COUNT diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index 6f2056330bd..4600f27ce0d 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -17,7 +17,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["ld2450"] diff --git a/esphome/components/ld2450/text.py b/esphome/components/ld2450/text.py new file mode 100644 index 00000000000..ebe8fc77131 --- /dev/null +++ b/esphome/components/ld2450/text.py @@ -0,0 +1,120 @@ +from esphome import preferences +import esphome.codegen as cg +from esphome.components import text +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_PLATFORM, ENTITY_CATEGORY_CONFIG +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import ( # noqa: F401 pylint: disable=unused-import + CONF_LD2450_ID, + CONF_POLYGON_ZONE_ID, + CONF_POLYGON_ZONES, + DOMAIN, + FILTER_SOURCE_FILES, + LD2450Component, + ld2450_ns, +) + +DEPENDENCIES = ["ld2450"] + +ICON_VECTOR_POLYGON = "mdi:vector-polygon" + +# Must match MIN_POLYGON_POINTS and MAX_POLYGON_POINTS in polygon.h +MIN_POLYGON_POINTS = 3 +MAX_POLYGON_POINTS = 23 +# Matches what Polygon::parse() accepts, apart from coordinate ranges, which only the device checks: +# empty, or MIN_POLYGON_POINTS to MAX_POLYGON_POINTS "x,y" points separated by ';'. HA validates input with it. +# No character classes: the HA frontend compiles it with the JS "v" flag, HA core with Python re. +POLYGON_PATTERN = ( + r"^ *$|^ *-?\d{1,4} *, *\d{1,4} *" + rf"(; *-?\d{{1,4}} *, *\d{{1,4}} *){{{MIN_POLYGON_POINTS - 1},{MAX_POLYGON_POINTS - 1}}};? *$" +) +# Home Assistant's limit for a state; the longest polygon the device publishes is 252 characters +POLYGON_MAX_LENGTH = 255 + +# sizeof(Polygon), pinned by a static_assert in polygon.h +POLYGON_SAVED_BYTES = 94 + +PolygonZone = ld2450_ns.class_("PolygonZone", text.Text) + +_request_polygon_zone_slot = cg.slot_counter("LD2450_POLYGON_ZONE_COUNT") + + +def _require_manual_id(config: ConfigType) -> ConfigType: + if not config[CONF_ID].is_manual: + raise cv.Invalid( + f"'{CONF_ID}' is required, so the zone's presence binary sensor can refer to it", + path=[CONF_ID], + ) + return config + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), + # Polygons are in raw radar mm, checked before sensor filters (polygon_zone.h) + cv.Required(CONF_POLYGON_ZONES): cv.ensure_list( + text.text_schema( + PolygonZone, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_VECTOR_POLYGON, + mode="TEXT", + ), + _require_manual_id, + ), + } +) + + +def _final_validate(config: ConfigType) -> None: + """Each polygon zone must have exactly one presence binary sensor, on the same LD2450.""" + full_config = fv.full_config.get() + # Polygon zone id -> the LD2450 id of each presence binary sensor that uses it + references: dict[str, list[str]] = {} + for binary_sensor_conf in full_config.get("binary_sensor", []): + if binary_sensor_conf.get(CONF_PLATFORM) != "ld2450": + continue + hub_id = binary_sensor_conf[CONF_LD2450_ID].id + for presence_conf in binary_sensor_conf.get(CONF_POLYGON_ZONES, []): + zone_id = presence_conf[CONF_POLYGON_ZONE_ID].id + references.setdefault(zone_id, []).append(hub_id) + + zone_hub_id = config[CONF_LD2450_ID].id + for index, zone_conf in enumerate(config[CONF_POLYGON_ZONES]): + zone_id = zone_conf[CONF_ID].id + hub_ids = references.get(zone_id, []) + if len(hub_ids) != 1: + problem = ( + "has no presence binary sensor" + if not hub_ids + else "is used by more than one presence binary sensor" + ) + raise cv.Invalid( + f"Polygon zone '{zone_id}' {problem}; each zone needs exactly one, under " + f"'binary_sensor: - platform: ld2450' -> '{CONF_POLYGON_ZONES}'", + path=[CONF_POLYGON_ZONES, index], + ) + if hub_ids[0] != zone_hub_id: + raise cv.Invalid( + f"Polygon zone '{zone_id}' belongs to LD2450 '{zone_hub_id}', but its presence " + f"binary sensor is under LD2450 '{hub_ids[0]}'; use the same '{CONF_LD2450_ID}'", + path=[CONF_POLYGON_ZONES, index], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2450_ID]) + # Each zone saves its polygon; the ESP8266 checks that every component's saved values fit + preferences.reserve_preferences( + len(config[CONF_POLYGON_ZONES]), POLYGON_SAVED_BYTES + ) + for zone_conf in config[CONF_POLYGON_ZONES]: + zone = await text.new_text( + zone_conf, max_length=POLYGON_MAX_LENGTH, pattern=POLYGON_PATTERN + ) + _request_polygon_zone_slot(str(hub)) + cg.add(hub.register_polygon_zone(zone)) diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index 3eaf6c7fcad..01821a4f7ef 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -14,7 +14,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["ld2450"] diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 50d8285cc1c..6075fc93797 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -157,6 +157,7 @@ #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 +#define LD2450_POLYGON_ZONE_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 diff --git a/tests/component_tests/ld2450/__init__.py b/tests/component_tests/ld2450/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/ld2450/config/esp8266_zones.yaml b/tests/component_tests/ld2450/config/esp8266_zones.yaml new file mode 100644 index 00000000000..19794626809 --- /dev/null +++ b/tests/component_tests/ld2450/config/esp8266_zones.yaml @@ -0,0 +1,31 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +uart: + tx_pin: GPIO0 + rx_pin: GPIO2 + baud_rate: 256000 + +ld2450: + id: radar + +text: + - platform: ld2450 + ld2450_id: radar + polygon_zones: + - id: couch + name: Couch + - id: desk + name: Desk + +binary_sensor: + - platform: ld2450 + ld2450_id: radar + polygon_zones: + - polygon_zone_id: couch + name: Couch Occupied + - polygon_zone_id: desk + name: Desk Occupied diff --git a/tests/component_tests/ld2450/test_polygon_pattern.py b/tests/component_tests/ld2450/test_polygon_pattern.py new file mode 100644 index 00000000000..4fb57ae6894 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_pattern.py @@ -0,0 +1,64 @@ +"""Tests for the polygon zone text pattern and maximum length. + +Home Assistant validates polygon input with the pattern before it reaches the device, so the pattern must accept +what Polygon::parse() accepts. Coordinate ranges are the one exception: only the device checks those. +""" + +from __future__ import annotations + +import re + +import pytest + +from esphome.components.ld2450.text import ( + MAX_POLYGON_POINTS, + POLYGON_MAX_LENGTH, + POLYGON_PATTERN, +) + +# The same inputs as the C++ parser tests (ld2450_polygon.cpp), without out-of-range values +ACCEPTED = [ + "", + " ", + "-1000,500;1000,500;0,3000", + " -1000 , 500 ; 1000,500; 0,3000; ", + "-4860,0;4860,0;4860,7560", +] +REJECTED = [ + "0,0", + "0,0;100,100", + "junk", + "1,2;3", + "1,2;3,4;5,", + "1;2;3", + "1,2 3,4 5,6", + "1,2;;3,4;5,6", + "--1,2;3,4;5,6", + "1,2;3,4;5,6x", + "1,2;\t3,4;5,6", +] + + +@pytest.mark.parametrize("value", ACCEPTED) +def test_pattern_accepts_valid_polygons(value: str) -> None: + assert re.fullmatch(POLYGON_PATTERN, value) + + +@pytest.mark.parametrize("value", REJECTED) +def test_pattern_rejects_invalid_polygons(value: str) -> None: + assert not re.fullmatch(POLYGON_PATTERN, value) + + +def test_point_count_limits() -> None: + assert re.fullmatch(POLYGON_PATTERN, ";".join(["0,0"] * MAX_POLYGON_POINTS)) + assert not re.fullmatch( + POLYGON_PATTERN, ";".join(["0,0"] * (MAX_POLYGON_POINTS + 1)) + ) + + +def test_longest_polygon_fits_max_length() -> None: + longest = ";".join(["-4860,7560"] * MAX_POLYGON_POINTS) + assert re.fullmatch(POLYGON_PATTERN, longest) + assert len(longest) <= POLYGON_MAX_LENGTH + # MAX_POLYGON_POINTS is the most that always fits + assert len(longest + ";-4860,7560") > POLYGON_MAX_LENGTH diff --git a/tests/component_tests/ld2450/test_polygon_zone_presence.py b/tests/component_tests/ld2450/test_polygon_zone_presence.py new file mode 100644 index 00000000000..8ea8e3a67a0 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_zone_presence.py @@ -0,0 +1,45 @@ +"""Tests for the ld2450 polygon zone presence binary sensor schema.""" + +from __future__ import annotations + +import pytest + +from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES +from esphome.components.ld2450.binary_sensor import ( + CONFIG_SCHEMA as BINARY_SENSOR_CONFIG_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import CONF_FILTERS, CONF_NAME, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _presence(**extra: object) -> ConfigType: + return { + CONF_POLYGON_ZONES: [ + {CONF_POLYGON_ZONE_ID: "couch_zone", CONF_NAME: "Couch Occupied", **extra} + ] + } + + +def test_presence_without_filters_passes( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + + cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)(_presence()) + + +def test_presence_with_filters_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Clearing a polygon bypasses filters, so they would get out of step with the zone.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone presence binary sensors can't have filters; .*presence_timeout", + ): + cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)( + _presence(**{CONF_FILTERS: [{"delayed_off": "1s"}]}) + ) diff --git a/tests/component_tests/ld2450/test_polygon_zones.py b/tests/component_tests/ld2450/test_polygon_zones.py new file mode 100644 index 00000000000..f63d3336782 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_zones.py @@ -0,0 +1,163 @@ +"""Tests for the ld2450 polygon zone validation. + +Polygon zones are declared in the text platform and their presence sensors in the +binary sensor platform, so the pairing between the two is checked at final validation. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp8266.const import ( + KEY_ESP8266, + KEY_PREFERENCES, + preference_words, +) +from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES +from esphome.components.ld2450.text import ( + CONFIG_SCHEMA as TEXT_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as TEXT_FINAL_VALIDATE_SCHEMA, + POLYGON_SAVED_BYTES, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_NAME, CONF_PLATFORM, PlatformFramework +from esphome.core import CORE, ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "ld2450_hub" +OTHER_HUB_ID = "ld2450_other" + + +def _text_config(*zone_ids: str) -> ConfigType: + """A text platform config declaring the given polygon zones.""" + return { + "ld2450_id": ID(HUB_ID, is_declaration=False, type="ld2450"), + CONF_POLYGON_ZONES: [ + {CONF_ID: ID(zone_id, is_declaration=True), CONF_NAME: zone_id} + for zone_id in zone_ids + ], + } + + +def _binary_sensors(*zone_ids: str, hub_id: str = HUB_ID) -> list[ConfigType]: + """A binary sensor platform list with one presence sensor per given zone reference.""" + return [ + { + CONF_PLATFORM: "ld2450", + "ld2450_id": ID(hub_id, is_declaration=False, type="ld2450"), + CONF_POLYGON_ZONES: [ + {CONF_POLYGON_ZONE_ID: ID(zone_id, is_declaration=False)} + for zone_id in zone_ids + ], + } + ] + + +def test_zones_paired_with_one_presence_pass( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch", "desk")}, + ) + + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk")) + + +def test_zone_without_presence_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch")}, + ) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone 'desk' has no presence binary sensor; .* @ data\['polygon_zones'\]\[1\]$", + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk")) + + +def test_zone_without_any_binary_sensor_platform_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config={}) + + with pytest.raises(cv.Invalid, match="'couch' has no presence binary sensor"): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_zone_with_two_presences_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Two references to one zone are rejected, even across binary sensor platform entries.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={ + "binary_sensor": _binary_sensors("couch") + _binary_sensors("couch") + }, + ) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone 'couch' is used by more than one presence binary sensor; ", + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_other_platforms_are_ignored( + set_core_config: SetCoreConfigCallable, +) -> None: + """Binary sensors of other platforms never count as presence for a zone.""" + other = {CONF_PLATFORM: "template", CONF_POLYGON_ZONES: []} + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": [other, *_binary_sensors("couch")]}, + ) + + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_zone_without_id_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The presence sensor refers to the zone by id, so an automatic id is no use.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match=r"^'id' is required, so the zone's presence"): + TEXT_CONFIG_SCHEMA({CONF_POLYGON_ZONES: [{CONF_NAME: "Couch Zone Polygon"}]}) + + +def test_presence_on_another_ld2450_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The zone's own LD2450 drives its presence, so the sensor must name the same one.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch", hub_id=OTHER_HUB_ID)}, + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^Polygon zone 'couch' belongs to LD2450 'ld2450_hub', but its presence binary " + r"sensor is under LD2450 'ld2450_other'; .* @ data\['polygon_zones'\]\[0\]$" + ), + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_esp8266_zones_reserve_their_saved_space( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Every zone reserves its polygon, so the ESP8266 can check that all saved values fit together.""" + generate_main(component_config_path("esp8266_zones.yaml")) + + words = preference_words(POLYGON_SAVED_BYTES) + assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(words, False)] * 2 diff --git a/tests/components/ld2450/__init__.py b/tests/components/ld2450/__init__.py new file mode 100644 index 00000000000..a4f30dd1f7c --- /dev/null +++ b/tests/components/ld2450/__init__.py @@ -0,0 +1,9 @@ +from esphome.loader import FileResource +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # ld2450 is MULTI_CONF, so no codegen runs here to keep polygon.cpp; the tests need it + manifest.resources = manifest.resources + [ + FileResource("esphome.components.ld2450", "polygon.cpp") + ] diff --git a/tests/components/ld2450/common.yaml b/tests/components/ld2450/common.yaml index 617228ca34a..f0d5ecb04fd 100644 --- a/tests/components/ld2450/common.yaml +++ b/tests/components/ld2450/common.yaml @@ -96,6 +96,20 @@ binary_sensor: name: Moving Target has_still_target: name: Still Target + polygon_zones: + - polygon_zone_id: ld2450_couch_zone + name: Couch Occupied + - polygon_zone_id: ld2450_desk_zone + name: Desk Occupied + +text: + - platform: ld2450 + ld2450_id: ld2450_radar + polygon_zones: + - id: ld2450_couch_zone + name: Couch Zone Polygon + - id: ld2450_desk_zone + name: Desk Zone Polygon switch: - platform: ld2450 diff --git a/tests/components/ld2450/ld2450_polygon.cpp b/tests/components/ld2450/ld2450_polygon.cpp new file mode 100644 index 00000000000..b087e83d9c8 --- /dev/null +++ b/tests/components/ld2450/ld2450_polygon.cpp @@ -0,0 +1,162 @@ +#include +#include +#include "esphome/components/ld2450/polygon.h" + +namespace esphome::ld2450::testing { + +static bool parse(Polygon &polygon, const char *str) { return polygon.parse(str, strlen(str)); } + +static std::string format(const Polygon &polygon) { + char buf[POLYGON_STR_SIZE]; + polygon.format(buf); + return buf; +} + +// --- Parsing --- + +TEST(LD2450PolygonTest, ParseValid) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_EQ(polygon.count, 3); + EXPECT_EQ(polygon.points[0].x, -1000); + EXPECT_EQ(polygon.points[0].y, 500); + EXPECT_EQ(polygon.points[2].x, 0); + EXPECT_EQ(polygon.points[2].y, 3000); + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000"); +} + +TEST(LD2450PolygonTest, ParseAllowsSpacesAndTrailingSeparator) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, " -1000 , 500 ; 1000,500; 0,3000; ")); + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000"); +} + +TEST(LD2450PolygonTest, ParseEmptyDisablesZone) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_TRUE(parse(polygon, "")); + EXPECT_TRUE(polygon.empty()); + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_TRUE(parse(polygon, " ")); + EXPECT_TRUE(polygon.empty()); + EXPECT_EQ(format(polygon), ""); +} + +TEST(LD2450PolygonTest, ParseLimits) { + Polygon polygon; + EXPECT_TRUE(parse(polygon, "-4860,0;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4861,0;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,0;4861,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,-1;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,0;4860,0;4860,7561")); + EXPECT_FALSE(parse(polygon, "-4860,0;4860,0;4860,99999999999")); +} + +TEST(LD2450PolygonTest, ParsePointCount) { + Polygon polygon; + EXPECT_FALSE(parse(polygon, "0,0")); + EXPECT_FALSE(parse(polygon, "0,0;100,100")); + + std::string text; + for (int i = 0; i < MAX_POLYGON_POINTS; i++) { + text += "-4860,7560;"; + } + ASSERT_TRUE(parse(polygon, text.c_str())); + EXPECT_EQ(polygon.count, MAX_POLYGON_POINTS); + // The longest possible polygon fits in the format buffer + EXPECT_EQ(format(polygon) + ";", text); + + text += "0,0"; + EXPECT_FALSE(parse(polygon, text.c_str())); +} + +TEST(LD2450PolygonTest, ParseInvalidKeepsPreviousPolygon) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + const char *invalid[] = { + "junk", + "1,2;3", + "1,2;3,4;5,", + "1;2;3", + "1,2 3,4 5,6", + "1,2;;3,4;5,6", + "--1,2;3,4;5,6", + "1,2;3,4;5,6x", + // Only spaces are allowed around numbers, as in the text entity's pattern + "1,2;\t3,4;5,6", + }; + for (const char *str : invalid) { + EXPECT_FALSE(parse(polygon, str)) << str; + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000") << str; + } +} + +TEST(LD2450PolygonTest, IsValid) { + Polygon polygon; + EXPECT_TRUE(polygon.is_valid()); + polygon.count = 2; + EXPECT_FALSE(polygon.is_valid()); + polygon.count = MAX_POLYGON_POINTS + 1; + EXPECT_FALSE(polygon.is_valid()); + polygon.count = 3; + EXPECT_TRUE(polygon.is_valid()); + polygon.points[1].y = -5; + EXPECT_FALSE(polygon.is_valid()); +} + +// --- Point in polygon --- + +TEST(LD2450PolygonTest, ContainsEmpty) { + Polygon polygon; + EXPECT_FALSE(polygon.contains(0, 0)); + EXPECT_FALSE(polygon.contains(100, 1000)); +} + +TEST(LD2450PolygonTest, ContainsSquare) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,1000;1000,1000;1000,3000;-1000,3000")); + EXPECT_TRUE(polygon.contains(0, 2000)); + EXPECT_TRUE(polygon.contains(-999, 1001)); + EXPECT_TRUE(polygon.contains(999, 2999)); + EXPECT_FALSE(polygon.contains(0, 999)); + EXPECT_FALSE(polygon.contains(0, 3001)); + EXPECT_FALSE(polygon.contains(-1001, 2000)); + EXPECT_FALSE(polygon.contains(1001, 2000)); +} + +TEST(LD2450PolygonTest, ContainsWithReversedWinding) { + Polygon clockwise; + Polygon counter_clockwise; + ASSERT_TRUE(parse(clockwise, "0,1000;2000,3000;-2000,3000")); + ASSERT_TRUE(parse(counter_clockwise, "-2000,3000;2000,3000;0,1000")); + for (int16_t x = -2500; x <= 2500; x += 250) { + for (int16_t y = 500; y <= 3500; y += 250) { + EXPECT_EQ(clockwise.contains(x, y), counter_clockwise.contains(x, y)) << x << "," << y; + } + } + EXPECT_TRUE(clockwise.contains(0, 2000)); + EXPECT_FALSE(clockwise.contains(1500, 2000)); +} + +TEST(LD2450PolygonTest, ContainsConcave) { + // U shape: the notch between the two arms is outside + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-3000,1000;3000,1000;3000,4000;1000,4000;1000,2000;-1000,2000;-1000,4000;-3000,4000")); + EXPECT_TRUE(polygon.contains(-2000, 3000)); + EXPECT_TRUE(polygon.contains(2000, 3000)); + EXPECT_TRUE(polygon.contains(0, 1500)); + EXPECT_FALSE(polygon.contains(0, 3000)); + EXPECT_FALSE(polygon.contains(0, 4500)); +} + +TEST(LD2450PolygonTest, ContainsFarOutsideRadarArea) { + // The radar reports 15-bit coordinates; the cross products must not overflow + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-4860,0;4860,0;4860,7560;-4860,7560")); + EXPECT_FALSE(polygon.contains(32767, 32767)); + EXPECT_FALSE(polygon.contains(-32767, -32767)); + EXPECT_FALSE(polygon.contains(-32767, 3000)); + EXPECT_TRUE(polygon.contains(0, 3000)); +} + +} // namespace esphome::ld2450::testing diff --git a/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml b/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml new file mode 100644 index 00000000000..df0b4ceb518 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml @@ -0,0 +1,248 @@ +esphome: + name: uart-mock-ld2450-has-target + +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 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: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + # One frame every 100 ms, as the radar sends them. Coordinates are sign-magnitude, with the high bit set for + # positive values. + injections: + # Target 1 at (-500, 1000) + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # No targets for 2 s: has_target must hold for the default 5 s presence_timeout + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +# Only has_target: no sensor platform and no presence_timeout number +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml new file mode 100644 index 00000000000..ad7aec4e8c0 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml @@ -0,0 +1,55 @@ +esphome: + name: uart-mock-ld2450-zone-restore + +host: +api: + batch_delay: 0ms +logger: + level: DEBUG + +preferences: + flash_write_interval: 0s + +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 + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: restore_zone + name: "Restore Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: restore_zone + name: "Restore Zone Presence" diff --git a/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml new file mode 100644 index 00000000000..112e75f5ab7 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml @@ -0,0 +1,300 @@ +esphome: + name: uart-mock-ld2450-zone-timeout + +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 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: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + # One frame every 100 ms, as the radar sends them. Target 1 is either at (-500, 1000), inside the zone, or at + # (300, 400), outside it. Coordinates are sign-magnitude, with the high bit set for positive values. + injections: + # Inside the zone + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside for one frame: radar noise that presence_timeout must hide + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Inside again: the last inside frame + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside: presence clears once presence_timeout passes + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 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 + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: hold_zone + name: "Hold Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: hold_zone + name: "Hold Zone Presence" + +number: + - platform: ld2450 + ld2450_id: ld2450_dev + presence_timeout: + name: "Presence Timeout" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml new file mode 100644 index 00000000000..5cc4784f814 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml @@ -0,0 +1,246 @@ +esphome: + name: uart-mock-ld2450-zones + +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 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: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + injections: + # Frame 1: target 1 at (-500, 1000), target 2 at (200, 500) + # Coordinates are sign-magnitude, with the high bit set for positive values. + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0xC8, 0x80, 0xF4, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + + # Frame 2: target 1 at (300, 400), no other targets + - delay: 200ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + + # Frame 3: no targets + - delay: 200ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + + # No targets, frame after frame, until presence_timeout (1 s) has cleared every zone + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 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, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: left_zone + name: "Left Zone Polygon" + - id: near_zone + name: "Near Zone Polygon" + - id: unset_zone + name: "Unset Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: left_zone + name: "Left Zone Presence" + - polygon_zone_id: near_zone + name: "Near Zone Presence" + - polygon_zone_id: unset_zone + name: "Unset Zone Presence" + +number: + - platform: ld2450 + ld2450_id: ld2450_dev + presence_timeout: + name: "Presence Timeout" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/test_uart_mock_ld2450_has_target_only.py b/tests/integration/test_uart_mock_ld2450_has_target_only.py new file mode 100644 index 00000000000..56e66ad65fb --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_has_target_only.py @@ -0,0 +1,64 @@ +"""Integration test for LD2450 has_target without the sensor and number platforms. + +Tests: +1. has_target turns on when only binary sensors are configured (target coordinates are still decoded) +2. has_target holds for the default presence_timeout of 5 s, without the presence_timeout number +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import BinarySensorInfo, BinarySensorState, ButtonInfo, EntityState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +# The fixture sends one frame with a target, then 2 s of frames without one +SCENARIO_S = 2.1 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_has_target_only( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test has_target with no sensor platform and no presence_timeout number.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + has_target = require_entity(entities, "has_target", BinarySensorInfo) + history: list[bool] = [] + turned_on = asyncio.get_running_loop().create_future() + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key == has_target.key: + history.append(state.state) + if state.state and not turned_on.done(): + turned_on.set_result(None) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(on_state)) + await helper.wait_for_initial_states() + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + await asyncio.wait_for(turned_on, timeout=5.0) + await asyncio.sleep(SCENARIO_S) + + assert history == [True], ( + "has_target must turn on and hold through 2 s without targets, " + f"got {history}" + ) diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py new file mode 100644 index 00000000000..2b25e65e190 --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py @@ -0,0 +1,117 @@ +"""Integration test for restoring LD2450 polygon zones from saved preferences. + +Tests: +1. A polygon set from the API is restored after a restart +2. A saved polygon that is no longer valid is reported and leaves the zone disabled +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import socket +import struct + +from aioesphomeapi import TextInfo, TextState +import pytest + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .host_prefs import clear_host_prefs, read_host_prefs, write_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "uart-mock-ld2450-zone-restore" +ZONE_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +# The saved Polygon starts with its points as little-endian int16 x, y pairs +SAVED_POLYGON_PREFIX = struct.pack(" None: + """Test that polygon zones are restored after a restart, and invalid saved polygons are rejected.""" + port, port_socket = reserved_tcp_port + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + clear_host_prefs(DEVICE_NAME) + + binary_path = await compile_esphome(await write_yaml_config(yaml_config)) + # Release the reserved port so the binary can bind to it + port_socket.close() + + async def initial_polygon(line_callback=None) -> str: + """Start the binary and return the zone's polygon state as first published.""" + async with ( + run_binary_and_wait_for_port( + binary_path, "127.0.0.1", port, line_callback=line_callback + ), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "restore_zone_polygon", TextInfo) + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(lambda state: None)) + await helper.wait_for_initial_states() + state = helper.initial_states[polygon.key] + assert isinstance(state, TextState) + return state.state + + # First run: set a polygon, which is saved + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "restore_zone_polygon", TextInfo) + loop = asyncio.get_running_loop() + published = loop.create_future() + + def on_state(state: object) -> None: + if ( + isinstance(state, TextState) + and state.key == polygon.key + and state.state == ZONE_POLYGON + and not published.done() + ): + published.set_result(None) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(on_state)) + await helper.wait_for_initial_states() + assert helper.initial_states[polygon.key].state == "" + + client.text_command(polygon.key, ZONE_POLYGON) + await asyncio.wait_for(published, timeout=5.0) + + # Second run: the polygon is restored + assert await initial_polygon() == ZONE_POLYGON + + # Third run: a saved polygon with too few points is rejected + prefs = read_host_prefs(DEVICE_NAME) + keys = [key for key, data in prefs.items() if data.startswith(SAVED_POLYGON_PREFIX)] + assert len(keys) == 1, f"saved polygon not found in {prefs}" + data = bytearray(prefs[keys[0]]) + data[COUNT_OFFSET] = 2 + prefs[keys[0]] = bytes(data) + write_host_prefs(DEVICE_NAME, prefs) + + warnings: list[str] = [] + + def capture_warning(line: str) -> None: + if "saved polygon is invalid" in line: + warnings.append(line) + + assert await initial_polygon(capture_warning) == "" + assert warnings, "expected a warning about the invalid saved polygon" + + clear_host_prefs(DEVICE_NAME) diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py b/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py new file mode 100644 index 00000000000..9aba119713f --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py @@ -0,0 +1,102 @@ +"""Integration test for LD2450 polygon zone presence with presence_timeout. + +Tests: +1. A target leaving the zone for a single radar frame does not clear presence +2. Presence clears once presence_timeout has passed since a target was last inside the zone +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ( + BinarySensorInfo, + BinarySensorState, + ButtonInfo, + EntityState, + NumberInfo, + NumberState, + TextInfo, + TextState, +) +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "uart-mock-ld2450-zone-timeout" +ZONE_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +PRESENCE_TIMEOUT_S = 1 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_polygon_zone_timeout( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that polygon zone presence follows presence_timeout.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + # The polygon and presence_timeout are saved, so a previous run would otherwise restore them + clear_host_prefs(DEVICE_NAME) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "hold_zone_polygon", TextInfo) + presence = require_entity(entities, "hold_zone_presence", BinarySensorInfo) + timeout = require_entity(entities, "presence_timeout", NumberInfo) + + loop = asyncio.get_running_loop() + # (time received, state) for each presence change + presence_history: list[tuple[float, bool]] = [] + cleared = loop.create_future() + polygon_set = loop.create_future() + timeout_set = loop.create_future() + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key == presence.key: + presence_history.append((loop.time(), state.state)) + if state.state is False and not cleared.done(): + cleared.set_result(None) + elif ( + isinstance(state, TextState) + and state.key == polygon.key + and state.state == ZONE_POLYGON + and not polygon_set.done() + ): + polygon_set.set_result(None) + elif ( + isinstance(state, NumberState) + and state.key == timeout.key + and state.state == PRESENCE_TIMEOUT_S + and not timeout_set.done() + ): + timeout_set.set_result(None) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + client.text_command(polygon.key, ZONE_POLYGON) + client.number_command(timeout.key, PRESENCE_TIMEOUT_S) + await asyncio.wait_for(asyncio.gather(polygon_set, timeout_set), timeout=5.0) + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + await asyncio.wait_for(cleared, timeout=5.0) + + # The one-frame blip outside never reaches HA + assert [state for _, state in presence_history] == [True, False] + # The last inside frame comes 200 ms after the first, then presence_timeout must pass + (on_time, _), (off_time, _) = presence_history + assert off_time - on_time >= PRESENCE_TIMEOUT_S diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zones.py b/tests/integration/test_uart_mock_ld2450_polygon_zones.py new file mode 100644 index 00000000000..c77b7288e4c --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zones.py @@ -0,0 +1,160 @@ +"""Integration test for LD2450 polygon zones with mock UART. + +Tests: +1. Polygons start empty and their presence sensors start unknown +2. A polygon set from the API is published back in canonical form +3. Invalid polygon text is rejected and the previous polygon is published again +4. Presence turns on and off as targets move in and out of each polygon +5. A zone without a polygon keeps presence unknown, and clearing a polygon makes it unknown again +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +from aioesphomeapi import ( + BinarySensorInfo, + BinarySensorState, + ButtonInfo, + EntityState, + NumberInfo, + NumberState, + TextInfo, + TextState, +) +import pytest + +from esphome.components.ld2450.text import POLYGON_MAX_LENGTH, POLYGON_PATTERN + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, StateWaiter, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "uart-mock-ld2450-zones" +LEFT_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +NEAR_POLYGON = "0,0;1000,0;1000,600;0,600" + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_polygon_zones( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LD2450 polygon zone configuration and presence detection.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + # Polygons are saved, so a previous run would otherwise restore them + clear_host_prefs(DEVICE_NAME) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + polygons = { + name: require_entity(entities, f"{name}_zone_polygon", TextInfo) + for name in ("left", "near", "unset") + } + presences = { + name: require_entity(entities, f"{name}_zone_presence", BinarySensorInfo) + for name in ("left", "near", "unset") + } + presence_names = {info.key: name for name, info in presences.items()} + # None records presence going back to unknown + presence_history: dict[str, list[bool | None]] = { + name: [] for name in presences + } + waiter = StateWaiter() + expect = waiter.expect + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key in presence_names: + presence_history[presence_names[state.key]].append( + None if state.missing_state else state.state + ) + waiter.on_state(state) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + for name, polygon in polygons.items(): + # Home Assistant validates input and the zone card finds zones with these + assert polygon.pattern == POLYGON_PATTERN + assert polygon.max_length == POLYGON_MAX_LENGTH + assert initial_state_helper.initial_states[polygon.key].state == "" + assert initial_state_helper.initial_states[ + presences[name].key + ].missing_state + + async def set_polygon(name: str, value: str, expected: str) -> None: + key = polygons[name].key + published = expect(lambda s: isinstance(s, TextState) and s.key == key) + client.text_command(key, value) + state = await published + assert state.state == expected + assert re.fullmatch(POLYGON_PATTERN, state.state) + + # Spaces and a trailing separator are accepted and removed + await set_polygon( + "left", f" {LEFT_POLYGON.replace(';', ' ; ')} ;", LEFT_POLYGON + ) + await set_polygon("near", NEAR_POLYGON, NEAR_POLYGON) + # Invalid text keeps the polygon in use + await set_polygon("near", "0,0;1000,0", NEAR_POLYGON) + await set_polygon("near", "junk", NEAR_POLYGON) + + # Zone presence follows presence_timeout; keep the scenario short + timeout = require_entity(entities, "presence_timeout", NumberInfo) + timeout_set = expect( + lambda s: ( + isinstance(s, NumberState) and s.key == timeout.key and s.state == 1 + ) + ) + client.number_command(timeout.key, 1) + await timeout_set + + left_cleared = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["left"].key + and s.state is False + ) + ) + near_cleared = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["near"].key + and s.state is False + ) + ) + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + + await asyncio.gather(left_cleared, near_cleared) + + # Frame 1: (-500, 1000) is in the left zone and (200, 500) is in the near zone. + # Frame 2: (300, 400) is in the near zone only; the left zone clears 1 s after frame 1. + # Frame 3 onwards: no targets; the near zone clears 1 s after frame 2. + assert presence_history["left"] == [True, False] + assert presence_history["near"] == [True, False] + assert presence_history["unset"] == [] + + left_unknown = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["left"].key + and s.missing_state + ) + ) + await set_polygon("left", "", "") + await left_unknown + assert presence_history["left"] == [True, False, None] From e869d1f7ab374ca862eba02964a042c4e87938ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 09:56:09 -1000 Subject: [PATCH 15/19] [ci] Give the BK72xx clang-tidy environment the static allocation flag the real build has (#20764) --- platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/platformio.ini b/platformio.ini index 9e05356665a..fe61470a358 100644 --- a/platformio.ini +++ b/platformio.ini @@ -599,6 +599,8 @@ build_flags = ${flags:clangtidy.build_flags} -DUSE_BK72XX -DUSE_LIBRETINY_VARIANT_BK7231N + ; The libretiny component adds this at code generation so FreeRTOSQueue can use xQueueCreateStatic + -DconfigSUPPORT_STATIC_ALLOCATION=1 build_unflags = ${common.build_unflags} From 3c4cf50cb6b844e098e47d3d6b64fd55939a7fac Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:02:51 +0100 Subject: [PATCH 16/19] [core] Point compile errors after a lambda at the right line (#20740) Co-authored-by: J. Nick Koston --- esphome/core/__init__.py | 13 ++++++-- esphome/cpp_generator.py | 12 ++++--- esphome/writer.py | 23 +++++++++++-- esphome/yaml_util.py | 17 +++++++--- tests/unit_tests/test_cpp_generator.py | 45 ++++++++++++++++++++++++-- tests/unit_tests/test_writer.py | 30 ++++++++++++++++- tests/unit_tests/test_yaml_util.py | 18 +++++++++++ 7 files changed, 142 insertions(+), 16 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 61092ec4272..9b8f9f98642 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -442,6 +442,16 @@ class ID: ) +# Replaced by write_cpp with a #line back into main.cpp +LINE_DIRECTIVE_RESET = "// ESPHOME_LINE_DIRECTIVE_RESET" + + +def line_directive(document: str, line: int) -> str: + """A #line directive naming the 1-based ``line`` of ``document``.""" + escaped = document.replace("\\", "\\\\") + return f'#line {line} "{escaped}"' + + class DocumentLocation: def __init__(self, document: str, line: int, column: int): self.document: str = document @@ -457,8 +467,7 @@ class DocumentLocation: @property def as_line_directive(self): - document_path = str(self.document).replace("\\", "\\\\") - return f'#line {self.line + 1} "{document_path}"' + return line_directive(str(self.document), self.line + 1) class DocumentRange: diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 1196669662c..d0fc126957f 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -8,6 +8,7 @@ from typing import Any from esphome.core import ( CORE, ID, + LINE_DIRECTIVE_RESET, Define, EnumValue, EsphomeError, @@ -214,9 +215,13 @@ class LambdaExpression(Expression): if self.return_type is not None: cpp += f" -> {self.return_type}" cpp += " {\n" + body = f"{self.content}\n" if self.source is not None: - cpp += f"{self.source.as_line_directive}\n" - cpp += f"{self.content}\n}}" + body = f"{self.source.as_line_directive}\n{body}" + # A #line in the body, the source's or lvgl's own marks, needs the reset after it + if "#line " in body: + body += f"{LINE_DIRECTIVE_RESET}\n" + cpp += f"{body}}}" return indent_all_but_first_and_last(cpp) @property @@ -946,8 +951,7 @@ async def process_lambda( parts[i * 3 + 2] = "" if isinstance(value, ESPHomeDataBase) and value.esp_range is not None: - location = value.esp_range.start_mark - location.line += value.content_offset + location = value.content_start else: location = None return LambdaExpression(parts, parameters, capture, return_type, location) diff --git a/esphome/writer.py b/esphome/writer.py index 559d7d85828..934595ef7e8 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -15,7 +15,7 @@ from esphome.const import ( SOURCE_FILE_EXTENSIONS, __version__, ) -from esphome.core import CORE, EsphomeError +from esphome.core import CORE, LINE_DIRECTIVE_RESET, EsphomeError, line_directive from esphome.helpers import ( copy_file_if_changed, cpp_string_escape, @@ -514,6 +514,25 @@ const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; """ +def _reset_line_directives(text: str, path: Path) -> str: + """Point the code after each lambda back at its own line of *path*. + + A lambda's #line directive points the compiler at the YAML; without a reset + every later line of main.cpp would be reported against the YAML too. + """ + if LINE_DIRECTIVE_RESET not in text: + return text + document = str(path.absolute()) + lines = text.split("\n") + for index, line in enumerate(lines): + if line.endswith(LINE_DIRECTIVE_RESET): + # The directive numbers the line after it: index + 2, counted from 1 + lines[index] = line.replace( + LINE_DIRECTIVE_RESET, line_directive(document, index + 2) + ) + return "\n".join(lines) + + def write_cpp(code_s): path = CORE.relative_src_path("main.cpp") if path.is_file(): @@ -540,7 +559,7 @@ def write_cpp(code_s): f"{code_format[1] + CPP_AUTO_GENERATE_BEGIN}\n{code_s}{CPP_AUTO_GENERATE_END}" ) full_file += code_format[2] - write_file_if_changed(path, full_file) + write_file_if_changed(path, _reset_line_directives(full_file, path)) def clean_cmake_cache(): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 261d8b50e36..6b7e49d3802 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -29,6 +29,7 @@ from esphome.config_helpers import Extend, Remove from esphome.const import CONF_DEFAULTS from esphome.core import ( CORE, + DocumentLocation, DocumentRange, EsphomeError, Lambda, @@ -142,14 +143,20 @@ class ESPHomeDataBase: def content_offset(self): return getattr(self, "_content_offset", 0) + @property + def content_start(self) -> DocumentLocation: + """Where the value's own text starts: the line after the marker for a block scalar.""" + start = self._esp_range.start_mark + return DocumentLocation( + start.document, start.line + self.content_offset, start.column + ) + def from_node(self, node): # pylint: disable=attribute-defined-outside-init self._esp_range = DocumentRange.from_marks(node.start_mark, node.end_mark) - if ( - isinstance(node, yaml.ScalarNode) - and node.style is not None - and node.style in "|>" - ): + # Only block scalars (| and >) start their content on the next line. A plain + # scalar's style is "", which `in "|>"` would also match. + if isinstance(node, yaml.ScalarNode) and node.style in ("|", ">"): self._content_offset = 1 def from_database(self, database): diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 9529e448008..ec88c583baa 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -1,16 +1,19 @@ from collections.abc import Iterator +import io import math +from pathlib import Path import pytest -from esphome import cpp_generator as cg, cpp_types as ct +from esphome import config_validation as cv, cpp_generator as cg, cpp_types as ct from esphome.const import ( KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_ESP32, PLATFORM_ESP8266, ) -from esphome.core import CORE, ID, EsphomeError +from esphome.core import CORE, ID, LINE_DIRECTIVE_RESET, DocumentLocation, EsphomeError +from esphome.yaml_util import parse_yaml class TestExpressions: @@ -244,6 +247,22 @@ class TestLambdaExpression: "[captured_var](int32_t x) -> int32_t {\n return captured_var + x;\n}" ) + def test_str__with_source_marks_end_of_body(self) -> None: + """A lambda from the YAML marks the end of its body for the line reset.""" + target = cg.LambdaExpression( + ("return 42;",), (), "", int, source=DocumentLocation("test.yaml", 4, 0) + ) + + actual = str(target) + + assert actual == ( + "[]() -> int32_t {\n" + ' #line 5 "test.yaml"\n' + " return 42;\n" + f" {LINE_DIRECTIVE_RESET}\n" + "}" + ) + class TestCallLambda: """Tests for the call_lambda() function.""" @@ -735,6 +754,28 @@ class TestProcessLambda: with pytest.raises(AssertionError): await cg.process_lambda(lambda_obj, [(int,)]) + def test_str__with_line_mark_in_body_marks_end_of_body(self) -> None: + """A #line written into the body, as lvgl does, gets the reset too.""" + target = cg.LambdaExpression(('#line 3 "test.yaml"\nfoo();',), (), "", None) + + actual = str(target) + + assert actual == ( + f'[]() {{\n #line 3 "test.yaml"\n foo();\n {LINE_DIRECTIVE_RESET}\n}}' + ) + + async def test_process_lambda__twice_keeps_line(self) -> None: + """Processing one lambda twice, as a YAML alias does, keeps its line.""" + config = parse_yaml(Path("test.yaml"), io.StringIO("x: |-\n return 42;\n")) + lambda_obj = cv.lambda_(config["x"]) + + first = await cg.process_lambda(lambda_obj, []) + second = await cg.process_lambda(lambda_obj, []) + + # The body of the block scalar is the second line, 1 counted from 0 + assert first.source.line == 1 + assert second.source.line == 1 + @pytest.mark.asyncio async def test_templatable__string_with_std_string_returns_flash_literal() -> None: diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index a1ae7f9ec77..67acfa6a189 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -22,7 +22,9 @@ from esphome.const import ( PLATFORM_RP2, PLATFORM_RTL87XX, ) -from esphome.core import EsphomeError +from esphome.core import DocumentLocation, EsphomeError, line_directive +from esphome.cpp_generator import LambdaExpression +from esphome.helpers import indent from esphome.storage_json import StorageJSON from esphome.writer import ( CPP_AUTO_GENERATE_BEGIN, @@ -965,6 +967,32 @@ def test_write_cpp_creates_new_file( assert "App.loop();" in written_content +@pytest.mark.usefixtures("mock_copy_src_tree") +@patch("esphome.writer.write_file_if_changed") +@patch("esphome.writer.CORE") +def test_write_cpp_resets_line_after_lambda( + mock_core: MagicMock, + mock_write_file: MagicMock, + tmp_path: Path, +) -> None: + """The code after a YAML lambda is reported against main.cpp again.""" + main_cpp = tmp_path / "main.cpp" + mock_core.relative_src_path.return_value = main_cpp + mock_core.cpp_global_section = "" + lambda_ = LambdaExpression( + ("foo();",), (), source=DocumentLocation("test.yaml", 4, 0) + ) + + write_cpp(indent(f"bar({lambda_});\nbaz();") + "\n") + + lines = mock_write_file.call_args[0][1].split("\n") + closing = lines.index(" });") + assert lines[closing - 3].strip() == '#line 5 "test.yaml"' + # The directive names the line after it, which is closing + 1 (1-based) + assert lines[closing - 1].strip() == line_directive(str(main_cpp), closing + 1) + assert lines[closing + 1] == " baz();" + + @pytest.mark.usefixtures("mock_copy_src_tree") @patch("esphome.writer.CORE") def test_write_cpp_with_missing_end_marker( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 655fac6bf26..2a4b5f8e5c5 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -2317,3 +2317,21 @@ def test_find_secret_references_in_flow_collections() -> None: """Flow mapping and sequence punctuation is not part of the secret name.""" text = "a: {password: !secret wifi_pw}\nb: [!secret one, !secret 'two']\n" assert yaml_util.find_secret_references(text) == {"wifi_pw", "one", "two"} + + +@pytest.mark.parametrize( + ("yaml_text", "expected_offset"), + [ + ("value: return 42;\n", 0), + ('value: "return 42;"\n', 0), + ("value: |-\n return 42;\n", 1), + ("value: >-\n return 42;\n", 1), + ], +) +def test_content_offset_only_for_block_scalars( + yaml_text: str, expected_offset: int +) -> None: + """A one-line lambda starts on its own line; only | and > move it to the next.""" + doc = yaml_util.parse_yaml(Path("test.yaml"), io.StringIO(yaml_text)) + assert doc["value"].content_offset == expected_offset + assert doc["value"].content_start.line == expected_offset From 1779b011c9bbb55b0d441f8296b740189487e4e4 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:21:45 +0000 Subject: [PATCH 17/19] Bump aioesphomeapi from 46.10.0 to 46.11.0 (#20766) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5397959f63f..a247eb4ba15 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.5.0 click==8.5.0 -aioesphomeapi==46.10.0 +aioesphomeapi==46.11.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 6d173c7371a00f76e39d1f46faaa5cdd2ec274d5 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:35:01 +0100 Subject: [PATCH 18/19] [bq25186] Set i2c_id in the component tests (#20742) --- tests/components/bq25186/common.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml index 295a68d262b..916fedec3d1 100644 --- a/tests/components/bq25186/common.yaml +++ b/tests/components/bq25186/common.yaml @@ -12,6 +12,7 @@ esphome: bq25186: - id: bq25186_charger + i2c_id: i2c_bus configure_on_boot: false fast_charge_current: 150mA battery_regulation_voltage: 4.2V @@ -20,6 +21,7 @@ bq25186: charge_enabled: true # Second charger with the boot-time write enabled - id: bq25186_boot + i2c_id: i2c_bus address: 0x6B configure_on_boot: true fast_charge_current: 20mA From a4278b8decaa087f593f4a50418e8b2167e2df31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 10:35:33 -1000 Subject: [PATCH 19/19] [ci] Run benchmarks when a component the Python import benchmark imports changes (#20763) --- script/determine-jobs.py | 68 ++++++++++++++++++++++++ tests/script/test_determine_jobs.py | 82 +++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 9d146c94a0b..e1483446d4d 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -49,6 +49,7 @@ Options: from __future__ import annotations import argparse +import ast from collections import Counter from collections.abc import Callable from enum import StrEnum @@ -58,6 +59,7 @@ import math import os from pathlib import Path import statistics +import subprocess import sys from typing import Any @@ -69,6 +71,7 @@ from clang_tidy_hash import ( ) from helpers import ( CPP_FILE_EXTENSIONS, + ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH, INTEGRATION_TESTS_PATH, PYTHON_FILE_EXTENSIONS, @@ -805,6 +808,9 @@ def determine_cpp_unit_tests( # Paths within tests/benchmarks/ that contain component benchmark files BENCHMARKS_COMPONENTS_PATH = "tests/benchmarks/components" +# The Python benchmark that imports components; its COMPONENTS tuple says which ones +PYTHON_IMPORT_BENCHMARK_FILE = "tests/benchmarks/python/test_component_import_bench.py" + # Files that, when changed, should trigger benchmark runs BENCHMARK_INFRASTRUCTURE_FILES = frozenset( { @@ -815,6 +821,53 @@ BENCHMARK_INFRASTRUCTURE_FILES = frozenset( ) +def _benchmark_components_tuple(path: Path) -> frozenset[str]: + """Return the benchmark file's ``COMPONENTS`` tuple. + + Raises when the file has none, so renaming it fails CI instead of quietly + turning the trigger off. + """ + for node in ast.parse(path.read_text(encoding="utf-8")).body: + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "COMPONENTS" for t in node.targets + ): + return frozenset(ast.literal_eval(node.value)) + raise RuntimeError(f"{PYTHON_IMPORT_BENCHMARK_FILE} has no COMPONENTS tuple") + + +# Imports the given components and prints every component whose module got loaded +_IMPORT_PROBE = ( + "import importlib, sys\n" + "for c in sys.argv[1:]: importlib.import_module('esphome.components.' + c)\n" + "print(*{n.split('.')[2] for n in sys.modules if n.startswith('esphome.components.')})" +) + + +def python_import_benchmarked_components() -> frozenset[str] | None: + """Return the components loaded by importing the benchmark's ``COMPONENTS``. + + Imported in a fresh interpreter from the checkout, so the set is exactly what + the benchmark times. None when the import fails. + """ + path = Path(root_path) / PYTHON_IMPORT_BENCHMARK_FILE + if not path.is_file(): + return frozenset() + result = subprocess.run( + [sys.executable, "-c", _IMPORT_PROBE, *_benchmark_components_tuple(path)], + cwd=root_path, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print( + f"Benchmark import probe failed, running benchmarks:\n{result.stderr}", + file=sys.stderr, + ) + return None + return frozenset(result.stdout.split()) + + def should_run_benchmarks(branch: str | None = None) -> bool: """Determine if benchmarks (C++ and Python) should run based on changed files. @@ -831,6 +884,8 @@ def should_run_benchmarks(branch: str | None = None) -> bool: 4. A directly changed component has benchmark files (no dependency expansion) 5. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, script/build_helpers.py, script/setup_codspeed_lib.py) + 6. A Python file changed in a component that importing the Python import + benchmark's ``COMPONENTS`` loads, even if it has no C++ benchmark Unlike unit tests, benchmarks do NOT expand to dependent components. Changing ``sensor`` does not trigger ``api`` benchmarks just because @@ -862,6 +917,19 @@ def should_run_benchmarks(branch: str | None = None) -> bool: ): return True + # The Python import benchmark times importing its components and what they pull + # in, so their Python sources move its numbers even without a C++ benchmark. + # A failed import runs the benchmarks rather than risk skipping them. + changed_python = { + get_component_from_path(f) + for f in files + if f.startswith(ESPHOME_COMPONENTS_PATH) and f.endswith(PYTHON_FILE_EXTENSIONS) + } + if changed_python: + loaded = python_import_benchmarked_components() + if loaded is None or changed_python & loaded: + return True + # Check if any directly changed component has benchmarks benchmarks_dir = Path(root_path) / BENCHMARKS_COMPONENTS_PATH if not benchmarks_dir.is_dir(): diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index e63e0849deb..6b63712f2a4 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -4,6 +4,7 @@ from collections.abc import Generator import importlib.util import json from pathlib import Path +import subprocess import sys from unittest.mock import Mock, patch @@ -2784,6 +2785,87 @@ def test_should_run_benchmarks_core_benchmark_file_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_python_import_benchmarked_components_imports_real_components() -> None: + """The set is what importing the real benchmark's components loads.""" + loaded = determine_jobs.python_import_benchmarked_components() + # mqtt is not listed in COMPONENTS; sensor imports it at module scope + assert {"binary_sensor", "sensor", "logger", "mqtt"} <= loaded + + +def test_should_run_benchmarks_python_import_benchmarked_component() -> None: + """A Python change in a component the import benchmark loads runs benchmarks. + + logger has no C++ benchmark, so before this a change to its __init__.py + skipped the Python import benchmark that times it. + """ + for changed, expected in [ + ("esphome/components/logger/__init__.py", True), + ("esphome/components/logger/select/__init__.py", True), + ("esphome/components/mqtt/__init__.py", True), + ("esphome/components/logger/logger.cpp", False), + ("tests/components/logger/common.yaml", False), + ("esphome/components/wifi/__init__.py", False), + ]: + with ( + patch.object(determine_jobs, "changed_files", return_value=[changed]), + patch.object( + determine_jobs, + "python_import_benchmarked_components", + return_value=frozenset({"logger", "mqtt"}), + ), + ): + assert determine_jobs.should_run_benchmarks() is expected, changed + + +def test_should_run_benchmarks_when_import_probe_fails() -> None: + """A component import that fails runs the benchmarks instead of skipping them.""" + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/components/wifi/__init__.py"], + ), + patch.object( + determine_jobs, "python_import_benchmarked_components", return_value=None + ), + ): + assert determine_jobs.should_run_benchmarks() is True + + +def test_python_import_benchmarked_components_logs_probe_failure( + capsys: pytest.CaptureFixture[str], +) -> None: + """A failed probe returns None and prints why on stderr, never stdout.""" + failed = subprocess.CompletedProcess( + args=[], returncode=1, stdout="", stderr="ModuleNotFoundError: boom" + ) + with patch.object(determine_jobs.subprocess, "run", return_value=failed): + assert determine_jobs.python_import_benchmarked_components() is None + captured = capsys.readouterr() + assert "ModuleNotFoundError: boom" in captured.err + assert captured.out == "" + + +def test_python_import_benchmarked_components_missing_file(tmp_path: Path) -> None: + """Without the benchmark file no component is treated as benchmarked.""" + with patch.object(determine_jobs, "root_path", str(tmp_path)): + assert determine_jobs.python_import_benchmarked_components() == frozenset() + + +def test_python_import_benchmarked_components_requires_components( + tmp_path: Path, +) -> None: + """A benchmark file without COMPONENTS fails loudly instead of disabling the rule.""" + bench = tmp_path / determine_jobs.PYTHON_IMPORT_BENCHMARK_FILE + bench.parent.mkdir(parents=True) + bench.write_text('NAMES = ("logger",)\n') + with ( + patch.object(determine_jobs, "root_path", str(tmp_path)), + pytest.raises(RuntimeError, match="COMPONENTS"), + ): + determine_jobs.python_import_benchmarked_components() + + def test_should_run_benchmarks_benchmarked_component_change(tmp_path: Path) -> None: """Test benchmarks trigger when a benchmarked component changes.""" # Create a fake benchmarks directory with an 'api' component