From 1ddacb49310eb212df6227fcd731631f166b5b0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 13:24:34 -1000 Subject: [PATCH 001/179] [tests] Set i2c_id in the component tests that clash with tca9548a (#20963) --- tests/components/bq27220/common.yaml | 1 + tests/components/bq76972/common.yaml | 1 + tests/components/fusb302b/common.yaml | 1 + tests/components/max17048/common.yaml | 1 + tests/components/spd2010/common.yaml | 3 +++ 5 files changed, 7 insertions(+) diff --git a/tests/components/bq27220/common.yaml b/tests/components/bq27220/common.yaml index 99d1e1f7f85..fad1ef169cd 100644 --- a/tests/components/bq27220/common.yaml +++ b/tests/components/bq27220/common.yaml @@ -1,6 +1,7 @@ sensor: - platform: bq27220 id: bq27220_id + i2c_id: i2c_bus voltage: name: "Battery Voltage" current: diff --git a/tests/components/bq76972/common.yaml b/tests/components/bq76972/common.yaml index 42a9f633aff..4b2e62eb20b 100644 --- a/tests/components/bq76972/common.yaml +++ b/tests/components/bq76972/common.yaml @@ -1,5 +1,6 @@ bq76972: id: bq76972_id + i2c_id: i2c_bus address: 0x08 crc_enabled: true reg_disabled: true diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml index 94e5f2da2bc..e2c4ee87ca7 100644 --- a/tests/components/fusb302b/common.yaml +++ b/tests/components/fusb302b/common.yaml @@ -1,5 +1,6 @@ fusb302b: id: pd + i2c_id: i2c_bus interrupt_pin: ${interrupt_pin} request_voltage: 20 contract: diff --git a/tests/components/max17048/common.yaml b/tests/components/max17048/common.yaml index de0247ed4e2..628bf7ce5c2 100644 --- a/tests/components/max17048/common.yaml +++ b/tests/components/max17048/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: max17048 + i2c_id: i2c_bus battery_voltage: name: Battery Voltage battery_level: diff --git a/tests/components/spd2010/common.yaml b/tests/components/spd2010/common.yaml index 0692137638d..2bd89ebfccd 100644 --- a/tests/components/spd2010/common.yaml +++ b/tests/components/spd2010/common.yaml @@ -1,15 +1,18 @@ display: - platform: ssd1306_i2c id: spd2010_display + i2c_id: i2c_bus model: SSD1306_128X64 touchscreen: - platform: spd2010 + i2c_id: i2c_bus id: spd2010_touchscreen display: spd2010_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} - platform: spd2010 + i2c_id: i2c_bus id: spd2010_polling_touchscreen display: spd2010_display address: 0x54 From b3f5789dc6e6da005a63b406961303015a8a17cf Mon Sep 17 00:00:00 2001 From: Hasan Khalil Date: Fri, 16 Oct 2026 19:39:44 -0400 Subject: [PATCH 002/179] [mipi_rgb] direct-to-PSRAM fast rotated blitting (#20905) --- esphome/components/mipi_rgb/display.py | 2 +- esphome/components/mipi_rgb/mipi_rgb.cpp | 50 +++++++----- esphome/components/mipi_rgb/mipi_rgb.h | 21 +---- .../components/mipi_rgb/mipi_rgb_blitter.h | 76 +++++++++++++++++++ .../mipi_rgb/test_mipi_rgb_config.py | 10 +-- tests/components/mipi_rgb/__init__.py | 5 ++ .../mipi_rgb/test.esp32-s3-idf.yaml | 1 + tests/components/mipi_rgb/test_blitter.cpp | 49 ++++++++++++ 8 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 esphome/components/mipi_rgb/mipi_rgb_blitter.h create mode 100644 tests/components/mipi_rgb/__init__.py create mode 100644 tests/components/mipi_rgb/test_blitter.cpp diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 6e508dd7c44..e973d78ae4d 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -242,7 +242,7 @@ def _config_schema(config: ConfigType) -> ConfigType: config[CONF_ID], width, height, - model.rotation_as_transform(config), + has_hardware_rotation=True, byte_order=config[CONF_BYTE_ORDER], has_writer=requires_buffer(config) or config.get(CONF_AUTO_CLEAR_ENABLED) is True, diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index a38404030ad..beb39ce8473 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -175,6 +175,15 @@ void MipiRgb::common_setup_() { err = esp_lcd_panel_reset(this->handle_); if (err == ESP_OK) err = esp_lcd_panel_init(this->handle_); + if (err == ESP_OK) { + void *fb = nullptr; + err = esp_lcd_rgb_panel_get_frame_buffer(this->handle_, 1, &fb); + if (err == ESP_OK && fb != nullptr) { + this->fb_ = reinterpret_cast(fb); + } else if (err == ESP_OK) { + err = ESP_ERR_NO_MEM; + } + } if (err != ESP_OK) { ESP_LOGE(TAG, "lcd setup failed: %s", esp_err_to_name(err)); this->mark_failed(LOG_STR("lcd setup failed")); @@ -223,30 +232,31 @@ void MipiRgb::draw_pixels_internal(int x_start, int y_start, int w, int h, const int x_pad) { if (w <= 0 || h <= 0 || this->is_failed()) return; - if (bitness != display::COLOR_BITNESS_565 || big_endian != this->big_endian_ || - this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { + if (bitness != display::COLOR_BITNESS_565 || big_endian != this->big_endian_ || this->is_clipping()) { Display::draw_pixels_internal(x_start, y_start, w, h, ptr, bitness, big_endian, x_offset, y_offset, x_pad); this->mark_dirty_(x_start, y_start, w, h); this->flush_dirty_(); - } else if (this->buffer_ != nullptr) { - // With a buffer the block goes through it, or a flush of the old contents would paint over it - const int first_col = std::max(0, -x_start), end_col = std::min(w, static_cast(this->width_) - x_start); - const int first_row = std::max(0, -y_start), end_row = std::min(h, static_cast(this->height_) - y_start); - if (first_col >= end_col || first_row >= end_row) - return; - const size_t stride = x_offset + w + x_pad; - for (int y = first_row; y != end_row; y++) { - memcpy(this->buffer_ + (y_start + y) * this->width_ + x_start + first_col, - ptr + ((y_offset + y) * stride + x_offset + first_col) * 2, (end_col - first_col) * 2); + return; + } + + const int log_w = this->get_width(); + const int log_h = this->get_height(); + + if (x_start < 0 || y_start < 0 || x_start + w > log_w || y_start + h > log_h) { + return; + } + + uint16_t *dst = this->buffer_ != nullptr ? this->buffer_ : this->fb_; + const size_t stride = static_cast(x_offset) + w + x_pad; + const auto *src_origin = + reinterpret_cast(ptr) + (static_cast(y_offset) * stride + x_offset); + + blit_rotated_565(dst, this->width_, this->height_, this->rotation_, src_origin, x_start, y_start, w, h, stride); + if (dst == this->buffer_) { + this->mark_dirty_(x_start, y_start, w, h); + if (!this->updating_) { + this->flush_dirty_(); } - if (this->updating_) { - this->mark_dirty_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row); - } else { - this->write_to_display_(x_start + first_col, y_start + first_row, end_col - first_col, end_row - first_row, ptr, - x_offset + first_col, y_offset + first_row, x_pad + w - end_col); - } - } else { - this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } } diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index f6aec76976b..199dfcfbfc7 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -5,6 +5,7 @@ #include "esphome/core/log.h" #include "esphome/components/display/display.h" #include "esphome/components/display/display_color_utils.h" +#include "mipi_rgb_blitter.h" #include #include #ifdef USE_SPI @@ -83,24 +84,7 @@ class MipiRgb : public display::Display { /// Send the region touched since the last flush to the panel. void flush_dirty_(); // Maps drawn coordinates to the buffer the way draw_pixel_at() stores them - void rotate_(int &x, int &y) const { - switch (this->rotation_) { - case display::DISPLAY_ROTATION_0_DEGREES: - break; - case display::DISPLAY_ROTATION_90_DEGREES: - std::swap(x, y); - x = this->width_ - x - 1; - break; - case display::DISPLAY_ROTATION_180_DEGREES: - x = this->width_ - x - 1; - y = this->height_ - y - 1; - break; - case display::DISPLAY_ROTATION_270_DEGREES: - std::swap(x, y); - y = this->height_ - y - 1; - break; - } - } + void rotate_(int &x, int &y) const { rotate_point(x, y, this->width_, this->height_, this->rotation_); } void mark_dirty_(int x_start, int y_start, int w, int h); void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void setup_enables_(); @@ -134,6 +118,7 @@ class MipiRgb : public display::Display { uint16_t y_high_{0}; esp_lcd_panel_handle_t handle_{}; + uint16_t *fb_{nullptr}; }; #ifdef USE_SPI diff --git a/esphome/components/mipi_rgb/mipi_rgb_blitter.h b/esphome/components/mipi_rgb/mipi_rgb_blitter.h new file mode 100644 index 00000000000..1f64b40f5b6 --- /dev/null +++ b/esphome/components/mipi_rgb/mipi_rgb_blitter.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include +#include +#include +#include +#include + +namespace esphome::mipi_rgb { + +inline void rotate_point(int &x, int &y, int width, int height, display::DisplayRotation rotation) { + switch (rotation) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = width - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = width - x - 1; + y = height - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = height - y - 1; + break; + } +} + +inline void blit_rotated_565(uint16_t *fb, int fb_width, int fb_height, display::DisplayRotation rotation, + const uint16_t *src_origin, int x_start, int y_start, int w, int h, size_t stride) { + switch (rotation) { + case display::DISPLAY_ROTATION_0_DEGREES: { + for (int py = 0; py < h; py++) { + memcpy(&fb[(y_start + py) * fb_width + x_start], src_origin + static_cast(py) * stride, + w * sizeof(uint16_t)); + } + break; + } + case display::DISPLAY_ROTATION_90_DEGREES: { + const size_t x_dest_offset = fb_width - y_start - h; + for (int px = 0; px < w; px++) { + uint16_t *dest = &fb[(x_start + px) * fb_width + x_dest_offset]; + for (int py = 0; py < h; py++) { + *dest++ = src_origin[static_cast(h - 1 - py) * stride + px]; + } + } + break; + } + case display::DISPLAY_ROTATION_180_DEGREES: { + const size_t x_dest_offset = fb_width - x_start - w; + for (int py = 0; py < h; py++) { + uint16_t *dest = &fb[(fb_height - 1 - (y_start + py)) * fb_width + x_dest_offset]; + const uint16_t *src_row = src_origin + static_cast(py) * stride; + for (int px = 0; px < w; px++) { + *dest++ = src_row[w - 1 - px]; + } + } + break; + } + case display::DISPLAY_ROTATION_270_DEGREES: { + for (int px = 0; px < w; px++) { + uint16_t *dest = &fb[(fb_height - 1 - x_start - px) * fb_width + y_start]; + const uint16_t *s = src_origin + px; + for (int py = 0; py < h; py++) { + *dest++ = *s; + s += stride; + } + } + break; + } + } +} + +} // namespace esphome::mipi_rgb diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py index 58e7ba9d959..96da849e3c4 100644 --- a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -139,14 +139,14 @@ def test_st7701s_only_supports_mirror_x( CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) -def test_pure_rgb_model_has_no_hardware_transform( +def test_pure_rgb_model_has_hardware_rotation( set_core_config: SetCoreConfigCallable, ) -> None: """A model with no init sequence has no command interface, so no transform - can be applied in hardware and every rotation must be done in software. + can be applied in panel silicon (MADCTL). - Previously such models claimed mirror_x/mirror_y support, so a rotation of - 180 was handed to the (nonexistent) hardware and the display was not rotated. + The driver itself handles rotation directly in the framebuffer without + allocating software rotation buffers, so has_hardware_rotation is True. """ _set_s3(set_core_config) @@ -162,7 +162,7 @@ def test_pure_rgb_model_has_no_hardware_transform( assert not model.rotation_as_transform(config) metadata = get_display_metadata(config["id"]) assert metadata.rotation == rotation - assert not metadata.has_hardware_rotation + assert metadata.has_hardware_rotation with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"): CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) diff --git a/tests/components/mipi_rgb/__init__.py b/tests/components/mipi_rgb/__init__.py new file mode 100644 index 00000000000..584c6e74b42 --- /dev/null +++ b/tests/components/mipi_rgb/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["display"] diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index 12b45ee160e..8a3a6fd3237 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -9,3 +9,4 @@ ch422g: display: - platform: mipi_rgb model: WAVESHARE-5-1024X600 + rotation: 90 diff --git a/tests/components/mipi_rgb/test_blitter.cpp b/tests/components/mipi_rgb/test_blitter.cpp new file mode 100644 index 00000000000..48da6d12199 --- /dev/null +++ b/tests/components/mipi_rgb/test_blitter.cpp @@ -0,0 +1,49 @@ +#include +#include +#include "esphome/components/mipi_rgb/mipi_rgb_blitter.h" + +namespace esphome::mipi_rgb::testing { + +static void test_rotation(display::DisplayRotation rotation) { + constexpr int fb_width = 40; + constexpr int fb_height = 30; + constexpr int w = 7, h = 5, x_offset = 2, y_offset = 3, x_pad = 4; + constexpr size_t stride = x_offset + w + x_pad; + constexpr size_t lines = y_offset + h; + + std::vector source(stride * lines); + uint16_t val = 0x1000; + for (size_t y = 0; y < lines; y++) { + for (size_t x = 0; x < stride; x++) { + source[y * stride + x] = val++; + } + } + + int x_start = 10; + int y_start = 8; + + // Run blit_rotated_565 + std::vector fb_actual(fb_width * fb_height, 0); + const uint16_t *src_origin = source.data() + (y_offset * stride + x_offset); + blit_rotated_565(fb_actual.data(), fb_width, fb_height, rotation, src_origin, x_start, y_start, w, h, stride); + + // Compute reference per-pixel using rotate_point + std::vector fb_expected(fb_width * fb_height, 0); + for (int py = 0; py < h; py++) { + for (int px = 0; px < w; px++) { + int rx = x_start + px; + int ry = y_start + py; + rotate_point(rx, ry, fb_width, fb_height, rotation); + fb_expected[ry * fb_width + rx] = src_origin[py * stride + px]; + } + } + + EXPECT_EQ(fb_actual, fb_expected); +} + +TEST(MipiRgbBlitter, Rotation0) { test_rotation(display::DISPLAY_ROTATION_0_DEGREES); } +TEST(MipiRgbBlitter, Rotation90) { test_rotation(display::DISPLAY_ROTATION_90_DEGREES); } +TEST(MipiRgbBlitter, Rotation180) { test_rotation(display::DISPLAY_ROTATION_180_DEGREES); } +TEST(MipiRgbBlitter, Rotation270) { test_rotation(display::DISPLAY_ROTATION_270_DEGREES); } + +} // namespace esphome::mipi_rgb::testing From b3ef5cf2f3cefbe2da7b5b2d8eb36779292e915b Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Sat, 17 Oct 2026 01:58:12 +0200 Subject: [PATCH 003/179] [online_image] Report an error when a download can never complete (#20594) Co-authored-by: J. Nick Koston Co-authored-by: guillempages Co-authored-by: J. Nick Koston --- .../components/online_image/online_image.cpp | 90 ++++-- .../components/online_image/online_image.h | 2 + .../components/runtime_image/bmp_decoder.h | 9 +- .../components/runtime_image/image_decoder.h | 3 + .../components/runtime_image/qoi_decoder.h | 9 +- .../runtime_image/runtime_image.cpp | 2 + .../components/runtime_image/runtime_image.h | 3 + tests/components/online_image/__init__.py | 14 + .../online_image/test_download_end.cpp | 289 ++++++++++++++++++ .../online_image_stalled_download.yaml | 54 ++++ .../test_online_image_stalled_download.py | 121 ++++++++ 11 files changed, 566 insertions(+), 30 deletions(-) create mode 100644 tests/components/online_image/__init__.py create mode 100644 tests/components/online_image/test_download_end.cpp create mode 100644 tests/integration/fixtures/online_image_stalled_download.yaml create mode 100644 tests/integration/test_online_image_stalled_download.py diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 4497d5cad12..59c1db38a58 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -1,5 +1,6 @@ #include "online_image.h" #include "esphome/components/runtime_image/image_decoder.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -51,6 +52,13 @@ void OnlineImage::update() { return; } + // A failed allocation at boot, or while growing the buffer for a JPEG, leaves no download buffer + if (this->download_buffer_.size() == 0 && this->download_buffer_.resize(this->download_buffer_initial_size_) == 0) { + // resize() logged the failed allocation + this->download_error_callback_.call(); + return; + } + ESP_LOGD(TAG, "Updating image from %s", this->url_.c_str()); std::vector headers; @@ -133,12 +141,17 @@ void OnlineImage::update() { } // JPEG requires the complete image in the download buffer before decoding - if (format == runtime_image::JPEG && total_size > this->download_buffer_.size()) { - this->download_buffer_.resize(total_size); + if (format == runtime_image::JPEG && total_size > this->download_buffer_.size() && + this->download_buffer_.resize(total_size) == 0) { + // resize() logged the failed allocation; the next update() allocates the configured buffer again + this->end_connection_(); + this->download_error_callback_.call(); + return; } ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size); this->start_time_ = millis(); + this->last_data_time_ = this->start_time_; this->enable_loop(); } @@ -161,18 +174,7 @@ void OnlineImage::loop() { if (this->is_decode_finished() || (this->downloader_->content_length > 0 && this->downloader_->get_bytes_read() >= this->downloader_->content_length && this->download_buffer_.unread() == 0)) { - // Finalize decoding - this->end_decode(); - - ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(), - millis() - this->start_time_); - - // Save caching headers - this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME); - this->last_modified_ = this->downloader_->get_response_header(LAST_MODIFIED_HEADER_NAME); - - this->download_finished_callback_.call(false); - this->end_connection_(); + this->finish_download_(); return; } @@ -184,6 +186,7 @@ void OnlineImage::loop() { auto len = this->downloader_->read(this->download_buffer_.append(), available); if (len > 0) { + this->last_data_time_ = App.get_loop_component_start_time(); this->download_buffer_.write(len); // Feed data to decoder @@ -204,25 +207,76 @@ void OnlineImage::loop() { this->end_connection_(); this->download_error_callback_.call(); return; + } else if (this->downloader_->is_read_complete()) { + // The response has ended, so data left in the buffer can never be completed + size_t unread = this->download_buffer_.unread(); + if (unread == 0 && this->downloader_->get_bytes_read() > 0) { + // A decoder that counts pixels knows the image was cut off; PNG cannot tell, so it passes + if (this->is_decode_finished() || !this->decoder_knows_end()) { + this->finish_download_(); + return; + } + ESP_LOGE(TAG, "Download ended before the image was complete"); + } else if (unread == 0) { + ESP_LOGE(TAG, "Server sent an empty response"); + } else { + ESP_LOGE(TAG, "Download ended with %zu bytes the decoder could not use", unread); + } + this->end_connection_(); + this->download_error_callback_.call(); + return; + } else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) { + // ESP-IDF keeps returning 0 after the server closes the connection early + ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout()); + this->end_connection_(); + this->download_error_callback_.call(); + return; } } else { // Buffer is full, need to decode some data first auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread()); if (consumed > 0) { + this->last_data_time_ = App.get_loop_component_start_time(); this->download_buffer_.read(consumed); } else if (consumed < 0) { ESP_LOGE(TAG, "Decode error with full buffer: %d", consumed); this->end_connection_(); this->download_error_callback_.call(); return; - } else { - // Decoder can't process more data, might need complete image - // This is normal for JPEG which needs complete data - ESP_LOGV(TAG, "Decoder waiting for more data"); + } else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) { + // The decoder needs more data than the buffer can hold, so it can never make progress. The + // no-data timeout gives a decoder that only had to wait the chance to show otherwise. + ESP_LOGE(TAG, "Image does not fit in the %zu byte download buffer; increase buffer_size", + this->download_buffer_.size()); + this->end_connection_(); + this->download_error_callback_.call(); + return; } } } +void OnlineImage::finish_download_() { + // Must be read before end_decode() ends the decoding session + bool decoder_finished = this->is_decode_finished(); + this->end_decode(); + + ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(), + millis() - this->start_time_); + + // Only cache an image the decoder confirmed as complete; otherwise a cut-off image would be kept by 304 responses + if (decoder_finished) { + this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME); + this->last_modified_ = this->downloader_->get_response_header(LAST_MODIFIED_HEADER_NAME); + } else { + ESP_LOGW(TAG, "Decoder could not confirm the image is complete; not caching it"); + this->etag_.clear(); + this->last_modified_.clear(); + } + + this->download_finished_callback_.call(false); + this->end_connection_(); +} + void OnlineImage::end_connection_() { // Abort any in-progress decode; the decoder object is kept warm for the next decode. // Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release(). diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index 8b3485b8f74..7397c6c5c32 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -80,6 +80,7 @@ class OnlineImage final : public PollingComponent, protected: bool validate_url_(const std::string &url); void end_connection_(); + void finish_download_(); CallbackManager download_finished_callback_{}; CallbackManager download_error_callback_{}; @@ -107,6 +108,7 @@ class OnlineImage final : public PollingComponent, std::string last_modified_; uint32_t start_time_{0}; + uint32_t last_data_time_{0}; }; } // namespace esphome::online_image diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index 01acc41f91f..cea7f11c784 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -26,13 +26,10 @@ class BmpDecoder : public ImageDecoder { void reset() override; int HOT decode(uint8_t *buffer, size_t size) override; + /// The header gives the pixel count, so the end is known once it has been parsed + bool knows_end() const override { return this->bits_per_pixel_ != 0; } bool is_finished() const override { - if (this->bits_per_pixel_ == 0) { - // header not yet received, so dimensions not yet determined - return false; - } - // BMP is finished when we've decoded all pixel data - return this->paint_index_ >= static_cast(this->width_ * this->height_); + return this->knows_end() && this->paint_index_ >= static_cast(this->width_ * this->height_); } protected: diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 8e0399ac354..6472638261e 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -126,6 +126,9 @@ class ImageDecoder { return false; } + /// Whether is_finished() can tell a complete image from a cut-off one + virtual bool knows_end() const { return this->expected_size_ > 0; } + protected: /// The image's pixel buffer, for decoders that write their output straight into it uint8_t *image_buffer_() const; diff --git a/esphome/components/runtime_image/qoi_decoder.h b/esphome/components/runtime_image/qoi_decoder.h index c44afd83cf2..e66b10027d8 100644 --- a/esphome/components/runtime_image/qoi_decoder.h +++ b/esphome/components/runtime_image/qoi_decoder.h @@ -25,13 +25,10 @@ class QoiDecoder : public ImageDecoder { void reset() override; int HOT decode(uint8_t *buffer, size_t size) override; + /// The header gives the pixel count, so the end is known once it has been parsed + bool knows_end() const override { return this->bits_per_pixel_ != 0; } bool is_finished() const override { - if (this->bits_per_pixel_ == 0) { - // header not yet received, so dimensions not yet determined - return false; - } - // QOI is finished when we've decoded all pixel data - return this->paint_index_ >= static_cast(this->width_ * this->height_); + return this->knows_end() && this->paint_index_ >= static_cast(this->width_ * this->height_); } protected: diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 14e0d5665a6..1c6a2bc6002 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -262,6 +262,8 @@ bool RuntimeImage::end_decode() { bool RuntimeImage::is_decode_finished() const { return this->is_decoding() && this->decoder_->is_finished(); } +bool RuntimeImage::decoder_knows_end() const { return this->is_decoding() && this->decoder_->knows_end(); } + void RuntimeImage::release() { this->release_buffer_(); // End any active decode session; decoders free the format-specific working buffers diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 2a3ab40a5c1..4dc60213649 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -96,6 +96,9 @@ class RuntimeImage : public image::Image { */ bool is_decode_finished() const; + /// True while decoding and the decoder can tell a complete image from a cut-off one + bool decoder_knows_end() const; + /** * @brief Check if an image is currently loaded. */ diff --git a/tests/components/online_image/__init__.py b/tests/components/online_image/__init__.py new file mode 100644 index 00000000000..8635a2ba4ee --- /dev/null +++ b/tests/components/online_image/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.runtime_image import get_format +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The real to_code needs an image config. The unit tests decode BMP and PNG, and use + # JPEG for the download buffer resize, so compile in those three decoders. + async def to_code_testing(config: ConfigType) -> None: + get_format("BMP").actions() + get_format("JPEG").actions() + get_format("PNG").actions() + + manifest.to_code = to_code_testing diff --git a/tests/components/online_image/test_download_end.cpp b/tests/components/online_image/test_download_end.cpp new file mode 100644 index 00000000000..d844cf845e8 --- /dev/null +++ b/tests/components/online_image/test_download_end.cpp @@ -0,0 +1,289 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/http_request/http_request.h" +#include "esphome/components/online_image/online_image.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::online_image::testing { + +// Serves a fixed body, handing out at most `available` bytes of it. +class FakeContainer : public http_request::HttpContainer { + public: + FakeContainer(std::vector body, size_t available, bool chunked) + : body_(std::move(body)), available_(std::min(available, this->body_.size())) { + this->status_code = 200; + this->content_length = chunked ? 0 : this->body_.size(); + this->set_chunked(chunked); + } + + int read(uint8_t *buf, size_t max_len) override { + size_t len = std::min(max_len, this->available_ - this->bytes_read_); + std::memcpy(buf, this->body_.data() + this->bytes_read_, len); + this->bytes_read_ += len; + return static_cast(len); + } + void end() override {} + + // Like ESP-IDF: a chunked response is complete once its last chunk has been read + bool is_read_complete() const override { + if (this->is_chunked_) + return this->bytes_read_ >= this->body_.size(); + return HttpContainer::is_read_complete(); + } + + void set_content_length(size_t length) { this->content_length = length; } + void add_response_header(const std::string &name, const std::string &value) { + this->response_headers_.push_back({name, value}); + } + + protected: + std::vector body_; + size_t available_; +}; + +class FakeHttpRequest : public http_request::HttpRequestComponent { + public: + std::shared_ptr next; + std::vector sent_headers; + + bool sent_header(const std::string &name) const { + return std::any_of(this->sent_headers.begin(), this->sent_headers.end(), + [&name](const http_request::Header &header) { return header.name == name; }); + } + + protected: + std::shared_ptr perform(const char *url, const char *method, const std::string &body, + const std::vector &request_headers, + const std::vector &collect_headers, + uint32_t timeout) override { + this->sent_headers = request_headers; + return std::move(this->next); + } +}; + +// A 24-bit BMP, 2x2 pixels: 54 header bytes, then two rows of 6 pixel bytes and 2 padding bytes. +static constexpr size_t BMP_HEADER_SIZE = 54; +static constexpr size_t BMP_ROW_SIZE = 8; + +static std::vector make_bmp() { + std::vector bmp(BMP_HEADER_SIZE + 2 * BMP_ROW_SIZE, 0); + auto put32 = [&bmp](size_t offset, uint32_t value) { + for (size_t i = 0; i < 4; i++) + bmp[offset + i] = (value >> (8 * i)) & 0xFF; + }; + bmp[0] = 'B'; + bmp[1] = 'M'; + put32(2, bmp.size()); + put32(10, BMP_HEADER_SIZE); + put32(14, 40); // DIB header size + put32(18, 2); // width + put32(22, 2); // height + bmp[26] = 1; // color planes + bmp[28] = 24; // bits per pixel + return bmp; +} + +// An 8x8, 8-bit BMP whose 256-color table puts the pixels at offset 1078, past a 256 byte buffer. +static std::vector make_palette_bmp() { + constexpr size_t data_offset = 14 + 40 + 256 * 4; + std::vector bmp(data_offset + 8 * 8, 0); + auto put32 = [&bmp](size_t offset, uint32_t value) { + for (size_t i = 0; i < 4; i++) + bmp[offset + i] = (value >> (8 * i)) & 0xFF; + }; + bmp[0] = 'B'; + bmp[1] = 'M'; + put32(2, bmp.size()); + put32(10, data_offset); + put32(14, 40); // DIB header size + put32(18, 8); // width + put32(22, 8); // height + bmp[26] = 1; // color planes + bmp[28] = 8; // bits per pixel + put32(34, 64); // image size + put32(46, 256); // colors in the table + return bmp; +} + +// A 1x1 RGB PNG: 8 signature bytes, a 25 byte IHDR chunk, a 24 byte IDAT chunk and a 12 byte IEND chunk. +static constexpr size_t PNG_HEADER_SIZE = 8 + 25; +static constexpr std::array PNG_1X1{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, + 0x0C, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0xC9, + 0xFE, 0x92, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82}; + +static void set_loop_time(uint32_t now) { LoopBlockingGuard clock(nullptr, LOG_STR("test"), now); } + +class OnlineImageDownloadEnd : public ::testing::Test { + protected: + std::unique_ptr make_image_(runtime_image::ImageFormat format) { + auto img = std::make_unique("http://example.com/image", 0, 0, format, image::IMAGE_TYPE_RGB, + image::TRANSPARENCY_OPAQUE, nullptr, 256); + img->set_parent(&this->http_); + return img; + } + + void start_(OnlineImage &image, const std::shared_ptr &container) { + image.add_on_finished_callback([this](bool) { this->finished_++; }); + image.add_on_error_callback([this]() { this->errors_++; }); + this->http_.next = container; + image.update(); + } + + FakeHttpRequest http_; + int finished_{0}; + int errors_{0}; +}; + +// A chunked stream can end without the decoder reporting it is finished. PNG cannot tell a +// complete image from a cut-off one, so when the decoder used every byte the download succeeds, +// but the image is not cached: a 304 response must not keep it. +TEST_F(OnlineImageDownloadEnd, ChunkedPngCutOffFinishesWithoutCaching) { + std::vector png(PNG_1X1.begin(), PNG_1X1.begin() + PNG_HEADER_SIZE); // ends after IHDR + auto container = std::make_shared(png, png.size(), true); + container->add_response_header("etag", "\"abc\""); + container->add_response_header("last-modified", "Mon, 12 Oct 2026 10:00:00 GMT"); + auto image = this->make_image_(runtime_image::PNG); + this->start_(*image, container); + + for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++) + image->loop(); + + EXPECT_EQ(this->finished_, 1); + EXPECT_EQ(this->errors_, 0); + + this->http_.next = std::make_shared(png, 0, false); + image->update(); + EXPECT_FALSE(this->http_.sent_header("if-none-match")); + EXPECT_FALSE(this->http_.sent_header("if-modified-since")); +} + +// BMP knows its pixel count from the header, so a chunked BMP cut off at a row boundary is an error. +TEST_F(OnlineImageDownloadEnd, ChunkedBmpCutOffAtRowReportsError) { + std::vector bmp = make_bmp(); + bmp.resize(BMP_HEADER_SIZE + BMP_ROW_SIZE); // only the first row + auto container = std::make_shared(bmp, bmp.size(), true); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++) + image->loop(); + + EXPECT_EQ(this->errors_, 1); + EXPECT_EQ(this->finished_, 0); +} + +// An image the decoder confirmed as complete is cached. +TEST_F(OnlineImageDownloadEnd, CompleteImageIsCached) { + std::vector bmp = make_bmp(); + auto container = std::make_shared(bmp, bmp.size(), false); + container->add_response_header("etag", "\"abc\""); + container->add_response_header("last-modified", "Mon, 12 Oct 2026 10:00:00 GMT"); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++) + image->loop(); + ASSERT_EQ(this->finished_, 1); + + this->http_.next = std::make_shared(bmp, 0, false); + image->update(); + EXPECT_TRUE(this->http_.sent_header("if-none-match")); + EXPECT_TRUE(this->http_.sent_header("if-modified-since")); +} + +// ESP-IDF keeps returning 0 after the server closes the connection early. +TEST_F(OnlineImageDownloadEnd, NoDataForTimeoutReportsError) { + this->http_.set_timeout(100); + auto container = std::make_shared(make_bmp(), 20, false); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + const uint32_t now = millis(); + set_loop_time(now); + image->loop(); + ASSERT_EQ(container->get_bytes_read(), 20u); + set_loop_time(now + 99); + image->loop(); + EXPECT_EQ(this->errors_, 0); + + set_loop_time(now + 100); + image->loop(); + EXPECT_EQ(this->errors_, 1); + EXPECT_EQ(this->finished_, 0); +} + +// The time spent connecting and reading the headers must not count as time without data. +TEST_F(OnlineImageDownloadEnd, NoDataTimeoutStartsAfterRequest) { + this->http_.set_timeout(100); + auto container = std::make_shared(make_bmp(), 0, false); + auto image = this->make_image_(runtime_image::BMP); + // update() runs in a dispatch that started before the blocking request, here one second earlier + set_loop_time(millis() - 1000); + this->start_(*image, container); + + const uint32_t now = millis(); + set_loop_time(now); + image->loop(); + EXPECT_EQ(this->errors_, 0); + + set_loop_time(now + 100); + image->loop(); + EXPECT_EQ(this->errors_, 1); +} + +// A full buffer the decoder cannot use waits for the no-data timeout before it fails, so a decoder +// that only had to wait is not cut off early. +TEST_F(OnlineImageDownloadEnd, FullBufferFailsAfterTimeout) { + this->http_.set_timeout(100); + auto bmp = make_palette_bmp(); + auto container = std::make_shared(bmp, bmp.size(), false); + auto image = this->make_image_(runtime_image::BMP); + this->start_(*image, container); + + const uint32_t now = millis(); + set_loop_time(now); + for (int i = 0; i < 8; i++) + image->loop(); + EXPECT_EQ(this->errors_, 0); + set_loop_time(now + 99); + image->loop(); + EXPECT_EQ(this->errors_, 0); + + set_loop_time(now + 100); + image->loop(); + EXPECT_EQ(this->errors_, 1); + EXPECT_EQ(this->finished_, 0); +} + +// A JPEG too big for the heap must fail, and the next download must get the configured buffer back. +TEST_F(OnlineImageDownloadEnd, JpegBufferAllocationFailureReportsError) { + auto image = this->make_image_(runtime_image::JPEG); + auto huge = std::make_shared(std::vector(16), 16, false); + huge->set_content_length(SIZE_MAX / 2); + this->start_(*image, huge); + EXPECT_EQ(this->errors_, 1); + + // A chunked response does not resize the buffer. The body is not a real JPEG, so the decoder + // rejects it, but only once it has been read into the buffer. + auto next = std::make_shared(std::vector(100, 0xAA), 100, true); + this->http_.next = next; + image->update(); + image->loop(); + EXPECT_EQ(next->get_bytes_read(), 100u); + EXPECT_EQ(this->errors_, 2); + EXPECT_EQ(this->finished_, 0); +} + +} // namespace esphome::online_image::testing diff --git a/tests/integration/fixtures/online_image_stalled_download.yaml b/tests/integration/fixtures/online_image_stalled_download.yaml new file mode 100644 index 00000000000..1be383ee9be --- /dev/null +++ b/tests/integration/fixtures/online_image_stalled_download.yaml @@ -0,0 +1,54 @@ +esphome: + name: online-image-stalled + +host: + +http_request: + timeout: 500ms + +display: + +image: + - platform: online_image + url: http://127.0.0.1:HTTP_PORT/truncated.bmp + id: truncated_img + format: BMP + type: RGB + on_download_finished: + logger.log: "download finished: truncated" + on_error: + logger.log: "download error: truncated" + - platform: online_image + url: http://127.0.0.1:HTTP_PORT/large_header.bmp + id: large_header_img + format: BMP + type: RGB + buffer_size: 256 + on_download_finished: + logger.log: "download finished: large_header" + on_error: + logger.log: "download error: large_header" + - platform: online_image + url: http://127.0.0.1:HTTP_PORT/empty.bmp + id: empty_img + format: BMP + type: RGB + on_download_finished: + logger.log: "download finished: empty" + on_error: + logger.log: "download error: empty" + +api: + actions: + - action: fetch_truncated + then: + - component.update: truncated_img + - action: fetch_large_header + then: + - component.update: large_header_img + - action: fetch_empty + then: + - component.update: empty_img + +logger: + level: DEBUG diff --git a/tests/integration/test_online_image_stalled_download.py b/tests/integration/test_online_image_stalled_download.py new file mode 100644 index 00000000000..35c549afe60 --- /dev/null +++ b/tests/integration/test_online_image_stalled_download.py @@ -0,0 +1,121 @@ +"""Downloads that can never complete must report an error instead of waiting forever.""" + +from __future__ import annotations + +import asyncio +import struct + +import pytest + +from .online_image_utils import BMP_IMAGE +from .types import APIClientConnectedFactory, RunCompiledFunction + +# 24-bit BMP cut off one byte into the sixth pixel: the decoder keeps that byte, +# waiting for the rest of the pixel, but the response has already ended. +TRUNCATED_BMP = BMP_IMAGE[: 54 + 5 * 3 + 1] + + +def _make_palette_bmp() -> bytes: + """Build an 8x8, 8-bit BMP whose 256-color table puts the pixels at offset 1078.""" + width = height = 8 + data_offset = 14 + 40 + 256 * 4 + file_size = data_offset + width * height + file_header = b"BM" + struct.pack(" None: + try: + async with asyncio.timeout(1.0): + request_line = await reader.readuntil(b"\r\n") + await reader.readuntil(b"\r\n\r\n") + path = request_line.split(b" ")[1].decode() + body = IMAGES[path] + writer.write( + b"\r\n".join( + [ + b"HTTP/1.1 200 OK", + b"Content-Length: %d" % len(body), + b"Content-Type: image/bmp", + b"Connection: close", + b"", + b"", + ] + ) + + body + ) + await writer.drain() + finally: + writer.close() + + +@pytest.mark.asyncio +async def test_online_image_stalled_download( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Esphome should report an error when a download can never complete.""" + loop = asyncio.get_running_loop() + expected_logs = { + "truncated_reason": "Download ended with 1 bytes the decoder could not use", + "truncated_error": "download error: truncated", + "large_header_reason": "Image does not fit in the 256 byte download buffer", + "large_header_error": "download error: large_header", + "empty_reason": "Server sent an empty response", + "empty_error": "download error: empty", + } + log_futures = {key: loop.create_future() for key in expected_logs} + finished_lines: list[str] = [] + + def check_output(line: str) -> None: + for key, text in expected_logs.items(): + if text in line and not log_futures[key].done(): + log_futures[key].set_result(True) + if "download finished" in line: + finished_lines.append(line) + + server = await asyncio.start_server(handle_http, "127.0.0.1", 0) + http_server_port = server.sockets[0].getsockname()[1] + config = yaml_config.replace("HTTP_PORT", str(http_server_port)) + + async with ( + server, + run_compiled(config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + actions = {s.name: s for s in services} + + await client.execute_service(actions["fetch_truncated"], {}) + async with asyncio.timeout(2.0): + await log_futures["truncated_reason"] + await log_futures["truncated_error"] + + await client.execute_service(actions["fetch_large_header"], {}) + async with asyncio.timeout(2.0): + await log_futures["large_header_reason"] + await log_futures["large_header_error"] + + await client.execute_service(actions["fetch_empty"], {}) + async with asyncio.timeout(2.0): + await log_futures["empty_reason"] + await log_futures["empty_error"] + + assert not finished_lines, f"Unexpected successful download: {finished_lines}" From 51ae3bb64245d8441492664c1554f3f157d16b90 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:31:06 +1100 Subject: [PATCH 004/179] [ci] Rename merge conflict label to has-conflicts (#20969) --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7507eb4a424..1b0d478bd86 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -27,7 +27,7 @@ jobs: days-before-close: 7 stale-label: stale exempt-label: not-stale - conflict-label: needs-rebase + conflict-label: has-conflicts ignored-users: esphbot,codecov-commenter stale-pr-message: > There hasn't been any activity on this pull request recently. This From d9969dd53376fda4d8658260367f91b3ba2c3cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:55:08 +0200 Subject: [PATCH 005/179] [teleinfo] Skip sensor values that are not numbers instead of publishing 0 (#20960) Co-authored-by: Claude --- .../teleinfo/sensor/teleinfo_sensor.cpp | 9 +++- tests/components/teleinfo/__init__.py | 11 +++++ tests/components/teleinfo/sensor/__init__.py | 10 ++++ .../teleinfo/teleinfo_sensor_test.cpp | 47 +++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 tests/components/teleinfo/__init__.py create mode 100644 tests/components/teleinfo/sensor/__init__.py create mode 100644 tests/components/teleinfo/teleinfo_sensor_test.cpp diff --git a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp index 443d4be8a10..6929f587d10 100644 --- a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp +++ b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp @@ -6,8 +6,13 @@ namespace esphome::teleinfo { ESPHOME_LOG_TAG(TAG, "teleinfo_sensor"); TeleInfoSensor::TeleInfoSensor(const char *tag) { this->tag = std::string(tag); } void TeleInfoSensor::publish_val(const std::string &val) { - auto newval = parse_number(val).value_or(0.0f); - publish_state(newval); + auto newval = parse_number(val); + if (!newval.has_value()) { + // Publishing 0 instead would look like a meter reset to total_increasing energy sensors + ESP_LOGW(TAG, "Value '%s' for tag %s is not a number", val.c_str(), this->tag.c_str()); + return; + } + this->publish_state(*newval); } void TeleInfoSensor::dump_config() { LOG_SENSOR(" ", "Teleinfo Sensor", this); } } // namespace esphome::teleinfo diff --git a/tests/components/teleinfo/__init__.py b/tests/components/teleinfo/__init__.py new file mode 100644 index 00000000000..5f1db440600 --- /dev/null +++ b/tests/components/teleinfo/__init__.py @@ -0,0 +1,11 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # teleinfo.h includes uart.h; the hub's own to_code needs a configured UART bus. + async def to_code_testing(config: ConfigType) -> None: + pass + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["uart"] diff --git a/tests/components/teleinfo/sensor/__init__.py b/tests/components/teleinfo/sensor/__init__.py new file mode 100644 index 00000000000..cfaaf8d96bd --- /dev/null +++ b/tests/components/teleinfo/sensor/__init__.py @@ -0,0 +1,10 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The platform's own to_code needs a configured hub; the sensor class is built directly. + async def to_code_testing(config: ConfigType) -> None: + pass + + manifest.to_code = to_code_testing diff --git a/tests/components/teleinfo/teleinfo_sensor_test.cpp b/tests/components/teleinfo/teleinfo_sensor_test.cpp new file mode 100644 index 00000000000..9b86fd61879 --- /dev/null +++ b/tests/components/teleinfo/teleinfo_sensor_test.cpp @@ -0,0 +1,47 @@ +#include + +#include + +#include "esphome/components/teleinfo/sensor/teleinfo_sensor.h" + +namespace esphome::teleinfo::testing { + +namespace { +class TeleInfoSensorTest : public ::testing::Test { + protected: + void SetUp() override { + this->sensor_.add_on_state_callback([this](float state) { + this->publishes_++; + this->last_ = state; + }); + } + + TeleInfoSensor sensor_{"EAST"}; + int publishes_{0}; + float last_{NAN}; +}; +} // namespace + +TEST_F(TeleInfoSensorTest, PublishesZeroPaddedNumber) { + this->sensor_.publish_val("000012345"); + EXPECT_EQ(this->publishes_, 1); + EXPECT_FLOAT_EQ(this->last_, 12345.0f); +} + +TEST_F(TeleInfoSensorTest, PublishesRealZero) { + this->sensor_.publish_val("000000000"); + EXPECT_EQ(this->publishes_, 1); + EXPECT_FLOAT_EQ(this->last_, 0.0f); +} + +// A value that is not a number must be dropped: publishing 0 would look like a meter reset +TEST_F(TeleInfoSensorTest, SkipsValueThatIsNotANumber) { + this->sensor_.publish_val("000012345"); + this->sensor_.publish_val("0000l2345"); + this->sensor_.publish_val("H081225223518"); + EXPECT_EQ(this->publishes_, 1); + EXPECT_FLOAT_EQ(this->last_, 12345.0f); + EXPECT_FLOAT_EQ(this->sensor_.get_state(), 12345.0f); +} + +} // namespace esphome::teleinfo::testing From bb0eabf241bd46d54f70df67c3d65a8baff2678b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 14:56:34 -1000 Subject: [PATCH 006/179] [core] Add options() to C++ enums (#20910) --- esphome/cpp_generator.py | 14 ++++++++++ tests/unit_tests/test_cpp_generator.py | 36 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index d0fc126957f..b17db965212 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1270,6 +1270,9 @@ class MockObj(Expression): return MockObj(op) +_ENUM_NON_ALNUM = re.compile(r"[^A-Za-z0-9]") + + class MockObjEnum(MockObj): def __init__(self, *args, **kwargs): self._enum = kwargs.pop("enum") @@ -1289,6 +1292,17 @@ class MockObjEnum(MockObj): def __repr__(self): return f"MockObj<{str(self.base)}>" + def options(self, *names: str, prefix: str = "") -> dict[str, "MockObj"]: + """Map option names to prefix + NAME enumerators (non-alphanumerics become "_"), for cv.enum.""" + enumerators = { + name: prefix + _ENUM_NON_ALNUM.sub("_", name).upper() for name in names + } + if len(set(enumerators.values())) != len(names): + raise ValueError(f"Option names must map to distinct enumerators: {names}") + return { + name: getattr(self, enumerator) for name, enumerator in enumerators.items() + } + class MockObjClass(MockObj): def __init__(self, *args, **kwargs): diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index ec88c583baa..e78035f1f6c 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -965,3 +965,39 @@ def test_extern_progmem_array_is_an_externally_linked_global() -> None: assert [str(st) for st in CORE.global_statements if "PROGMEM" in str(st)] == [ "const uint8_t esphome::api::API_TABLE[] PROGMEM = {1, 2};" ] + + +class TestEnumOptions: + def test_class_enum_with_prefix(self) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode", is_class=True) + options = mode.options("fast", "slow-ish", "2.5x", prefix="MODE_") + assert list(options) == ["fast", "slow-ish", "2.5x"] + assert {k: str(v) for k, v in options.items()} == { + "fast": "esphome::foo::Mode::MODE_FAST", + "slow-ish": "esphome::foo::Mode::MODE_SLOW_ISH", + "2.5x": "esphome::foo::Mode::MODE_2_5X", + } + + def test_matches_attribute_access(self) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode") + options = mode.options("LOW", "HIGH") + assert str(options["LOW"]) == str(mode.LOW) == "esphome::foo::LOW" + assert str(options["HIGH"]) == str(mode.HIGH) + + def test_result_works_with_cv_enum(self) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode", is_class=True) + options = mode.options("on", "off", prefix="MODE_") + validator = cv.enum(options, lower=True) + assert str(validator("ON").enum_value) == "esphome::foo::Mode::MODE_ON" + assert validator(cv.SCHEMA_EXTRACT) is options + + @pytest.mark.parametrize( + "names", + [("OFF", "OFF"), ("slow-ish", "slow_ish"), ("2.5x", "2_5X")], + ) + def test_names_must_map_to_distinct_enumerators( + self, names: tuple[str, ...] + ) -> None: + mode = cg.MockObj("esphome::foo", "::").enum("Mode") + with pytest.raises(ValueError, match="distinct enumerators"): + mode.options(*names) From 36ba2f6106c9a0648a26c585e699cf45dfabbbe8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 14:56:34 -1000 Subject: [PATCH 007/179] [components] Build enum option dicts with options() (#20911) --- .../components/absolute_humidity/sensor.py | 6 +- esphome/components/ac_dimmer/output.py | 8 +- esphome/components/adc/__init__.py | 6 +- esphome/components/ags10/sensor.py | 8 +- esphome/components/aht10/sensor.py | 5 +- .../components/airthings_wave_plus/sensor.py | 5 +- esphome/components/as5600/sensor/__init__.py | 5 +- esphome/components/as734x/__init__.py | 9 +- esphome/components/audio/__init__.py | 8 +- esphome/components/bedjet/climate/__init__.py | 12 +- esphome/components/bh1745/__init__.py | 6 +- esphome/components/bl0939/sensor.py | 7 +- esphome/components/bme280_base/__init__.py | 21 +--- esphome/components/bme680/sensor.py | 24 +--- esphome/components/bme680_bsec/__init__.py | 10 +- esphome/components/bme68x_bsec2/__init__.py | 22 +--- esphome/components/bmi270/motion.py | 9 +- esphome/components/bmp280_base/__init__.py | 21 +--- esphome/components/canbus/__init__.py | 47 +++---- esphome/components/cc1101/__init__.py | 115 +++++------------- esphome/components/climate/__init__.py | 93 +++++++------- esphome/components/cover/__init__.py | 8 +- esphome/components/cs5460a/sensor.py | 5 +- esphome/components/cse7761/sensor.py | 7 +- esphome/components/deep_sleep/__init__.py | 16 +-- esphome/components/dfplayer/__init__.py | 14 +-- esphome/components/dht/sensor.py | 21 ++-- esphome/components/es8311/audio_dac.py | 21 ++-- esphome/components/es8388/select/__init__.py | 16 +-- .../components/es9038q2m_katana/audio_dac.py | 9 +- esphome/components/esp32_ble/__init__.py | 21 ++-- esphome/components/esp32_camera/__init__.py | 64 +++++----- .../esp32_camera_web_server/__init__.py | 2 +- esphome/components/espectre/__init__.py | 11 +- esphome/components/ethernet/__init__.py | 48 ++++---- .../exponential_moving_average/sensor.py | 8 +- esphome/components/fan/__init__.py | 23 ++-- .../components/fingerprint_grow/__init__.py | 23 +--- esphome/components/friedrich/climate.py | 4 +- esphome/components/gp8403/__init__.py | 10 +- esphome/components/graph/__init__.py | 23 ++-- esphome/components/gree/climate.py | 12 +- esphome/components/growatt_solar/sensor.py | 5 +- esphome/components/haier/climate.py | 29 ++--- esphome/components/haier_tundra/climate.py | 5 +- esphome/components/hbridge/fan/__init__.py | 5 +- esphome/components/hdc302x/sensor.py | 9 +- esphome/components/heatpumpir/climate.py | 22 +--- esphome/components/hlw8012/sensor.py | 8 +- .../components/honeywellabp2_i2c/sensor.py | 5 +- esphome/components/htu21d/sensor.py | 8 +- esphome/components/hub75/display.py | 49 ++++---- .../components/i2s_audio/speaker/__init__.py | 25 ++-- esphome/components/icm20689/motion.py | 9 +- esphome/components/inkplate/display.py | 16 +-- esphome/components/integration/sensor.py | 8 +- esphome/components/jsn_sr04t/sensor.py | 5 +- esphome/components/lc709203f/sensor.py | 7 +- esphome/components/light/types.py | 27 ++-- esphome/components/lis2dw12/__init__.py | 39 +++--- esphome/components/lis3dh/motion.py | 22 ++-- esphome/components/lock/__init__.py | 19 +-- esphome/components/lsm6ds/motion.py | 9 +- esphome/components/max31856/sensor.py | 13 +- esphome/components/max31865/sensor.py | 5 +- esphome/components/max44009/sensor.py | 8 +- esphome/components/max6956/__init__.py | 5 +- esphome/components/max7219digit/display.py | 10 +- esphome/components/mcp23xxx_base/__init__.py | 9 +- esphome/components/mcp2515/canbus.py | 13 +- esphome/components/mcp4728/output/__init__.py | 11 +- esphome/components/mcp9600/sensor.py | 13 +- esphome/components/media_player/__init__.py | 7 +- esphome/components/midea/climate.py | 8 +- esphome/components/mitsubishi/climate.py | 34 +++--- esphome/components/modbus/helpers.py | 60 +++++---- .../modbus_controller/text_sensor/__init__.py | 7 +- .../motion/binary_sensor/__init__.py | 9 +- esphome/components/msa3xx/__init__.py | 7 +- esphome/components/number/__init__.py | 15 +-- esphome/components/pca9685/__init__.py | 5 +- esphome/components/pcm5122/switch/__init__.py | 7 +- esphome/components/pm2005/sensor.py | 5 +- esphome/components/pulse_counter/sensor.py | 8 +- esphome/components/pulse_meter/sensor.py | 5 +- esphome/components/pzem6l24/sensor.py | 10 +- esphome/components/qmi8658/motion.py | 9 +- esphome/components/qmp6988/sensor.py | 11 +- esphome/components/qr_code/__init__.py | 9 +- esphome/components/remote_base/__init__.py | 47 +++---- esphome/components/resistance/sensor.py | 5 +- esphome/components/rotary_encoder/sensor.py | 7 +- .../components/rp2040_pio_led_strip/light.py | 7 +- esphome/components/scd4x/sensor.py | 9 +- esphome/components/select/__init__.py | 9 +- esphome/components/sen5x/sensor.py | 6 +- .../sendspin/text_sensor/__init__.py | 9 +- esphome/components/serial_proxy/__init__.py | 9 +- esphome/components/sht4x/sensor.py | 23 ++-- esphome/components/sm2135/__init__.py | 48 ++++---- esphome/components/spa06_base/__init__.py | 37 +++--- .../speaker/media_player/__init__.py | 5 +- esphome/components/spi_device/__init__.py | 5 +- esphome/components/st7701s/display.py | 5 +- esphome/components/stcc4/sensor.py | 5 +- esphome/components/sun/sensor/__init__.py | 5 +- esphome/components/sx126x/__init__.py | 32 +++-- esphome/components/sx127x/__init__.py | 11 +- esphome/components/sy6970/__init__.py | 9 +- esphome/components/syslog/__init__.py | 5 +- esphome/components/tas58xx/audio_dac.py | 15 +-- esphome/components/tcs34725/sensor.py | 50 ++++---- .../template/alarm_control_panel/__init__.py | 20 +-- .../components/template/climate/__init__.py | 7 +- esphome/components/template/cover/__init__.py | 8 +- esphome/components/template/valve/__init__.py | 8 +- .../template/water_heater/__init__.py | 8 +- esphome/components/thermostat/climate.py | 24 ++-- esphome/components/tmc22xx/__init__.py | 13 +- esphome/components/tmp1075/sensor.py | 5 +- esphome/components/toshiba/climate.py | 17 +-- .../components/total_daily_energy/sensor.py | 8 +- esphome/components/tsl2561/sensor.py | 5 +- esphome/components/tuya/cover/__init__.py | 8 +- esphome/components/tuya/light/__init__.py | 6 +- esphome/components/uart/__init__.py | 24 ++-- esphome/components/uln2003/stepper.py | 8 +- esphome/components/usb_uart/__init__.py | 8 +- esphome/components/valve/__init__.py | 8 +- esphome/components/veml3235/sensor.py | 23 ++-- esphome/components/water_heater/__init__.py | 19 +-- esphome/components/whirlpool/climate.py | 6 +- esphome/components/wifi/__init__.py | 25 ++-- esphome/components/xgzp68xx/sensor.py | 21 ++-- esphome/components/zigbee/const.py | 9 +- esphome/components/zigbee/const_esp32.py | 62 +++++----- 136 files changed, 821 insertions(+), 1339 deletions(-) diff --git a/esphome/components/absolute_humidity/sensor.py b/esphome/components/absolute_humidity/sensor.py index 84a69dfa236..f11ba5af990 100644 --- a/esphome/components/absolute_humidity/sensor.py +++ b/esphome/components/absolute_humidity/sensor.py @@ -19,11 +19,7 @@ AbsoluteHumidityComponent = absolute_humidity_ns.class_( SaturationVaporPressureEquation = absolute_humidity_ns.enum( "SaturationVaporPressureEquation" ) -EQUATION = { - "BUCK": SaturationVaporPressureEquation.BUCK, - "TETENS": SaturationVaporPressureEquation.TETENS, - "WOBUS": SaturationVaporPressureEquation.WOBUS, -} +EQUATION = SaturationVaporPressureEquation.options("BUCK", "TETENS", "WOBUS") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index 498565b0ea8..7d1b66a6905 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -14,11 +14,9 @@ ac_dimmer_ns = cg.esphome_ns.namespace("ac_dimmer") AcDimmer = ac_dimmer_ns.class_("AcDimmer", output.FloatOutput, cg.Component) DimMethod = ac_dimmer_ns.enum("DimMethod") -DIM_METHODS = { - "LEADING_PULSE": DimMethod.DIM_METHOD_LEADING_PULSE, - "LEADING": DimMethod.DIM_METHOD_LEADING, - "TRAILING": DimMethod.DIM_METHOD_TRAILING, -} +DIM_METHODS = DimMethod.options( + "LEADING_PULSE", "LEADING", "TRAILING", prefix="DIM_METHOD_" +) ZC_INTERRUPT_TYPES = { "RISING": gpio_ns.INTERRUPT_RISING_EDGE, diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 6966a86f6b9..f90c01e833d 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -46,11 +46,7 @@ ATTENUATION_MODES = { sampling_mode = adc_ns.enum("SamplingMode", is_class=True) -SAMPLING_MODES = { - "avg": sampling_mode.AVG, - "min": sampling_mode.MIN, - "max": sampling_mode.MAX, -} +SAMPLING_MODES = sampling_mode.options("avg", "min", "max") adc_unit_t = cg.global_ns.enum("adc_unit_t", is_class=True) diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index d20d80b2fc7..8fd18671f81 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -84,11 +84,9 @@ automation.register_apply_action( ) AGS10SetZeroPointActionMode = ags10_ns.enum("AGS10SetZeroPointActionMode") -AGS10_SET_ZERO_POINT_ACTION_MODE = { - "FACTORY_DEFAULT": AGS10SetZeroPointActionMode.FACTORY_DEFAULT, - "CURRENT_VALUE": AGS10SetZeroPointActionMode.CURRENT_VALUE, - "CUSTOM_VALUE": AGS10SetZeroPointActionMode.CUSTOM_VALUE, -} +AGS10_SET_ZERO_POINT_ACTION_MODE = AGS10SetZeroPointActionMode.options( + "FACTORY_DEFAULT", "CURRENT_VALUE", "CUSTOM_VALUE" +) AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( { diff --git a/esphome/components/aht10/sensor.py b/esphome/components/aht10/sensor.py index add86e16a63..974e7d4df4c 100644 --- a/esphome/components/aht10/sensor.py +++ b/esphome/components/aht10/sensor.py @@ -20,10 +20,7 @@ aht10_ns = cg.esphome_ns.namespace("aht10") AHT10Component = aht10_ns.class_("AHT10Component", cg.PollingComponent, i2c.I2CDevice) AHT10Variant = aht10_ns.enum("AHT10Variant") -AHT10_VARIANTS = { - "AHT10": AHT10Variant.AHT10, - "AHT20": AHT10Variant.AHT20, -} +AHT10_VARIANTS = AHT10Variant.options("AHT10", "AHT20") CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/airthings_wave_plus/sensor.py b/esphome/components/airthings_wave_plus/sensor.py index 75c1eca594f..14e862d55b8 100644 --- a/esphome/components/airthings_wave_plus/sensor.py +++ b/esphome/components/airthings_wave_plus/sensor.py @@ -29,10 +29,7 @@ AirthingsWavePlus = airthings_wave_plus_ns.class_( CONF_DEVICE_TYPE = "device_type" WaveDeviceType = airthings_wave_plus_ns.enum("WaveDeviceType") -DEVICE_TYPES = { - "WAVE_PLUS": WaveDeviceType.WAVE_PLUS, - "WAVE_GEN2": WaveDeviceType.WAVE_GEN2, -} +DEVICE_TYPES = WaveDeviceType.options("WAVE_PLUS", "WAVE_GEN2") def validate_wave_gen2_config(config: ConfigType) -> ConfigType: diff --git a/esphome/components/as5600/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index 4cf1e95f695..7a3010b8e35 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -30,10 +30,7 @@ CONF_END_POSITION = "end_position" CONF_OUT_OF_RANGE_MODE = "out_of_range_mode" OutOfRangeMode = as5600_ns.enum("OutRangeMode") -OUT_OF_RANGE_MODES = { - "MIN_MAX": OutOfRangeMode.OUT_RANGE_MODE_MIN_MAX, - "NAN": OutOfRangeMode.OUT_RANGE_MODE_NAN, -} +OUT_OF_RANGE_MODES = OutOfRangeMode.options("MIN_MAX", "NAN", prefix="OUT_RANGE_MODE_") CONF_AS5600_ID = "as5600_id" diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index bd43f78f01e..61eeaa5a35b 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__init__.py @@ -55,12 +55,9 @@ GAINS_41 = { GAINS_43 = {**GAINS_41, "X1024": Gain.GAIN_1024X, "X2048": Gain.GAIN_2048X} Normalization = as734x_ns.enum("Normalization", True) -NORMALIZATIONS = { - "NONE": Normalization.NORMALIZATION_NONE, - "ALL": Normalization.NORMALIZATION_ALL, - "BANDS": Normalization.NORMALIZATION_BANDS, - "CLEAR": Normalization.NORMALIZATION_CLEAR, -} +NORMALIZATIONS = Normalization.options( + "NONE", "ALL", "BANDS", "CLEAR", prefix="NORMALIZATION_" +) # Per-channel correction for the golden device, measured by the manufacturer. DEFAULT_CHANNEL_CORRECTION_43 = [ diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 36a9161eb45..24b3c1435d2 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -27,13 +27,7 @@ audio_ns = cg.esphome_ns.namespace("audio") AudioFile = audio_ns.struct("AudioFile") AudioFileType = audio_ns.enum("AudioFileType", is_class=True) -AUDIO_FILE_TYPE_ENUM = { - "NONE": AudioFileType.NONE, - "WAV": AudioFileType.WAV, - "MP3": AudioFileType.MP3, - "FLAC": AudioFileType.FLAC, - "OPUS": AudioFileType.OPUS, -} +AUDIO_FILE_TYPE_ENUM = AudioFileType.options("NONE", "WAV", "MP3", "FLAC", "OPUS") MEMORY_PSRAM = "psram" MEMORY_INTERNAL = "internal" diff --git a/esphome/components/bedjet/climate/__init__.py b/esphome/components/bedjet/climate/__init__.py index 36650d643cc..417bad44fc4 100644 --- a/esphome/components/bedjet/climate/__init__.py +++ b/esphome/components/bedjet/climate/__init__.py @@ -12,14 +12,10 @@ DEPENDENCIES = ["bedjet"] BedJetClimate = bedjet_ns.class_("BedJetClimate", climate.Climate, cg.PollingComponent) BedjetHeatMode = bedjet_ns.enum("BedjetHeatMode") BedjetTemperatureSource = bedjet_ns.enum("BedjetTemperatureSource") -BEDJET_HEAT_MODES = { - "heat": BedjetHeatMode.HEAT_MODE_HEAT, - "extended": BedjetHeatMode.HEAT_MODE_EXTENDED, -} -BEDJET_TEMPERATURE_SOURCES = { - "outlet": BedjetTemperatureSource.TEMPERATURE_SOURCE_OUTLET, - "ambient": BedjetTemperatureSource.TEMPERATURE_SOURCE_AMBIENT, -} +BEDJET_HEAT_MODES = BedjetHeatMode.options("heat", "extended", prefix="HEAT_MODE_") +BEDJET_TEMPERATURE_SOURCES = BedjetTemperatureSource.options( + "outlet", "ambient", prefix="TEMPERATURE_SOURCE_" +) CONFIG_SCHEMA = ( climate.climate_schema(BedJetClimate) diff --git a/esphome/components/bh1745/__init__.py b/esphome/components/bh1745/__init__.py index bd63fd4ef2d..a922340df71 100644 --- a/esphome/components/bh1745/__init__.py +++ b/esphome/components/bh1745/__init__.py @@ -25,11 +25,7 @@ BH1745Component = bh1745_ns.class_( ) AdcGain = bh1745_ns.enum("AdcGain", is_class=True) -ADC_GAINS = { - "1X": AdcGain.ADC_GAIN_1X, - "2X": AdcGain.ADC_GAIN_2X, - "16X": AdcGain.ADC_GAIN_16X, -} +ADC_GAINS = AdcGain.options("1X", "2X", "16X", prefix="ADC_GAIN_") MeasurementTime = bh1745_ns.enum("MeasurementTime", is_class=True) MEASUREMENT_TIMES = { diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 248002d185f..5e33759dea4 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -35,10 +35,9 @@ CONF_ENERGY_TOTAL = "energy_total" bl0939_ns = cg.esphome_ns.namespace("bl0939") BL0939 = bl0939_ns.class_("BL0939", cg.PollingComponent, uart.UARTDevice) BL0939Mode = bl0939_ns.enum("BL0939Mode", is_class=True) -MODE_OPTIONS = { - "direct": BL0939Mode.BL0939_MODE_DIRECT, - "current_transformer": BL0939Mode.BL0939_MODE_CURRENT_TRANSFORMER, -} +MODE_OPTIONS = BL0939Mode.options( + "direct", "current_transformer", prefix="BL0939_MODE_" +) # Must match the mode_ member initializer in bl0939.h; set_mode() is skipped # when the config already equals it. DEFAULT_MODE = "direct" diff --git a/esphome/components/bme280_base/__init__.py b/esphome/components/bme280_base/__init__.py index 8d54cbed2a0..c55e40f90d1 100644 --- a/esphome/components/bme280_base/__init__.py +++ b/esphome/components/bme280_base/__init__.py @@ -24,23 +24,14 @@ DOMAIN = "bme280_base" bme280_ns = cg.esphome_ns.namespace("bme280_base") BME280Oversampling = bme280_ns.enum("BME280Oversampling") -OVERSAMPLING_OPTIONS = { - "NONE": BME280Oversampling.BME280_OVERSAMPLING_NONE, - "1X": BME280Oversampling.BME280_OVERSAMPLING_1X, - "2X": BME280Oversampling.BME280_OVERSAMPLING_2X, - "4X": BME280Oversampling.BME280_OVERSAMPLING_4X, - "8X": BME280Oversampling.BME280_OVERSAMPLING_8X, - "16X": BME280Oversampling.BME280_OVERSAMPLING_16X, -} +OVERSAMPLING_OPTIONS = BME280Oversampling.options( + "NONE", "1X", "2X", "4X", "8X", "16X", prefix="BME280_OVERSAMPLING_" +) BME280IIRFilter = bme280_ns.enum("BME280IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": BME280IIRFilter.BME280_IIR_FILTER_OFF, - "2X": BME280IIRFilter.BME280_IIR_FILTER_2X, - "4X": BME280IIRFilter.BME280_IIR_FILTER_4X, - "8X": BME280IIRFilter.BME280_IIR_FILTER_8X, - "16X": BME280IIRFilter.BME280_IIR_FILTER_16X, -} +IIR_FILTER_OPTIONS = BME280IIRFilter.options( + "OFF", "2X", "4X", "8X", "16X", prefix="BME280_IIR_FILTER_" +) CONFIG_SCHEMA_BASE = cv.Schema( { diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index ffe5c74bbd4..73f48526be6 100644 --- a/esphome/components/bme680/sensor.py +++ b/esphome/components/bme680/sensor.py @@ -28,26 +28,14 @@ DEPENDENCIES = ["i2c"] bme680_ns = cg.esphome_ns.namespace("bme680") BME680Oversampling = bme680_ns.enum("BME680Oversampling") -OVERSAMPLING_OPTIONS = { - "NONE": BME680Oversampling.BME680_OVERSAMPLING_NONE, - "1X": BME680Oversampling.BME680_OVERSAMPLING_1X, - "2X": BME680Oversampling.BME680_OVERSAMPLING_2X, - "4X": BME680Oversampling.BME680_OVERSAMPLING_4X, - "8X": BME680Oversampling.BME680_OVERSAMPLING_8X, - "16X": BME680Oversampling.BME680_OVERSAMPLING_16X, -} +OVERSAMPLING_OPTIONS = BME680Oversampling.options( + "NONE", "1X", "2X", "4X", "8X", "16X", prefix="BME680_OVERSAMPLING_" +) BME680IIRFilter = bme680_ns.enum("BME680IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": BME680IIRFilter.BME680_IIR_FILTER_OFF, - "1X": BME680IIRFilter.BME680_IIR_FILTER_1X, - "3X": BME680IIRFilter.BME680_IIR_FILTER_3X, - "7X": BME680IIRFilter.BME680_IIR_FILTER_7X, - "15X": BME680IIRFilter.BME680_IIR_FILTER_15X, - "31X": BME680IIRFilter.BME680_IIR_FILTER_31X, - "63X": BME680IIRFilter.BME680_IIR_FILTER_63X, - "127X": BME680IIRFilter.BME680_IIR_FILTER_127X, -} +IIR_FILTER_OPTIONS = BME680IIRFilter.options( + "OFF", "1X", "3X", "7X", "15X", "31X", "63X", "127X", prefix="BME680_IIR_FILTER_" +) BME680Component = bme680_ns.class_( "BME680Component", cg.PollingComponent, i2c.I2CDevice diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 190994b2f8a..55fd1c975f5 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -19,10 +19,7 @@ CONF_SUPPLY_VOLTAGE = "supply_voltage" bme680_bsec_ns = cg.esphome_ns.namespace("bme680_bsec") IAQMode = bme680_bsec_ns.enum("IAQMode") -IAQ_MODE_OPTIONS = { - "STATIC": IAQMode.IAQ_MODE_STATIC, - "MOBILE": IAQMode.IAQ_MODE_MOBILE, -} +IAQ_MODE_OPTIONS = IAQMode.options("STATIC", "MOBILE", prefix="IAQ_MODE_") SupplyVoltage = bme680_bsec_ns.enum("SupplyVoltage") SUPPLY_VOLTAGE_OPTIONS = { @@ -31,10 +28,7 @@ SUPPLY_VOLTAGE_OPTIONS = { } SampleRate = bme680_bsec_ns.enum("SampleRate") -SAMPLE_RATE_OPTIONS = { - "LP": SampleRate.SAMPLE_RATE_LP, - "ULP": SampleRate.SAMPLE_RATE_ULP, -} +SAMPLE_RATE_OPTIONS = SampleRate.options("LP", "ULP", prefix="SAMPLE_RATE_") BME680BSECComponent = bme680_bsec_ns.class_( "BME680BSECComponent", cg.Component, i2c.I2CDevice diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 8208672b6a5..85bb250cd0a 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -36,28 +36,18 @@ BME68xBSEC2Component = bme68x_bsec2_ns.class_("BME68xBSEC2Component", cg.Compone MODEL_OPTIONS = ["bme680", "bme688"] AlgorithmOutput = bme68x_bsec2_ns.enum("AlgorithmOutput") -ALGORITHM_OUTPUT_OPTIONS = { - "classification": AlgorithmOutput.ALGORITHM_OUTPUT_CLASSIFICATION, - "regression": AlgorithmOutput.ALGORITHM_OUTPUT_REGRESSION, -} +ALGORITHM_OUTPUT_OPTIONS = AlgorithmOutput.options( + "classification", "regression", prefix="ALGORITHM_OUTPUT_" +) OperatingAge = bme68x_bsec2_ns.enum("OperatingAge") -OPERATING_AGE_OPTIONS = { - "4d": OperatingAge.OPERATING_AGE_4D, - "28d": OperatingAge.OPERATING_AGE_28D, -} +OPERATING_AGE_OPTIONS = OperatingAge.options("4d", "28d", prefix="OPERATING_AGE_") SampleRate = bme68x_bsec2_ns.enum("SampleRate") -SAMPLE_RATE_OPTIONS = { - "LP": SampleRate.SAMPLE_RATE_LP, - "ULP": SampleRate.SAMPLE_RATE_ULP, -} +SAMPLE_RATE_OPTIONS = SampleRate.options("LP", "ULP", prefix="SAMPLE_RATE_") Voltage = bme68x_bsec2_ns.enum("Voltage") -VOLTAGE_OPTIONS = { - "1.8V": Voltage.VOLTAGE_1_8V, - "3.3V": Voltage.VOLTAGE_3_3V, -} +VOLTAGE_OPTIONS = Voltage.options("1.8V", "3.3V", prefix="VOLTAGE_") ALGORITHM_OUTPUT_FILE_NAME = { "classification": "sel", diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py index 97127fd2dd5..bab24b45d65 100644 --- a/esphome/components/bmi270/motion.py +++ b/esphome/components/bmi270/motion.py @@ -20,12 +20,9 @@ CONF_AUX_DEVICE = "aux_device" # Enum proxies (must match the C++ enum values exactly) BMI270AuxDevice = bmi270_ns.enum("BMI270AuxDevice") BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange") -ACCEL_RANGE_OPTIONS = { - "2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G, - "4G": BMI270AccelRange.BMI270_ACCEL_RANGE_4G, - "8G": BMI270AccelRange.BMI270_ACCEL_RANGE_8G, - "16G": BMI270AccelRange.BMI270_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = BMI270AccelRange.options( + "2G", "4G", "8G", "16G", prefix="BMI270_ACCEL_RANGE_" +) BMI270GyroRange = bmi270_ns.enum("BMI270GyroRange") GYRO_RANGE_OPTIONS = { diff --git a/esphome/components/bmp280_base/__init__.py b/esphome/components/bmp280_base/__init__.py index 68d9145378b..3d84d6728f5 100644 --- a/esphome/components/bmp280_base/__init__.py +++ b/esphome/components/bmp280_base/__init__.py @@ -21,23 +21,14 @@ DOMAIN = "bmp280_base" bmp280_ns = cg.esphome_ns.namespace("bmp280_base") BMP280Oversampling = bmp280_ns.enum("BMP280Oversampling") -OVERSAMPLING_OPTIONS = { - "NONE": BMP280Oversampling.BMP280_OVERSAMPLING_NONE, - "1X": BMP280Oversampling.BMP280_OVERSAMPLING_1X, - "2X": BMP280Oversampling.BMP280_OVERSAMPLING_2X, - "4X": BMP280Oversampling.BMP280_OVERSAMPLING_4X, - "8X": BMP280Oversampling.BMP280_OVERSAMPLING_8X, - "16X": BMP280Oversampling.BMP280_OVERSAMPLING_16X, -} +OVERSAMPLING_OPTIONS = BMP280Oversampling.options( + "NONE", "1X", "2X", "4X", "8X", "16X", prefix="BMP280_OVERSAMPLING_" +) BMP280IIRFilter = bmp280_ns.enum("BMP280IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": BMP280IIRFilter.BMP280_IIR_FILTER_OFF, - "2X": BMP280IIRFilter.BMP280_IIR_FILTER_2X, - "4X": BMP280IIRFilter.BMP280_IIR_FILTER_4X, - "8X": BMP280IIRFilter.BMP280_IIR_FILTER_8X, - "16X": BMP280IIRFilter.BMP280_IIR_FILTER_16X, -} +IIR_FILTER_OPTIONS = BMP280IIRFilter.options( + "OFF", "2X", "4X", "8X", "16X", prefix="BMP280_IIR_FILTER_" +) CONFIG_SCHEMA_BASE = cv.Schema( { diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 56733870dc4..27220051a77 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -56,29 +56,30 @@ CanbusTrigger = canbus_ns.class_( ) CanSpeed = canbus_ns.enum("CAN_SPEED") -CAN_SPEEDS = { - "1KBPS": CanSpeed.CAN_1KBPS, - "5KBPS": CanSpeed.CAN_5KBPS, - "10KBPS": CanSpeed.CAN_10KBPS, - "12K5BPS": CanSpeed.CAN_12K5BPS, - "16KBPS": CanSpeed.CAN_16KBPS, - "20KBPS": CanSpeed.CAN_20KBPS, - "25KBPS": CanSpeed.CAN_25KBPS, - "31K25BPS": CanSpeed.CAN_31K25BPS, - "33KBPS": CanSpeed.CAN_33KBPS, - "40KBPS": CanSpeed.CAN_40KBPS, - "50KBPS": CanSpeed.CAN_50KBPS, - "80KBPS": CanSpeed.CAN_80KBPS, - "83K3BPS": CanSpeed.CAN_83K3BPS, - "95KBPS": CanSpeed.CAN_95KBPS, - "100KBPS": CanSpeed.CAN_100KBPS, - "125KBPS": CanSpeed.CAN_125KBPS, - "200KBPS": CanSpeed.CAN_200KBPS, - "250KBPS": CanSpeed.CAN_250KBPS, - "500KBPS": CanSpeed.CAN_500KBPS, - "800KBPS": CanSpeed.CAN_800KBPS, - "1000KBPS": CanSpeed.CAN_1000KBPS, -} +CAN_SPEEDS = CanSpeed.options( + "1KBPS", + "5KBPS", + "10KBPS", + "12K5BPS", + "16KBPS", + "20KBPS", + "25KBPS", + "31K25BPS", + "33KBPS", + "40KBPS", + "50KBPS", + "80KBPS", + "83K3BPS", + "95KBPS", + "100KBPS", + "125KBPS", + "200KBPS", + "250KBPS", + "500KBPS", + "800KBPS", + "1000KBPS", + prefix="CAN_", +) def get_rate(value: str) -> int: diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 8b9e91f833a..2660d5bbde7 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -67,41 +67,30 @@ CONF_GDO0_PIN = "gdo0_pin" # Enums SyncMode = ns.enum("SyncMode", True) -SYNC_MODE = { - "None": SyncMode.SYNC_MODE_NONE, - "15/16": SyncMode.SYNC_MODE_15_16, - "16/16": SyncMode.SYNC_MODE_16_16, - "30/32": SyncMode.SYNC_MODE_30_32, -} +SYNC_MODE = SyncMode.options("None", "15/16", "16/16", "30/32", prefix="SYNC_MODE_") Modulation = ns.enum("Modulation", True) -MODULATION = { - "2-FSK": Modulation.MODULATION_2_FSK, - "GFSK": Modulation.MODULATION_GFSK, - "ASK/OOK": Modulation.MODULATION_ASK_OOK, - "4-FSK": Modulation.MODULATION_4_FSK, - "MSK": Modulation.MODULATION_MSK, -} +MODULATION = Modulation.options( + "2-FSK", "GFSK", "ASK/OOK", "4-FSK", "MSK", prefix="MODULATION_" +) RxAttenuation = ns.enum("RxAttenuation", True) -RX_ATTENUATION = { - "0dB": RxAttenuation.RX_ATTENUATION_0DB, - "6dB": RxAttenuation.RX_ATTENUATION_6DB, - "12dB": RxAttenuation.RX_ATTENUATION_12DB, - "18dB": RxAttenuation.RX_ATTENUATION_18DB, -} +RX_ATTENUATION = RxAttenuation.options( + "0dB", "6dB", "12dB", "18dB", prefix="RX_ATTENUATION_" +) MagnTarget = ns.enum("MagnTarget", True) -MAGN_TARGET = { - "24dB": MagnTarget.MAGN_TARGET_24DB, - "27dB": MagnTarget.MAGN_TARGET_27DB, - "30dB": MagnTarget.MAGN_TARGET_30DB, - "33dB": MagnTarget.MAGN_TARGET_33DB, - "36dB": MagnTarget.MAGN_TARGET_36DB, - "38dB": MagnTarget.MAGN_TARGET_38DB, - "40dB": MagnTarget.MAGN_TARGET_40DB, - "42dB": MagnTarget.MAGN_TARGET_42DB, -} +MAGN_TARGET = MagnTarget.options( + "24dB", + "27dB", + "30dB", + "33dB", + "36dB", + "38dB", + "40dB", + "42dB", + prefix="MAGN_TARGET_", +) MaxLnaGain = ns.enum("MaxLnaGain", True) MAX_LNA_GAIN = { @@ -140,20 +129,14 @@ FILTER_LENGTH_FSK_MSK = { } FilterLengthAskOok = ns.enum("FilterLengthAskOok", True) -FILTER_LENGTH_ASK_OOK = { - "4dB": FilterLengthAskOok.FILTER_LENGTH_4DB, - "8dB": FilterLengthAskOok.FILTER_LENGTH_8DB, - "12dB": FilterLengthAskOok.FILTER_LENGTH_12DB, - "16dB": FilterLengthAskOok.FILTER_LENGTH_16DB, -} +FILTER_LENGTH_ASK_OOK = FilterLengthAskOok.options( + "4dB", "8dB", "12dB", "16dB", prefix="FILTER_LENGTH_" +) Freeze = ns.enum("Freeze", True) -FREEZE = { - "Default": Freeze.FREEZE_DEFAULT, - "On Sync": Freeze.FREEZE_ON_SYNC, - "Analog Only": Freeze.FREEZE_ANALOG_ONLY, - "Analog And Digital": Freeze.FREEZE_ANALOG_AND_DIGITAL, -} +FREEZE = Freeze.options( + "Default", "On Sync", "Analog Only", "Analog And Digital", prefix="FREEZE_" +) WaitTime = ns.enum("WaitTime", True) WAIT_TIME = { @@ -164,34 +147,16 @@ WAIT_TIME = { } HystLevel = ns.enum("HystLevel", True) -HYST_LEVEL = { - "None": HystLevel.HYST_LEVEL_NONE, - "Low": HystLevel.HYST_LEVEL_LOW, - "Medium": HystLevel.HYST_LEVEL_MEDIUM, - "High": HystLevel.HYST_LEVEL_HIGH, -} +HYST_LEVEL = HystLevel.options("None", "Low", "Medium", "High", prefix="HYST_LEVEL_") FocLimit = ns.enum("FocLimit", True) -FOC_LIMIT = { - "Disabled": FocLimit.FOC_LIMIT_DISABLED, - "BW/8": FocLimit.FOC_LIMIT_BW_8, - "BW/4": FocLimit.FOC_LIMIT_BW_4, - "BW/2": FocLimit.FOC_LIMIT_BW_2, -} +FOC_LIMIT = FocLimit.options("Disabled", "BW/8", "BW/4", "BW/2", prefix="FOC_LIMIT_") FocPreK = ns.enum("FocPreK", True) -FOC_PRE_K = { - "K": FocPreK.FOC_PRE_K_K, - "2K": FocPreK.FOC_PRE_K_2K, - "3K": FocPreK.FOC_PRE_K_3K, - "4K": FocPreK.FOC_PRE_K_4K, -} +FOC_PRE_K = FocPreK.options("K", "2K", "3K", "4K", prefix="FOC_PRE_K_") FocPostK = ns.enum("FocPostK", True) -FOC_POST_K = { - "Same": FocPostK.FOC_POST_K_SAME, - "K/2": FocPostK.FOC_POST_K_K_2, -} +FOC_POST_K = FocPostK.options("Same", "K/2", prefix="FOC_POST_K_") BsLimit = ns.enum("BsLimit", True) BS_LIMIT = { @@ -202,32 +167,16 @@ BS_LIMIT = { } BsPreKi = ns.enum("BsPreKi", True) -BS_PRE_KI = { - "KI": BsPreKi.BS_PRE_KI_KI, - "2KI": BsPreKi.BS_PRE_KI_2KI, - "3KI": BsPreKi.BS_PRE_KI_3KI, - "4KI": BsPreKi.BS_PRE_KI_4KI, -} +BS_PRE_KI = BsPreKi.options("KI", "2KI", "3KI", "4KI", prefix="BS_PRE_KI_") BsPreKp = ns.enum("BsPreKp", True) -BS_PRE_KP = { - "KP": BsPreKp.BS_PRE_KP_KP, - "2KP": BsPreKp.BS_PRE_KP_2KP, - "3KP": BsPreKp.BS_PRE_KP_3KP, - "4KP": BsPreKp.BS_PRE_KP_4KP, -} +BS_PRE_KP = BsPreKp.options("KP", "2KP", "3KP", "4KP", prefix="BS_PRE_KP_") BsPostKi = ns.enum("BsPostKi", True) -BS_POST_KI = { - "Same": BsPostKi.BS_POST_KI_SAME, - "KI/2": BsPostKi.BS_POST_KI_KI_2, -} +BS_POST_KI = BsPostKi.options("Same", "KI/2", prefix="BS_POST_KI_") BsPostKp = ns.enum("BsPostKp", True) -BS_POST_KP = { - "Same": BsPostKp.BS_POST_KP_SAME, - "KP": BsPostKp.BS_POST_KP_KP, -} +BS_POST_KP = BsPostKp.options("Same", "KP", prefix="BS_POST_KP_") # Optional settings to generate setter calls for CONFIG_MAP = { diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index a59c03ed1e5..1b335c27395 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -69,67 +69,68 @@ ClimateCall = climate_ns.class_("ClimateCall") ClimateTraits = climate_ns.class_("ClimateTraits") ClimateMode = climate_ns.enum("ClimateMode") -CLIMATE_MODES = { - "OFF": ClimateMode.CLIMATE_MODE_OFF, - "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, - "COOL": ClimateMode.CLIMATE_MODE_COOL, - "HEAT": ClimateMode.CLIMATE_MODE_HEAT, - "DRY": ClimateMode.CLIMATE_MODE_DRY, - "FAN_ONLY": ClimateMode.CLIMATE_MODE_FAN_ONLY, - "AUTO": ClimateMode.CLIMATE_MODE_AUTO, -} +CLIMATE_MODES = ClimateMode.options( + "OFF", + "HEAT_COOL", + "COOL", + "HEAT", + "DRY", + "FAN_ONLY", + "AUTO", + prefix="CLIMATE_MODE_", +) validate_climate_mode = cv.enum(CLIMATE_MODES, upper=True) ClimateFanMode = climate_ns.enum("ClimateFanMode") -CLIMATE_FAN_MODES = { - "ON": ClimateFanMode.CLIMATE_FAN_ON, - "OFF": ClimateFanMode.CLIMATE_FAN_OFF, - "AUTO": ClimateFanMode.CLIMATE_FAN_AUTO, - "LOW": ClimateFanMode.CLIMATE_FAN_LOW, - "MEDIUM": ClimateFanMode.CLIMATE_FAN_MEDIUM, - "HIGH": ClimateFanMode.CLIMATE_FAN_HIGH, - "MIDDLE": ClimateFanMode.CLIMATE_FAN_MIDDLE, - "FOCUS": ClimateFanMode.CLIMATE_FAN_FOCUS, - "DIFFUSE": ClimateFanMode.CLIMATE_FAN_DIFFUSE, - "QUIET": ClimateFanMode.CLIMATE_FAN_QUIET, -} +CLIMATE_FAN_MODES = ClimateFanMode.options( + "ON", + "OFF", + "AUTO", + "LOW", + "MEDIUM", + "HIGH", + "MIDDLE", + "FOCUS", + "DIFFUSE", + "QUIET", + prefix="CLIMATE_FAN_", +) validate_climate_fan_mode = cv.enum(CLIMATE_FAN_MODES, upper=True) ClimatePreset = climate_ns.enum("ClimatePreset") -CLIMATE_PRESETS = { - "NONE": ClimatePreset.CLIMATE_PRESET_NONE, - "ECO": ClimatePreset.CLIMATE_PRESET_ECO, - "AWAY": ClimatePreset.CLIMATE_PRESET_AWAY, - "BOOST": ClimatePreset.CLIMATE_PRESET_BOOST, - "COMFORT": ClimatePreset.CLIMATE_PRESET_COMFORT, - "HOME": ClimatePreset.CLIMATE_PRESET_HOME, - "SLEEP": ClimatePreset.CLIMATE_PRESET_SLEEP, - "ACTIVITY": ClimatePreset.CLIMATE_PRESET_ACTIVITY, -} +CLIMATE_PRESETS = ClimatePreset.options( + "NONE", + "ECO", + "AWAY", + "BOOST", + "COMFORT", + "HOME", + "SLEEP", + "ACTIVITY", + prefix="CLIMATE_PRESET_", +) validate_climate_preset = cv.enum(CLIMATE_PRESETS, upper=True) ClimateSwingMode = climate_ns.enum("ClimateSwingMode") -CLIMATE_SWING_MODES = { - "OFF": ClimateSwingMode.CLIMATE_SWING_OFF, - "BOTH": ClimateSwingMode.CLIMATE_SWING_BOTH, - "VERTICAL": ClimateSwingMode.CLIMATE_SWING_VERTICAL, - "HORIZONTAL": ClimateSwingMode.CLIMATE_SWING_HORIZONTAL, -} +CLIMATE_SWING_MODES = ClimateSwingMode.options( + "OFF", "BOTH", "VERTICAL", "HORIZONTAL", prefix="CLIMATE_SWING_" +) validate_climate_swing_mode = cv.enum(CLIMATE_SWING_MODES, upper=True) ClimateAction = climate_ns.enum("ClimateAction") -CLIMATE_ACTIONS = { - "OFF": ClimateAction.CLIMATE_ACTION_OFF, - "COOLING": ClimateAction.CLIMATE_ACTION_COOLING, - "HEATING": ClimateAction.CLIMATE_ACTION_HEATING, - "IDLE": ClimateAction.CLIMATE_ACTION_IDLE, - "DRYING": ClimateAction.CLIMATE_ACTION_DRYING, - "FAN": ClimateAction.CLIMATE_ACTION_FAN, - "DEFROSTING": ClimateAction.CLIMATE_ACTION_DEFROSTING, -} +CLIMATE_ACTIONS = ClimateAction.options( + "OFF", + "COOLING", + "HEATING", + "IDLE", + "DRYING", + "FAN", + "DEFROSTING", + prefix="CLIMATE_ACTION_", +) validate_climate_action = cv.enum(CLIMATE_ACTIONS, upper=True) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index c570f3d4ca2..7bdd9000094 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -80,11 +80,9 @@ COVER_STATES = { validate_cover_state = cv.enum(COVER_STATES, upper=True) CoverOperation = cover_ns.enum("CoverOperation") -COVER_OPERATIONS = { - "IDLE": CoverOperation.COVER_OPERATION_IDLE, - "OPENING": CoverOperation.COVER_OPERATION_OPENING, - "CLOSING": CoverOperation.COVER_OPERATION_CLOSING, -} +COVER_OPERATIONS = CoverOperation.options( + "IDLE", "OPENING", "CLOSING", prefix="COVER_OPERATION_" +) validate_cover_operation = cv.enum(COVER_OPERATIONS, upper=True) # Actions diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index d087c99bf1a..54e452efc88 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -24,10 +24,7 @@ DEPENDENCIES = ["spi"] cs5460a_ns = cg.esphome_ns.namespace("cs5460a") CS5460APGAGain = cs5460a_ns.enum("CS5460APGAGain") -PGA_GAIN_OPTIONS = { - "10X": CS5460APGAGain.CS5460A_PGA_GAIN_10X, - "50X": CS5460APGAGain.CS5460A_PGA_GAIN_50X, -} +PGA_GAIN_OPTIONS = CS5460APGAGain.options("10X", "50X", prefix="CS5460A_PGA_GAIN_") CS5460AComponent = cs5460a_ns.class_("CS5460AComponent", spi.SPIDevice, cg.Component) diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 6f695307de8..1618cb43962 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -39,10 +39,9 @@ CONF_POWER_FACTOR_1 = "power_factor_1" CONF_CURRENT_INPUT = "current_input" CSE7761CurrentInput = cse7761_ns.enum("CSE7761CurrentInput", is_class=True) -CURRENT_INPUTS = { - "shunt": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_SHUNT, - "current_transformer": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER, -} +CURRENT_INPUTS = CSE7761CurrentInput.options( + "shunt", "current_transformer", prefix="CSE7761_CURRENT_INPUT_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 5f340c60efe..d60a51c3cf7 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -223,19 +223,15 @@ DeepSleepComponent = deep_sleep_ns.class_("DeepSleepComponent", cg.Component) EnterDeepSleepAction = deep_sleep_ns.class_("EnterDeepSleepAction", automation.Action) WakeupPinMode = deep_sleep_ns.enum("WakeupPinMode") -WAKEUP_PIN_MODES = { - "IGNORE": WakeupPinMode.WAKEUP_PIN_MODE_IGNORE, - "KEEP_AWAKE": WakeupPinMode.WAKEUP_PIN_MODE_KEEP_AWAKE, - "INVERT_WAKEUP": WakeupPinMode.WAKEUP_PIN_MODE_INVERT_WAKEUP, -} +WAKEUP_PIN_MODES = WakeupPinMode.options( + "IGNORE", "KEEP_AWAKE", "INVERT_WAKEUP", prefix="WAKEUP_PIN_MODE_" +) esp_sleep_ext1_wakeup_mode_t = cg.global_ns.enum("esp_sleep_ext1_wakeup_mode_t") Ext1Wakeup = deep_sleep_ns.struct("Ext1Wakeup") -EXT1_WAKEUP_MODES = { - "ANY_LOW": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ANY_LOW, - "ALL_LOW": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ALL_LOW, - "ANY_HIGH": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ANY_HIGH, -} +EXT1_WAKEUP_MODES = esp_sleep_ext1_wakeup_mode_t.options( + "ANY_LOW", "ALL_LOW", "ANY_HIGH", prefix="ESP_EXT1_WAKEUP_" +) WakeupCauseToRunDuration = deep_sleep_ns.struct("WakeupCauseToRunDuration") WakeupCause = deep_sleep_ns.enum("WakeupCause") diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index d4ec9d6590a..69b745df9cf 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -21,19 +21,9 @@ CONF_EQ_PRESET = "eq_preset" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" EqPreset = dfplayer_ns.enum("EqPreset") -EQ_PRESET = { - "NORMAL": EqPreset.NORMAL, - "POP": EqPreset.POP, - "ROCK": EqPreset.ROCK, - "JAZZ": EqPreset.JAZZ, - "CLASSIC": EqPreset.CLASSIC, - "BASS": EqPreset.BASS, -} +EQ_PRESET = EqPreset.options("NORMAL", "POP", "ROCK", "JAZZ", "CLASSIC", "BASS") Device = dfplayer_ns.enum("Device") -DEVICE = { - "USB": Device.USB, - "TF_CARD": Device.TF_CARD, -} +DEVICE = Device.options("USB", "TF_CARD") CONFIG_SCHEMA = cv.All( cv.Schema( diff --git a/esphome/components/dht/sensor.py b/esphome/components/dht/sensor.py index c7ae70b8344..da8a5775b15 100644 --- a/esphome/components/dht/sensor.py +++ b/esphome/components/dht/sensor.py @@ -19,16 +19,17 @@ from esphome.types import ConfigType dht_ns = cg.esphome_ns.namespace("dht") DHTModel = dht_ns.enum("DHTModel") -DHT_MODELS = { - "AUTO_DETECT": DHTModel.DHT_MODEL_AUTO_DETECT, - "DHT11": DHTModel.DHT_MODEL_DHT11, - "DHT22": DHTModel.DHT_MODEL_DHT22, - "AM2120": DHTModel.DHT_MODEL_AM2120, - "AM2302": DHTModel.DHT_MODEL_AM2302, - "RHT03": DHTModel.DHT_MODEL_RHT03, - "SI7021": DHTModel.DHT_MODEL_SI7021, - "DHT22_TYPE2": DHTModel.DHT_MODEL_DHT22_TYPE2, -} +DHT_MODELS = DHTModel.options( + "AUTO_DETECT", + "DHT11", + "DHT22", + "AM2120", + "AM2302", + "RHT03", + "SI7021", + "DHT22_TYPE2", + prefix="DHT_MODEL_", +) DHT = dht_ns.class_("DHT", cg.PollingComponent) CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/es8311/audio_dac.py b/esphome/components/es8311/audio_dac.py index f9cfb822cca..036ee93bd3e 100644 --- a/esphome/components/es8311/audio_dac.py +++ b/esphome/components/es8311/audio_dac.py @@ -22,16 +22,17 @@ ES8311_BITS_PER_SAMPLE_ENUM = { } es8311_mic_gain = es8311_ns.enum("ES8311MicGain") -ES8311_MIC_GAIN_ENUM = { - "0DB": es8311_mic_gain.ES8311_MIC_GAIN_0DB, - "6DB": es8311_mic_gain.ES8311_MIC_GAIN_6DB, - "12DB": es8311_mic_gain.ES8311_MIC_GAIN_12DB, - "18DB": es8311_mic_gain.ES8311_MIC_GAIN_18DB, - "24DB": es8311_mic_gain.ES8311_MIC_GAIN_24DB, - "30DB": es8311_mic_gain.ES8311_MIC_GAIN_30DB, - "36DB": es8311_mic_gain.ES8311_MIC_GAIN_36DB, - "42DB": es8311_mic_gain.ES8311_MIC_GAIN_42DB, -} +ES8311_MIC_GAIN_ENUM = es8311_mic_gain.options( + "0DB", + "6DB", + "12DB", + "18DB", + "24DB", + "30DB", + "36DB", + "42DB", + prefix="ES8311_MIC_GAIN_", +) _validate_bits = cv.float_with_unit("bits", "bit") diff --git a/esphome/components/es8388/select/__init__.py b/esphome/components/es8388/select/__init__.py index f0e3f5b7c89..41866877384 100644 --- a/esphome/components/es8388/select/__init__.py +++ b/esphome/components/es8388/select/__init__.py @@ -14,18 +14,14 @@ CONF_DAC_OUTPUT = "dac_output" CONF_ADC_INPUT_MIC = "adc_input_mic" DacOutputLine = es8388_ns.enum("DacOutputLine") -DAC_OUTPUT_OPTIONS = { - "LINE1": DacOutputLine.DAC_OUTPUT_LINE1, - "LINE2": DacOutputLine.DAC_OUTPUT_LINE2, - "BOTH": DacOutputLine.DAC_OUTPUT_BOTH, -} +DAC_OUTPUT_OPTIONS = DacOutputLine.options( + "LINE1", "LINE2", "BOTH", prefix="DAC_OUTPUT_" +) DAC_OUTPUT_OPTION_NAMES = list(DAC_OUTPUT_OPTIONS) AdcInputMicLine = es8388_ns.enum("AdcInputMicLine") -ADC_INPUT_MIC_OPTIONS = { - "LINE1": AdcInputMicLine.ADC_INPUT_MIC_LINE1, - "LINE2": AdcInputMicLine.ADC_INPUT_MIC_LINE2, - "DIFFERENCE": AdcInputMicLine.ADC_INPUT_MIC_DIFFERENCE, -} +ADC_INPUT_MIC_OPTIONS = AdcInputMicLine.options( + "LINE1", "LINE2", "DIFFERENCE", prefix="ADC_INPUT_MIC_" +) ADC_INPUT_MIC_OPTION_NAMES = list(ADC_INPUT_MIC_OPTIONS) DacOutputSelect = es8388_ns.class_("DacOutputSelect", select.Select) diff --git a/esphome/components/es9038q2m_katana/audio_dac.py b/esphome/components/es9038q2m_katana/audio_dac.py index f5c14d234c6..0568e5e202a 100644 --- a/esphome/components/es9038q2m_katana/audio_dac.py +++ b/esphome/components/es9038q2m_katana/audio_dac.py @@ -42,12 +42,9 @@ FILTER_SHAPES = { } DeemphasisMode = es9038q2m_katana_ns.enum("DeemphasisMode") -DEEMPHASIS_MODES = { - "bypass": DeemphasisMode.DEEMPHASIS_BYPASS, - "32khz": DeemphasisMode.DEEMPHASIS_32KHZ, - "44_1khz": DeemphasisMode.DEEMPHASIS_44_1KHZ, - "48khz": DeemphasisMode.DEEMPHASIS_48KHZ, -} +DEEMPHASIS_MODES = DeemphasisMode.options( + "bypass", "32khz", "44_1khz", "48khz", prefix="DEEMPHASIS_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 48f2e3c9460..95a83edf76f 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -310,16 +310,17 @@ IO_CAPABILITY = { } AuthReqMode = esp32_ble_ns.enum("AuthReqMode") -AUTH_REQ_MODE = { - "no_bond": AuthReqMode.AUTH_REQ_NO_BOND, - "bond": AuthReqMode.AUTH_REQ_BOND, - "mitm": AuthReqMode.AUTH_REQ_MITM, - "bond_mitm": AuthReqMode.AUTH_REQ_BOND_MITM, - "sc_only": AuthReqMode.AUTH_REQ_SC_ONLY, - "sc_bond": AuthReqMode.AUTH_REQ_SC_BOND, - "sc_mitm": AuthReqMode.AUTH_REQ_SC_MITM, - "sc_mitm_bond": AuthReqMode.AUTH_REQ_SC_MITM_BOND, -} +AUTH_REQ_MODE = AuthReqMode.options( + "no_bond", + "bond", + "mitm", + "bond_mitm", + "sc_only", + "sc_bond", + "sc_mitm", + "sc_mitm_bond", + prefix="AUTH_REQ_", +) esp_power_level_t = cg.global_ns.enum("esp_power_level_t") diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 2d4e1ed7e99..0014d38a5c4 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -90,50 +90,42 @@ FRAME_SIZES = { "QSXGA": ESP32CameraFrameSize.ESP32_CAMERA_SIZE_2560X1920, } ESP32CameraPixelFormat = esp32_camera_ns.enum("ESP32CameraPixelFormat") -PIXEL_FORMATS = { - "RGB565": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB565, - "YUV422": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_YUV422, - "YUV420": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_YUV420, - "GRAYSCALE": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_GRAYSCALE, - "JPEG": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_JPEG, - "RGB888": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB888, - "RAW": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RAW, - "RGB444": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB444, - "RGB555": ESP32CameraPixelFormat.ESP32_PIXEL_FORMAT_RGB555, -} +PIXEL_FORMATS = ESP32CameraPixelFormat.options( + "RGB565", + "YUV422", + "YUV420", + "GRAYSCALE", + "JPEG", + "RGB888", + "RAW", + "RGB444", + "RGB555", + prefix="ESP32_PIXEL_FORMAT_", +) ESP32GainControlMode = esp32_camera_ns.enum("ESP32GainControlMode") ENUM_GAIN_CONTROL_MODE = { "MANUAL": ESP32GainControlMode.ESP32_GC_MODE_MANU, "AUTO": ESP32GainControlMode.ESP32_GC_MODE_AUTO, } ESP32AgcGainCeiling = esp32_camera_ns.enum("ESP32AgcGainCeiling") -ENUM_GAIN_CEILING = { - "2X": ESP32AgcGainCeiling.ESP32_GAINCEILING_2X, - "4X": ESP32AgcGainCeiling.ESP32_GAINCEILING_4X, - "8X": ESP32AgcGainCeiling.ESP32_GAINCEILING_8X, - "16X": ESP32AgcGainCeiling.ESP32_GAINCEILING_16X, - "32X": ESP32AgcGainCeiling.ESP32_GAINCEILING_32X, - "64X": ESP32AgcGainCeiling.ESP32_GAINCEILING_64X, - "128X": ESP32AgcGainCeiling.ESP32_GAINCEILING_128X, -} +ENUM_GAIN_CEILING = ESP32AgcGainCeiling.options( + "2X", "4X", "8X", "16X", "32X", "64X", "128X", prefix="ESP32_GAINCEILING_" +) ESP32WhiteBalanceMode = esp32_camera_ns.enum("ESP32WhiteBalanceMode") -ENUM_WB_MODE = { - "AUTO": ESP32WhiteBalanceMode.ESP32_WB_MODE_AUTO, - "SUNNY": ESP32WhiteBalanceMode.ESP32_WB_MODE_SUNNY, - "CLOUDY": ESP32WhiteBalanceMode.ESP32_WB_MODE_CLOUDY, - "OFFICE": ESP32WhiteBalanceMode.ESP32_WB_MODE_OFFICE, - "HOME": ESP32WhiteBalanceMode.ESP32_WB_MODE_HOME, -} +ENUM_WB_MODE = ESP32WhiteBalanceMode.options( + "AUTO", "SUNNY", "CLOUDY", "OFFICE", "HOME", prefix="ESP32_WB_MODE_" +) ESP32SpecialEffect = esp32_camera_ns.enum("ESP32SpecialEffect") -ENUM_SPECIAL_EFFECT = { - "NONE": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_NONE, - "NEGATIVE": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_NEGATIVE, - "GRAYSCALE": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_GRAYSCALE, - "RED_TINT": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_RED_TINT, - "GREEN_TINT": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_GREEN_TINT, - "BLUE_TINT": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_BLUE_TINT, - "SEPIA": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_SEPIA, -} +ENUM_SPECIAL_EFFECT = ESP32SpecialEffect.options( + "NONE", + "NEGATIVE", + "GRAYSCALE", + "RED_TINT", + "GREEN_TINT", + "BLUE_TINT", + "SEPIA", + prefix="ESP32_SPECIAL_EFFECT_", +) camera_fb_location_t = cg.global_ns.enum("camera_fb_location_t") ENUM_FB_LOCATION = { diff --git a/esphome/components/esp32_camera_web_server/__init__.py b/esphome/components/esp32_camera_web_server/__init__.py index c063eb88be5..60465cf8f91 100644 --- a/esphome/components/esp32_camera_web_server/__init__.py +++ b/esphome/components/esp32_camera_web_server/__init__.py @@ -14,7 +14,7 @@ esp32_camera_web_server_ns = cg.esphome_ns.namespace("esp32_camera_web_server") CameraWebServer = esp32_camera_web_server_ns.class_("CameraWebServer", cg.Component) Mode = esp32_camera_web_server_ns.enum("Mode") -MODES = {"STREAM": Mode.STREAM, "SNAPSHOT": Mode.SNAPSHOT} +MODES = Mode.options("STREAM", "SNAPSHOT") def _consume_camera_web_server_sockets(config: ConfigType) -> ConfigType: diff --git a/esphome/components/espectre/__init__.py b/esphome/components/espectre/__init__.py index 4f10db996c9..3d9154c5d62 100644 --- a/esphome/components/espectre/__init__.py +++ b/esphome/components/espectre/__init__.py @@ -38,15 +38,8 @@ CsiCapturePolicy = sdk_ns.enum("CsiCapturePolicy", is_class=True) TrafficGeneratorMode = sdk_ns.enum("TrafficGeneratorMode", is_class=True) WifiBandPolicy = sdk_ns.enum("WifiBandPolicy", is_class=True) -DETECTION_ALGORITHMS = { - "lightweight": DetectionAlgorithm.LIGHTWEIGHT, - "high_accuracy": DetectionAlgorithm.HIGH_ACCURACY, -} -CSI_CAPTURE_PROFILES = { - "auto": CsiCapturePolicy.AUTO, - "lltf": CsiCapturePolicy.LLTF, - "ht_vht": CsiCapturePolicy.HT_VHT, -} +DETECTION_ALGORITHMS = DetectionAlgorithm.options("lightweight", "high_accuracy") +CSI_CAPTURE_PROFILES = CsiCapturePolicy.options("auto", "lltf", "ht_vht") TRAFFIC_GENERATOR_MODES = { "ping": TrafficGeneratorMode.PING, "dns": TrafficGeneratorMode.DNS, diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 31e4952d8e0..3fa71632f02 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -117,27 +117,28 @@ CONF_INTERFACE = "interface" CONF_CLOCK_SPEED = "clock_speed" EthernetType = ethernet_ns.enum("EthernetType") -ETHERNET_TYPES = { - "LAN8720": EthernetType.ETHERNET_TYPE_LAN8720, - "RTL8201": EthernetType.ETHERNET_TYPE_RTL8201, - "DP83848": EthernetType.ETHERNET_TYPE_DP83848, - "IP101": EthernetType.ETHERNET_TYPE_IP101, - "JL1101": EthernetType.ETHERNET_TYPE_JL1101, - "KSZ8081": EthernetType.ETHERNET_TYPE_KSZ8081, - "KSZ8081RNA": EthernetType.ETHERNET_TYPE_KSZ8081RNA, - "W5100": EthernetType.ETHERNET_TYPE_W5100, - "W5500": EthernetType.ETHERNET_TYPE_W5500, - "OPENETH": EthernetType.ETHERNET_TYPE_OPENETH, - "DM9051": EthernetType.ETHERNET_TYPE_DM9051, - "LAN8670": EthernetType.ETHERNET_TYPE_LAN8670, - "ENC28J60": EthernetType.ETHERNET_TYPE_ENC28J60, - "W6100": EthernetType.ETHERNET_TYPE_W6100, - "W6300": EthernetType.ETHERNET_TYPE_W6300, - "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, - "YT8531": EthernetType.ETHERNET_TYPE_YT8531, - "CH390": EthernetType.ETHERNET_TYPE_CH390, - "KSZ8851SNL": EthernetType.ETHERNET_TYPE_KSZ8851SNL, -} +ETHERNET_TYPES = EthernetType.options( + "LAN8720", + "RTL8201", + "DP83848", + "IP101", + "JL1101", + "KSZ8081", + "KSZ8081RNA", + "W5100", + "W5500", + "OPENETH", + "DM9051", + "LAN8670", + "ENC28J60", + "W6100", + "W6300", + "GENERIC", + "YT8531", + "CH390", + "KSZ8851SNL", + prefix="ETHERNET_TYPE_", +) # PHY types that need compile-time defines for conditional compilation # Each RMII PHY type gets a define so unused PHY drivers are excluded by the linker @@ -207,10 +208,7 @@ SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) emac_rmii_clock_mode_t = cg.global_ns.enum("emac_rmii_clock_mode_t") -CLK_MODES = { - "CLK_EXT_IN": emac_rmii_clock_mode_t.EMAC_CLK_EXT_IN, - "CLK_OUT": emac_rmii_clock_mode_t.EMAC_CLK_OUT, -} +CLK_MODES = emac_rmii_clock_mode_t.options("CLK_EXT_IN", "CLK_OUT", prefix="EMAC_") spi_host_device_t = cg.global_ns.enum("spi_host_device_t") diff --git a/esphome/components/exponential_moving_average/sensor.py b/esphome/components/exponential_moving_average/sensor.py index 20c8135636d..d8f6a949969 100644 --- a/esphome/components/exponential_moving_average/sensor.py +++ b/esphome/components/exponential_moving_average/sensor.py @@ -23,11 +23,9 @@ ExponentialMovingAverageSensor = exponential_moving_average_ns.class_( ) TimeWeighting = exponential_moving_average_ns.enum("TimeWeighting") -TIME_WEIGHTINGS: dict[str, cg.MockObj] = { - "new": TimeWeighting.TIME_WEIGHTING_NEW, - "previous": TimeWeighting.TIME_WEIGHTING_PREVIOUS, - "linear": TimeWeighting.TIME_WEIGHTING_LINEAR, -} +TIME_WEIGHTINGS: dict[str, cg.MockObj] = TimeWeighting.options( + "new", "previous", "linear", prefix="TIME_WEIGHTING_" +) CONF_TIME_WEIGHTING: str = "time_weighting" diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index cb54e89c3ca..2f0003a4def 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -45,21 +45,18 @@ fan_ns = cg.esphome_ns.namespace("fan") Fan = fan_ns.class_("Fan", cg.EntityBase) FanDirection = fan_ns.enum("FanDirection", is_class=True) -FAN_DIRECTION_ENUM = { - "FORWARD": FanDirection.FORWARD, - "REVERSE": FanDirection.REVERSE, -} +FAN_DIRECTION_ENUM = FanDirection.options("FORWARD", "REVERSE") FanRestoreMode = fan_ns.enum("FanRestoreMode", is_class=True) -RESTORE_MODES = { - "NO_RESTORE": FanRestoreMode.NO_RESTORE, - "ALWAYS_OFF": FanRestoreMode.ALWAYS_OFF, - "ALWAYS_ON": FanRestoreMode.ALWAYS_ON, - "RESTORE_DEFAULT_OFF": FanRestoreMode.RESTORE_DEFAULT_OFF, - "RESTORE_DEFAULT_ON": FanRestoreMode.RESTORE_DEFAULT_ON, - "RESTORE_INVERTED_DEFAULT_OFF": FanRestoreMode.RESTORE_INVERTED_DEFAULT_OFF, - "RESTORE_INVERTED_DEFAULT_ON": FanRestoreMode.RESTORE_INVERTED_DEFAULT_ON, -} +RESTORE_MODES = FanRestoreMode.options( + "NO_RESTORE", + "ALWAYS_OFF", + "ALWAYS_ON", + "RESTORE_DEFAULT_OFF", + "RESTORE_DEFAULT_ON", + "RESTORE_INVERTED_DEFAULT_OFF", + "RESTORE_INVERTED_DEFAULT_ON", +) # Actions CycleSpeedAction = fan_ns.class_("CycleSpeedAction", automation.Action) diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index e496e9c635e..2ef654afc55 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -40,25 +40,14 @@ FingerprintGrowComponent = fingerprint_grow_ns.class_( AuraLEDState = fingerprint_grow_ns.enum("GrowAuraLEDState", True) -AURA_LED_STATES = { - "BREATHING": AuraLEDState.BREATHING, - "FLASHING": AuraLEDState.FLASHING, - "ALWAYS_ON": AuraLEDState.ALWAYS_ON, - "ALWAYS_OFF": AuraLEDState.ALWAYS_OFF, - "GRADUAL_ON": AuraLEDState.GRADUAL_ON, - "GRADUAL_OFF": AuraLEDState.GRADUAL_OFF, -} +AURA_LED_STATES = AuraLEDState.options( + "BREATHING", "FLASHING", "ALWAYS_ON", "ALWAYS_OFF", "GRADUAL_ON", "GRADUAL_OFF" +) validate_aura_led_states = cv.enum(AURA_LED_STATES, upper=True) AuraLEDColor = fingerprint_grow_ns.enum("GrowAuraLEDColor", True) -AURA_LED_COLORS = { - "RED": AuraLEDColor.RED, - "BLUE": AuraLEDColor.BLUE, - "PURPLE": AuraLEDColor.PURPLE, - "GREEN": AuraLEDColor.GREEN, - "YELLOW": AuraLEDColor.YELLOW, - "CYAN": AuraLEDColor.CYAN, - "WHITE": AuraLEDColor.WHITE, -} +AURA_LED_COLORS = AuraLEDColor.options( + "RED", "BLUE", "PURPLE", "GREEN", "YELLOW", "CYAN", "WHITE" +) validate_aura_led_colors = cv.enum(AURA_LED_COLORS, upper=True) diff --git a/esphome/components/friedrich/climate.py b/esphome/components/friedrich/climate.py index 52f1f35708d..b2c65843676 100644 --- a/esphome/components/friedrich/climate.py +++ b/esphome/components/friedrich/climate.py @@ -11,9 +11,7 @@ friedrich_ns = cg.esphome_ns.namespace("friedrich") FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR) Model = friedrich_ns.enum("Model", is_class=True) -MODELS = { - "MW12Y3H": Model.MODEL_MW12Y3H, -} +MODELS = Model.options("MW12Y3H", prefix="MODEL_") CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend( { diff --git a/esphome/components/gp8403/__init__.py b/esphome/components/gp8403/__init__.py index 583b1d15689..59bab37d255 100644 --- a/esphome/components/gp8403/__init__.py +++ b/esphome/components/gp8403/__init__.py @@ -17,15 +17,9 @@ GP8403Model = gp8403_ns.enum("GP8403Model") CONF_GP8403_ID = "gp8403_id" -MODELS = { - "GP8403": GP8403Model.GP8403, - "GP8413": GP8403Model.GP8413, -} +MODELS = GP8403Model.options("GP8403", "GP8413") -VOLTAGES = { - "5V": GP8403Voltage.GP8403_VOLTAGE_5V, - "10V": GP8403Voltage.GP8403_VOLTAGE_10V, -} +VOLTAGES = GP8403Voltage.options("5V", "10V", prefix="GP8403_VOLTAGE_") CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index 0aee5973347..ce9035655c3 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -43,26 +43,17 @@ GraphTrace = graph_ns.class_("GraphTrace") GraphLegend = graph_ns.class_("GraphLegend") LineType = graph_ns.enum("LineType") -LINE_TYPE = { - "SOLID": LineType.LINE_TYPE_SOLID, - "DOTTED": LineType.LINE_TYPE_DOTTED, - "DASHED": LineType.LINE_TYPE_DASHED, -} +LINE_TYPE = LineType.options("SOLID", "DOTTED", "DASHED", prefix="LINE_TYPE_") DirectionType = graph_ns.enum("DirectionType") -DIRECTION_TYPE = { - "AUTO": DirectionType.DIRECTION_TYPE_AUTO, - "HORIZONTAL": DirectionType.DIRECTION_TYPE_HORIZONTAL, - "VERTICAL": DirectionType.DIRECTION_TYPE_VERTICAL, -} +DIRECTION_TYPE = DirectionType.options( + "AUTO", "HORIZONTAL", "VERTICAL", prefix="DIRECTION_TYPE_" +) ValuePositionType = graph_ns.enum("ValuePositionType") -VALUE_POSITION_TYPE = { - "NONE": ValuePositionType.VALUE_POSITION_TYPE_NONE, - "AUTO": ValuePositionType.VALUE_POSITION_TYPE_AUTO, - "BESIDE": ValuePositionType.VALUE_POSITION_TYPE_BESIDE, - "BELOW": ValuePositionType.VALUE_POSITION_TYPE_BELOW, -} +VALUE_POSITION_TYPE = ValuePositionType.options( + "NONE", "AUTO", "BESIDE", "BELOW", prefix="VALUE_POSITION_TYPE_" +) GRAPH_TRACE_SCHEMA = cv.Schema( { diff --git a/esphome/components/gree/climate.py b/esphome/components/gree/climate.py index 356845a7a6f..ebdc5f27744 100644 --- a/esphome/components/gree/climate.py +++ b/esphome/components/gree/climate.py @@ -12,15 +12,9 @@ AUTO_LOAD = ["climate_ir"] GreeClimate = gree_ns.class_("GreeClimate", climate_ir.ClimateIR) Model = gree_ns.enum("Model") -MODELS = { - "generic": Model.GREE_GENERIC, - "yan": Model.GREE_YAN, - "yaa": Model.GREE_YAA, - "yac": Model.GREE_YAC, - "yac1fb9": Model.GREE_YAC1FB9, - "yx1ff": Model.GREE_YX1FF, - "yag": Model.GREE_YAG, -} +MODELS = Model.options( + "generic", "yan", "yaa", "yac", "yac1fb9", "yx1ff", "yag", prefix="GREE_" +) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend( { diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index af323e6b07d..0f49a30e6da 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -100,10 +100,7 @@ PV_SCHEMA = cv.Schema( ) GrowattProtocolVersion = growatt_solar_ns.enum("GrowattProtocolVersion") -PROTOCOL_VERSIONS = { - "RTU": GrowattProtocolVersion.RTU, - "RTU2": GrowattProtocolVersion.RTU2, -} +PROTOCOL_VERSIONS = GrowattProtocolVersion.options("RTU", "RTU2") CONFIG_SCHEMA = ( diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index db915e9c7f4..52171d1855f 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -67,23 +67,14 @@ Smartair2Climate = haier_ns.class_("Smartair2Climate", HaierClimateBase) CONF_HAIER_ID = "haier_id" AirflowVerticalDirection = hon_protocol_ns.enum("VerticalSwingMode", True) -AIRFLOW_VERTICAL_DIRECTION_OPTIONS = { - "HEALTH_UP": AirflowVerticalDirection.HEALTH_UP, - "MAX_UP": AirflowVerticalDirection.MAX_UP, - "UP": AirflowVerticalDirection.UP, - "CENTER": AirflowVerticalDirection.CENTER, - "DOWN": AirflowVerticalDirection.DOWN, - "HEALTH_DOWN": AirflowVerticalDirection.HEALTH_DOWN, -} +AIRFLOW_VERTICAL_DIRECTION_OPTIONS = AirflowVerticalDirection.options( + "HEALTH_UP", "MAX_UP", "UP", "CENTER", "DOWN", "HEALTH_DOWN" +) AirflowHorizontalDirection = hon_protocol_ns.enum("HorizontalSwingMode", True) -AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS = { - "MAX_LEFT": AirflowHorizontalDirection.MAX_LEFT, - "LEFT": AirflowHorizontalDirection.LEFT, - "CENTER": AirflowHorizontalDirection.CENTER, - "RIGHT": AirflowHorizontalDirection.RIGHT, - "MAX_RIGHT": AirflowHorizontalDirection.MAX_RIGHT, -} +AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS = AirflowHorizontalDirection.options( + "MAX_LEFT", "LEFT", "CENTER", "RIGHT", "MAX_RIGHT" +) SUPPORTED_SWING_MODES_OPTIONS = { "OFF": ClimateSwingMode.CLIMATE_SWING_OFF, @@ -114,11 +105,9 @@ SUPPORTED_CLIMATE_PRESETS_HON_OPTIONS = { } HonControlMethod = haier_ns.enum("HonControlMethod", True) -SUPPORTED_HON_CONTROL_METHODS = { - "MONITOR_ONLY": HonControlMethod.MONITOR_ONLY, - "SET_GROUP_PARAMETERS": HonControlMethod.SET_GROUP_PARAMETERS, - "SET_SINGLE_PARAMETER": HonControlMethod.SET_SINGLE_PARAMETER, -} +SUPPORTED_HON_CONTROL_METHODS = HonControlMethod.options( + "MONITOR_ONLY", "SET_GROUP_PARAMETERS", "SET_SINGLE_PARAMETER" +) def validate_visual(config): diff --git a/esphome/components/haier_tundra/climate.py b/esphome/components/haier_tundra/climate.py index c992d14bfed..3ef6ff48598 100644 --- a/esphome/components/haier_tundra/climate.py +++ b/esphome/components/haier_tundra/climate.py @@ -12,10 +12,7 @@ HaierTundra = haier_tundra_ns.class_("HaierTundra", climate_ir.ClimateIR) CONF_UNIT = "unit" Unit = haier_tundra_ns.enum("Unit") -UNITS = { - "a": Unit.HAIER_HEADER_UNIT_A, - "b": Unit.HAIER_HEADER_UNIT_B, -} +UNITS = Unit.options("a", "b", prefix="HAIER_HEADER_UNIT_") CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HaierTundra).extend( { diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index e862760e6ae..f6d935c300d 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -22,10 +22,7 @@ CODEOWNERS = ["@WeekendWarrior"] HBridgeFan = hbridge_ns.class_("HBridgeFan", cg.Component, fan.Fan) DecayMode = hbridge_ns.enum("DecayMode") -DECAY_MODE_OPTIONS = { - "SLOW": DecayMode.DECAY_MODE_SLOW, - "FAST": DecayMode.DECAY_MODE_FAST, -} +DECAY_MODE_OPTIONS = DecayMode.options("SLOW", "FAST", prefix="DECAY_MODE_") CONFIG_SCHEMA = ( fan.fan_schema(HBridgeFan) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 46e1a95cb12..604730f971a 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -28,12 +28,9 @@ HDC302XComponent = hdc302x_ns.class_( ) HDC302XPowerMode = hdc302x_ns.enum("HDC302XPowerMode") -POWER_MODE_OPTIONS = { - "HIGH_ACCURACY": HDC302XPowerMode.HIGH_ACCURACY, - "BALANCED": HDC302XPowerMode.BALANCED, - "LOW_POWER": HDC302XPowerMode.LOW_POWER, - "ULTRA_LOW_POWER": HDC302XPowerMode.ULTRA_LOW_POWER, -} +POWER_MODE_OPTIONS = HDC302XPowerMode.options( + "HIGH_ACCURACY", "BALANCED", "LOW_POWER", "ULTRA_LOW_POWER" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 42ef79d31d2..03148194fe6 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -78,25 +78,15 @@ PROTOCOLS = { CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = heatpumpir_ns.enum("HorizontalDirections") -HORIZONTAL_DIRECTIONS = { - "auto": HorizontalDirections.HORIZONTAL_DIRECTION_AUTO, - "middle": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE, - "left": HorizontalDirections.HORIZONTAL_DIRECTION_LEFT, - "mleft": HorizontalDirections.HORIZONTAL_DIRECTION_MLEFT, - "mright": HorizontalDirections.HORIZONTAL_DIRECTION_MRIGHT, - "right": HorizontalDirections.HORIZONTAL_DIRECTION_RIGHT, -} +HORIZONTAL_DIRECTIONS = HorizontalDirections.options( + "auto", "middle", "left", "mleft", "mright", "right", prefix="HORIZONTAL_DIRECTION_" +) CONF_VERTICAL_DEFAULT = "vertical_default" VerticalDirections = heatpumpir_ns.enum("VerticalDirections") -VERTICAL_DIRECTIONS = { - "auto": VerticalDirections.VERTICAL_DIRECTION_AUTO, - "up": VerticalDirections.VERTICAL_DIRECTION_UP, - "mup": VerticalDirections.VERTICAL_DIRECTION_MUP, - "middle": VerticalDirections.VERTICAL_DIRECTION_MIDDLE, - "mdown": VerticalDirections.VERTICAL_DIRECTION_MDOWN, - "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, -} +VERTICAL_DIRECTIONS = VerticalDirections.options( + "auto", "up", "mup", "middle", "mdown", "down", prefix="VERTICAL_DIRECTION_" +) def _default_visual(config: ConfigType) -> ConfigType: diff --git a/esphome/components/hlw8012/sensor.py b/esphome/components/hlw8012/sensor.py index 5099dcc94ca..007f7c53030 100644 --- a/esphome/components/hlw8012/sensor.py +++ b/esphome/components/hlw8012/sensor.py @@ -41,11 +41,9 @@ INITIAL_MODES = { CONF_VOLTAGE: HLW8012InitialMode.HLW8012_INITIAL_MODE_VOLTAGE, } -MODELS = { - "HLW8012": HLW8012SensorModels.HLW8012_SENSOR_MODEL_HLW8012, - "CSE7759": HLW8012SensorModels.HLW8012_SENSOR_MODEL_CSE7759, - "BL0937": HLW8012SensorModels.HLW8012_SENSOR_MODEL_BL0937, -} +MODELS = HLW8012SensorModels.options( + "HLW8012", "CSE7759", "BL0937", prefix="HLW8012_SENSOR_MODEL_" +) CONF_CF1_PIN = "cf1_pin" CONF_CF_PIN = "cf_pin" diff --git a/esphome/components/honeywellabp2_i2c/sensor.py b/esphome/components/honeywellabp2_i2c/sensor.py index d4c4e44a3b6..7f41fe21939 100644 --- a/esphome/components/honeywellabp2_i2c/sensor.py +++ b/esphome/components/honeywellabp2_i2c/sensor.py @@ -20,10 +20,7 @@ CONF_MIN_PRESSURE = "min_pressure" CONF_MAX_PRESSURE = "max_pressure" TRANSFER_FUNCTION = "transfer_function" ABP2TRANFERFUNCTION = honeywellabp2_ns.enum("ABP2TRANFERFUNCTION") -TRANS_FUNC_OPTIONS = { - "A": ABP2TRANFERFUNCTION.ABP2_TRANS_FUNC_A, - "B": ABP2TRANFERFUNCTION.ABP2_TRANS_FUNC_B, -} +TRANS_FUNC_OPTIONS = ABP2TRANFERFUNCTION.options("A", "B", prefix="ABP2_TRANS_FUNC_") HONEYWELLABP2Sensor = honeywellabp2_ns.class_( "HONEYWELLABP2Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index d78267964b0..bde4b244b4a 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -27,11 +27,9 @@ HTU21DComponent = htu21d_ns.class_( ) HTU21DSensorModels = htu21d_ns.enum("HTU21DSensorModels") -MODELS = { - "HTU21D": HTU21DSensorModels.HTU21D_SENSOR_MODEL_HTU21D, - "SI7021": HTU21DSensorModels.HTU21D_SENSOR_MODEL_SI7021, - "SHT21": HTU21DSensorModels.HTU21D_SENSOR_MODEL_SHT21, -} +MODELS = HTU21DSensorModels.options( + "HTU21D", "SI7021", "SHT21", prefix="HTU21D_SENSOR_MODEL_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 823928176a1..408126e1ad1 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -97,37 +97,32 @@ CONF_MIN_REFRESH_RATE = "min_refresh_rate" # Map to hub75 library enums (in global namespace) Hub75ShiftDriver = cg.global_ns.enum("Hub75ShiftDriver", is_class=True) -SHIFT_DRIVERS = { - "GENERIC": Hub75ShiftDriver.GENERIC, - "FM6126A": Hub75ShiftDriver.FM6126A, - "ICN2038S": Hub75ShiftDriver.ICN2038S, - "FM6124": Hub75ShiftDriver.FM6124, - "MBI5124": Hub75ShiftDriver.MBI5124, - "DP3246": Hub75ShiftDriver.DP3246, -} +SHIFT_DRIVERS = Hub75ShiftDriver.options( + "GENERIC", "FM6126A", "ICN2038S", "FM6124", "MBI5124", "DP3246" +) Hub75PanelLayout = cg.global_ns.enum("Hub75PanelLayout", is_class=True) -PANEL_LAYOUTS = { - "HORIZONTAL": Hub75PanelLayout.HORIZONTAL, - "TOP_LEFT_DOWN": Hub75PanelLayout.TOP_LEFT_DOWN, - "TOP_RIGHT_DOWN": Hub75PanelLayout.TOP_RIGHT_DOWN, - "BOTTOM_LEFT_UP": Hub75PanelLayout.BOTTOM_LEFT_UP, - "BOTTOM_RIGHT_UP": Hub75PanelLayout.BOTTOM_RIGHT_UP, - "TOP_LEFT_DOWN_ZIGZAG": Hub75PanelLayout.TOP_LEFT_DOWN_ZIGZAG, - "TOP_RIGHT_DOWN_ZIGZAG": Hub75PanelLayout.TOP_RIGHT_DOWN_ZIGZAG, - "BOTTOM_LEFT_UP_ZIGZAG": Hub75PanelLayout.BOTTOM_LEFT_UP_ZIGZAG, - "BOTTOM_RIGHT_UP_ZIGZAG": Hub75PanelLayout.BOTTOM_RIGHT_UP_ZIGZAG, -} +PANEL_LAYOUTS = Hub75PanelLayout.options( + "HORIZONTAL", + "TOP_LEFT_DOWN", + "TOP_RIGHT_DOWN", + "BOTTOM_LEFT_UP", + "BOTTOM_RIGHT_UP", + "TOP_LEFT_DOWN_ZIGZAG", + "TOP_RIGHT_DOWN_ZIGZAG", + "BOTTOM_LEFT_UP_ZIGZAG", + "BOTTOM_RIGHT_UP_ZIGZAG", +) Hub75ScanWiring = cg.global_ns.enum("Hub75ScanWiring", is_class=True) -SCAN_WIRINGS = { - "STANDARD_TWO_SCAN": Hub75ScanWiring.STANDARD_TWO_SCAN, - "SCAN_1_4_16PX_HIGH": Hub75ScanWiring.SCAN_1_4_16PX_HIGH, - "SCAN_1_8_32PX_HIGH": Hub75ScanWiring.SCAN_1_8_32PX_HIGH, - "SCAN_1_8_32PX_FULL": Hub75ScanWiring.SCAN_1_8_32PX_FULL, - "SCAN_1_8_40PX_HIGH": Hub75ScanWiring.SCAN_1_8_40PX_HIGH, - "SCAN_1_8_64PX_HIGH": Hub75ScanWiring.SCAN_1_8_64PX_HIGH, -} +SCAN_WIRINGS = Hub75ScanWiring.options( + "STANDARD_TWO_SCAN", + "SCAN_1_4_16PX_HIGH", + "SCAN_1_8_32PX_HIGH", + "SCAN_1_8_32PX_FULL", + "SCAN_1_8_40PX_HIGH", + "SCAN_1_8_64PX_HIGH", +) def _validate_scan_wiring(value: Any) -> str: diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index ddbe08922fe..3c7922421bf 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -68,18 +68,19 @@ INTERNAL_DAC_OPTIONS = { } i2s_comm_format_t = cg.global_ns.enum("i2s_comm_format_t") -I2C_COMM_FMT_OPTIONS = { - "stand_i2s": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_I2S, - "stand_msb": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_MSB, - "stand_pcm_short": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_PCM_SHORT, - "stand_pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_PCM_LONG, - "stand_max": i2s_comm_format_t.I2S_COMM_FORMAT_STAND_MAX, - "i2s_msb": i2s_comm_format_t.I2S_COMM_FORMAT_I2S_MSB, - "i2s_lsb": i2s_comm_format_t.I2S_COMM_FORMAT_I2S_LSB, - "pcm": i2s_comm_format_t.I2S_COMM_FORMAT_PCM, - "pcm_short": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_SHORT, - "pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_LONG, -} +I2C_COMM_FMT_OPTIONS = i2s_comm_format_t.options( + "stand_i2s", + "stand_msb", + "stand_pcm_short", + "stand_pcm_long", + "stand_max", + "i2s_msb", + "i2s_lsb", + "pcm", + "pcm_short", + "pcm_long", + prefix="I2S_COMM_FORMAT_", +) INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] diff --git a/esphome/components/icm20689/motion.py b/esphome/components/icm20689/motion.py index 7673dcf4cf4..b2677596c4d 100644 --- a/esphome/components/icm20689/motion.py +++ b/esphome/components/icm20689/motion.py @@ -11,12 +11,9 @@ DEPENDENCIES = ["spi"] # Enum proxies (must match the C++ enum values exactly) ICM20689AccelRange = icm20689_ns.enum("ICM20689AccelRange") -ACCEL_RANGE_OPTIONS = { - "2G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_2G, - "4G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_4G, - "8G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_8G, - "16G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = ICM20689AccelRange.options( + "2G", "4G", "8G", "16G", prefix="ICM20689_ACCEL_RANGE_" +) ICM20689GyroRange = icm20689_ns.enum("ICM20689GyroRange") GYRO_RANGE_OPTIONS = { diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 350a0c16527..93eecbbe919 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -57,14 +57,14 @@ Inkplate = inkplate_ns.class_( InkplateModel = inkplate_ns.enum("InkplateModel") -MODELS = { - "inkplate_6": InkplateModel.INKPLATE_6, - "inkplate_10": InkplateModel.INKPLATE_10, - "inkplate_6_plus": InkplateModel.INKPLATE_6_PLUS, - "inkplate_6_v2": InkplateModel.INKPLATE_6_V2, - "inkplate_5": InkplateModel.INKPLATE_5, - "inkplate_5_v2": InkplateModel.INKPLATE_5_V2, -} +MODELS = InkplateModel.options( + "inkplate_6", + "inkplate_10", + "inkplate_6_plus", + "inkplate_6_v2", + "inkplate_5", + "inkplate_5_v2", +) CONF_CUSTOM_WAVEFORM = "custom_waveform" diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index a68cb575192..d8e2c82cf6f 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -28,11 +28,9 @@ INTEGRATION_TIMES = { "d": IntegrationSensorTime.INTEGRATION_SENSOR_TIME_DAY, } IntegrationMethod = integration_ns.enum("IntegrationMethod") -INTEGRATION_METHODS = { - "trapezoid": IntegrationMethod.INTEGRATION_METHOD_TRAPEZOID, - "left": IntegrationMethod.INTEGRATION_METHOD_LEFT, - "right": IntegrationMethod.INTEGRATION_METHOD_RIGHT, -} +INTEGRATION_METHODS = IntegrationMethod.options( + "trapezoid", "left", "right", prefix="INTEGRATION_METHOD_" +) CONF_TIME_UNIT = "time_unit" CONF_INTEGRATION_METHOD = "integration_method" diff --git a/esphome/components/jsn_sr04t/sensor.py b/esphome/components/jsn_sr04t/sensor.py index 0c4187b823f..695c8b97d8d 100644 --- a/esphome/components/jsn_sr04t/sensor.py +++ b/esphome/components/jsn_sr04t/sensor.py @@ -17,10 +17,7 @@ Jsnsr04tComponent = jsn_sr04t_ns.class_( "Jsnsr04tComponent", sensor.Sensor, cg.PollingComponent, uart.UARTDevice ) Model = jsn_sr04t_ns.enum("Model") -MODEL = { - "jsn_sr04t": Model.JSN_SR04T, - "aj_sr04m": Model.AJ_SR04M, -} +MODEL = Model.options("jsn_sr04t", "aj_sr04m") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 0281609edfa..67e5942a43b 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -25,10 +25,9 @@ DEPENDENCIES = ["i2c"] lc709203f_ns = cg.esphome_ns.namespace("lc709203f") LC709203FBatteryVoltage = lc709203f_ns.enum("LC709203FBatteryVoltage") -BATTERY_VOLTAGE_OPTIONS = { - "3.7": LC709203FBatteryVoltage.LC709203F_BATTERY_VOLTAGE_3_7, - "3.8": LC709203FBatteryVoltage.LC709203F_BATTERY_VOLTAGE_3_8, -} +BATTERY_VOLTAGE_OPTIONS = LC709203FBatteryVoltage.options( + "3.7", "3.8", prefix="LC709203F_BATTERY_VOLTAGE_" +) lc709203f = lc709203f_ns.class_("Lc709203f", cg.PollingComponent, i2c.I2CDevice) diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 7a95920a740..23d1d7e00ea 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -21,24 +21,21 @@ ChannelColors = light_ns.struct("ChannelColors") # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) -COLOR_MODES = { - "ON_OFF": ColorMode.ON_OFF, - "BRIGHTNESS": ColorMode.BRIGHTNESS, - "WHITE": ColorMode.WHITE, - "COLOR_TEMPERATURE": ColorMode.COLOR_TEMPERATURE, - "COLD_WARM_WHITE": ColorMode.COLD_WARM_WHITE, - "RGB": ColorMode.RGB, - "RGB_WHITE": ColorMode.RGB_WHITE, - "RGB_COLOR_TEMPERATURE": ColorMode.RGB_COLOR_TEMPERATURE, - "RGB_COLD_WARM_WHITE": ColorMode.RGB_COLD_WARM_WHITE, -} +COLOR_MODES = ColorMode.options( + "ON_OFF", + "BRIGHTNESS", + "WHITE", + "COLOR_TEMPERATURE", + "COLD_WARM_WHITE", + "RGB", + "RGB_WHITE", + "RGB_COLOR_TEMPERATURE", + "RGB_COLD_WARM_WHITE", +) # Limit modes LimitMode = light_ns.enum("LimitMode", is_class=True) -LIMIT_MODES = { - "CLAMP": LimitMode.CLAMP, - "DO_NOTHING": LimitMode.DO_NOTHING, -} +LIMIT_MODES = LimitMode.options("CLAMP", "DO_NOTHING") # Actions ToggleAction = light_ns.class_("ToggleAction", automation.Action) diff --git a/esphome/components/lis2dw12/__init__.py b/esphome/components/lis2dw12/__init__.py index fcd885e5db4..76a69433910 100644 --- a/esphome/components/lis2dw12/__init__.py +++ b/esphome/components/lis2dw12/__init__.py @@ -37,12 +37,7 @@ lis2dw12_ns = cg.esphome_ns.namespace("lis2dw12") LIS2DW12Component = lis2dw12_ns.class_("LIS2DW12Component", cg.PollingComponent) LIS2DW12Range = lis2dw12_ns.enum("Range", True) -RANGES = { - "2G": LIS2DW12Range.RANGE_2G, - "4G": LIS2DW12Range.RANGE_4G, - "8G": LIS2DW12Range.RANGE_8G, - "16G": LIS2DW12Range.RANGE_16G, -} +RANGES = LIS2DW12Range.options("2G", "4G", "8G", "16G", prefix="RANGE_") LIS2DW12PowerMode = lis2dw12_ns.enum("PowerMode", True) POWER_MODES = { @@ -54,25 +49,23 @@ POWER_MODES = { } LIS2DW12DataRate = lis2dw12_ns.enum("DataRate", True) -DATA_RATES = { - "1.6Hz": LIS2DW12DataRate.RATE_1_6HZ, - "12.5Hz": LIS2DW12DataRate.RATE_12_5HZ, - "25Hz": LIS2DW12DataRate.RATE_25HZ, - "50Hz": LIS2DW12DataRate.RATE_50HZ, - "100Hz": LIS2DW12DataRate.RATE_100HZ, - "200Hz": LIS2DW12DataRate.RATE_200HZ, - "400Hz": LIS2DW12DataRate.RATE_400HZ, - "800Hz": LIS2DW12DataRate.RATE_800HZ, - "1600Hz": LIS2DW12DataRate.RATE_1600HZ, -} +DATA_RATES = LIS2DW12DataRate.options( + "1.6Hz", + "12.5Hz", + "25Hz", + "50Hz", + "100Hz", + "200Hz", + "400Hz", + "800Hz", + "1600Hz", + prefix="RATE_", +) LIS2DW12FilterBandwidth = lis2dw12_ns.enum("FilterBandwidth", True) -FILTER_BANDWIDTHS = { - "odr_div_2": LIS2DW12FilterBandwidth.BW_ODR_DIV_2, - "odr_div_4": LIS2DW12FilterBandwidth.BW_ODR_DIV_4, - "odr_div_10": LIS2DW12FilterBandwidth.BW_ODR_DIV_10, - "odr_div_20": LIS2DW12FilterBandwidth.BW_ODR_DIV_20, -} +FILTER_BANDWIDTHS = LIS2DW12FilterBandwidth.options( + "odr_div_2", "odr_div_4", "odr_div_10", "odr_div_20", prefix="BW_" +) _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation(CONF_ON_TAP, "add_on_tap_callback"), diff --git a/esphome/components/lis3dh/motion.py b/esphome/components/lis3dh/motion.py index ea4b4d3b81a..317d4b48e90 100644 --- a/esphome/components/lis3dh/motion.py +++ b/esphome/components/lis3dh/motion.py @@ -21,12 +21,7 @@ CONF_HIGH_PASS_FILTER = "high_pass_filter" # Enum values must match the C++ enums. LIS3DHRange = lis3dh_ns.enum("LIS3DHRange") -RANGE_OPTIONS = { - "2G": LIS3DHRange.LIS3DH_RANGE_2G, - "4G": LIS3DHRange.LIS3DH_RANGE_4G, - "8G": LIS3DHRange.LIS3DH_RANGE_8G, - "16G": LIS3DHRange.LIS3DH_RANGE_16G, -} +RANGE_OPTIONS = LIS3DHRange.options("2G", "4G", "8G", "16G", prefix="LIS3DH_RANGE_") LIS3DHDataRate = lis3dh_ns.enum("LIS3DHDataRate") DATA_RATE_OPTIONS = { @@ -58,17 +53,14 @@ ODR_HZ = { DURATION_MAX_REG = 0x7F LIS3DHOperatingMode = lis3dh_ns.enum("LIS3DHOperatingMode") -OPERATING_MODE_OPTIONS = { - "LOW_POWER": LIS3DHOperatingMode.LIS3DH_MODE_LOW_POWER, - "NORMAL": LIS3DHOperatingMode.LIS3DH_MODE_NORMAL, - "HIGH_RESOLUTION": LIS3DHOperatingMode.LIS3DH_MODE_HIGH_RESOLUTION, -} +OPERATING_MODE_OPTIONS = LIS3DHOperatingMode.options( + "LOW_POWER", "NORMAL", "HIGH_RESOLUTION", prefix="LIS3DH_MODE_" +) LIS3DHInterruptPin = lis3dh_ns.enum("LIS3DHInterruptPin") -INTERRUPT_PIN_OPTIONS = { - "INT1": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT1, - "INT2": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT2, -} +INTERRUPT_PIN_OPTIONS = LIS3DHInterruptPin.options( + "INT1", "INT2", prefix="LIS3DH_INT_PIN_" +) # INT1_CFG high-event bit per axis. INT_CFG_HIGH_EVENT = {"x": 0x02, "y": 0x08, "z": 0x20} diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index cf723df9b93..fb9a79f81fe 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -36,15 +36,16 @@ LockStateForwarder = lock_ns.class_("LockStateForwarder") LockState = lock_ns.enum("LockState") -LOCK_STATES = { - "OPEN": LockState.LOCK_STATE_OPEN, - "LOCKED": LockState.LOCK_STATE_LOCKED, - "UNLOCKED": LockState.LOCK_STATE_UNLOCKED, - "JAMMED": LockState.LOCK_STATE_JAMMED, - "OPENING": LockState.LOCK_STATE_OPENING, - "LOCKING": LockState.LOCK_STATE_LOCKING, - "UNLOCKING": LockState.LOCK_STATE_UNLOCKING, -} +LOCK_STATES = LockState.options( + "OPEN", + "LOCKED", + "UNLOCKED", + "JAMMED", + "OPENING", + "LOCKING", + "UNLOCKING", + prefix="LOCK_STATE_", +) validate_lock_state = cv.enum(LOCK_STATES, upper=True) diff --git a/esphome/components/lsm6ds/motion.py b/esphome/components/lsm6ds/motion.py index 064cd312a92..0eaff38db3e 100644 --- a/esphome/components/lsm6ds/motion.py +++ b/esphome/components/lsm6ds/motion.py @@ -19,12 +19,9 @@ DOMAIN = "lsm6ds" # ── C++ namespace / class ──────────────────────────────────────────────────── # ── Enum proxies ───────────────────────────────────────────────────────────── LSM6DSAccelRange = lsm6ds_ns.enum("LSM6DSAccelRange") -ACCEL_RANGE_OPTIONS = { - "2G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_2G, - "4G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_4G, - "8G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_8G, - "16G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = LSM6DSAccelRange.options( + "2G", "4G", "8G", "16G", prefix="LSM6DS_ACCEL_RANGE_" +) LSM6DSAccelODR = lsm6ds_ns.enum("LSM6DSAccelODR") ACCEL_ODR_OPTIONS = { diff --git a/esphome/components/max31856/sensor.py b/esphome/components/max31856/sensor.py index 43a2e18db87..4a12c1bb482 100644 --- a/esphome/components/max31856/sensor.py +++ b/esphome/components/max31856/sensor.py @@ -21,16 +21,9 @@ FILTER = { 60: MAX31865ConfigFilter.FILTER_60HZ, } MAX31856ThermocoupleType = max31856_ns.enum("MAX31856ThermocoupleType") -THERMOCOUPLE_TYPE = { - "B": MAX31856ThermocoupleType.MAX31856_TCTYPE_B, - "E": MAX31856ThermocoupleType.MAX31856_TCTYPE_E, - "J": MAX31856ThermocoupleType.MAX31856_TCTYPE_J, - "K": MAX31856ThermocoupleType.MAX31856_TCTYPE_K, - "N": MAX31856ThermocoupleType.MAX31856_TCTYPE_N, - "R": MAX31856ThermocoupleType.MAX31856_TCTYPE_R, - "S": MAX31856ThermocoupleType.MAX31856_TCTYPE_S, - "T": MAX31856ThermocoupleType.MAX31856_TCTYPE_T, -} +THERMOCOUPLE_TYPE = MAX31856ThermocoupleType.options( + "B", "E", "J", "K", "N", "R", "S", "T", prefix="MAX31856_TCTYPE_" +) CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/max31865/sensor.py b/esphome/components/max31865/sensor.py index 167a0997e45..e249dc5855c 100644 --- a/esphome/components/max31865/sensor.py +++ b/esphome/components/max31865/sensor.py @@ -21,10 +21,7 @@ MAX31865Sensor = max31865_ns.class_( ) MAX31865ConfigFilter = max31865_ns.enum("MAX31865ConfigFilter") -FILTER = { - "50HZ": MAX31865ConfigFilter.FILTER_50HZ, - "60HZ": MAX31865ConfigFilter.FILTER_60HZ, -} +FILTER = MAX31865ConfigFilter.options("50HZ", "60HZ", prefix="FILTER_") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/max44009/sensor.py b/esphome/components/max44009/sensor.py index 88673c5d00a..09b480335ec 100644 --- a/esphome/components/max44009/sensor.py +++ b/esphome/components/max44009/sensor.py @@ -19,11 +19,9 @@ MAX44009Sensor = max44009_ns.class_( ) MAX44009Mode = max44009_ns.enum("MAX44009Mode") -MODE_OPTIONS = { - "auto": MAX44009Mode.MAX44009_MODE_AUTO, - "low_power": MAX44009Mode.MAX44009_MODE_LOW_POWER, - "continuous": MAX44009Mode.MAX44009_MODE_CONTINUOUS, -} +MODE_OPTIONS = MAX44009Mode.options( + "auto", "low_power", "continuous", prefix="MAX44009_MODE_" +) CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 77e82fdec71..b5d5041c968 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -30,10 +30,7 @@ MAX6956 = max6956_ns.class_("MAX6956", cg.Component, i2c.I2CDevice) MAX6956GPIOPin = max6956_ns.class_("MAX6956GPIOPin", cg.GPIOPin) MAX6956_CURRENTMODE = max6956_ns.enum("MAX6956CURRENTMODE") -CURRENT_MODES = { - "global": MAX6956_CURRENTMODE.GLOBAL, - "segment": MAX6956_CURRENTMODE.SEGMENT, -} +CURRENT_MODES = MAX6956_CURRENTMODE.options("global", "segment") CONFIG_SCHEMA = ( diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index 7974ef20202..2271bc09f48 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -28,16 +28,10 @@ CONF_CHIP_LINES_STYLE = "chip_lines_style" integration_ns = cg.esphome_ns.namespace("max7219digit") ChipLinesStyle = integration_ns.enum("ChipLinesStyle") -CHIP_LINES_STYLE = { - "ZIGZAG": ChipLinesStyle.ZIGZAG, - "SNAKE": ChipLinesStyle.SNAKE, -} +CHIP_LINES_STYLE = ChipLinesStyle.options("ZIGZAG", "SNAKE") ScrollMode = integration_ns.enum("ScrollMode") -SCROLL_MODES = { - "CONTINUOUS": ScrollMode.CONTINUOUS, - "STOP": ScrollMode.STOP, -} +SCROLL_MODES = ScrollMode.options("CONTINUOUS", "STOP") CHIP_MODES = { "0": 0, diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index f7449b0ab9f..942a975813a 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -27,12 +27,9 @@ MCP23XXXBase = mcp23xxx_base_ns.class_("MCP23XXXBase", cg.Component) MCP23XXXGPIOPin = mcp23xxx_base_ns.class_("MCP23XXXGPIOPin", cg.GPIOPin) MCP23XXXInterruptMode = mcp23xxx_base_ns.enum("MCP23XXXInterruptMode") -MCP23XXX_INTERRUPT_MODES = { - "NO_INTERRUPT": MCP23XXXInterruptMode.MCP23XXX_NO_INTERRUPT, - "CHANGE": MCP23XXXInterruptMode.MCP23XXX_CHANGE, - "RISING": MCP23XXXInterruptMode.MCP23XXX_RISING, - "FALLING": MCP23XXXInterruptMode.MCP23XXX_FALLING, -} +MCP23XXX_INTERRUPT_MODES = MCP23XXXInterruptMode.options( + "NO_INTERRUPT", "CHANGE", "RISING", "FALLING", prefix="MCP23XXX_" +) MCP23XXX_CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/mcp2515/canbus.py b/esphome/components/mcp2515/canbus.py index eab9be0f700..62b80b172c1 100644 --- a/esphome/components/mcp2515/canbus.py +++ b/esphome/components/mcp2515/canbus.py @@ -15,18 +15,9 @@ mcp2515 = mcp2515_ns.class_("MCP2515", CanbusComponent, spi.SPIDevice) CanClock = mcp2515_ns.enum("CAN_CLOCK") McpMode = mcp2515_ns.enum("CANCTRL_REQOP_MODE") -CAN_CLOCK = { - "8MHZ": CanClock.MCP_8MHZ, - "12MHZ": CanClock.MCP_12MHZ, - "16MHZ": CanClock.MCP_16MHZ, - "20MHZ": CanClock.MCP_20MHZ, -} +CAN_CLOCK = CanClock.options("8MHZ", "12MHZ", "16MHZ", "20MHZ", prefix="MCP_") -MCP_MODE = { - "NORMAL": McpMode.CANCTRL_REQOP_NORMAL, - "LOOPBACK": McpMode.CANCTRL_REQOP_LOOPBACK, - "LISTENONLY": McpMode.CANCTRL_REQOP_LISTENONLY, -} +MCP_MODE = McpMode.options("NORMAL", "LOOPBACK", "LISTENONLY", prefix="CANCTRL_REQOP_") CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( { diff --git a/esphome/components/mcp4728/output/__init__.py b/esphome/components/mcp4728/output/__init__.py index e8cb4c47d69..659a3731a5a 100644 --- a/esphome/components/mcp4728/output/__init__.py +++ b/esphome/components/mcp4728/output/__init__.py @@ -19,7 +19,7 @@ VREF_OPTIONS = { } MCP4728Gain = mcp4728_ns.enum("MCP4728Gain") -GAIN_OPTIONS = {"X1": MCP4728Gain.MCP4728_GAIN_X1, "X2": MCP4728Gain.MCP4728_GAIN_X2} +GAIN_OPTIONS = MCP4728Gain.options("X1", "X2", prefix="MCP4728_GAIN_") MCP4728PwrDown = mcp4728_ns.enum("MCP4728PwrDown") PWRDOWN_OPTIONS = { @@ -30,12 +30,9 @@ PWRDOWN_OPTIONS = { } MCP4728ChannelIdx = mcp4728_ns.enum("MCP4728ChannelIdx") -CHANNEL_OPTIONS = { - "A": MCP4728ChannelIdx.MCP4728_CHANNEL_A, - "B": MCP4728ChannelIdx.MCP4728_CHANNEL_B, - "C": MCP4728ChannelIdx.MCP4728_CHANNEL_C, - "D": MCP4728ChannelIdx.MCP4728_CHANNEL_D, -} +CHANNEL_OPTIONS = MCP4728ChannelIdx.options( + "A", "B", "C", "D", prefix="MCP4728_CHANNEL_" +) CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { diff --git a/esphome/components/mcp9600/sensor.py b/esphome/components/mcp9600/sensor.py index 75f2e025c76..9a5afe64c28 100644 --- a/esphome/components/mcp9600/sensor.py +++ b/esphome/components/mcp9600/sensor.py @@ -22,16 +22,9 @@ MCP9600Component = mcp9600_ns.class_( ) MCP9600ThermocoupleType = mcp9600_ns.enum("MCP9600ThermocoupleType") -THERMOCOUPLE_TYPE = { - "K": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_K, - "J": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_J, - "T": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_T, - "N": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_N, - "S": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_S, - "E": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_E, - "B": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_B, - "R": MCP9600ThermocoupleType.MCP9600_THERMOCOUPLE_TYPE_R, -} +THERMOCOUPLE_TYPE = MCP9600ThermocoupleType.options( + "K", "J", "T", "N", "S", "E", "B", "R", prefix="MCP9600_THERMOCOUPLE_TYPE_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index d012773d8a6..88b000f5fcb 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -42,10 +42,9 @@ MediaPlayerSupportedFormat = media_player_ns.struct("MediaPlayerSupportedFormat" MediaPlayerFormatPurpose = media_player_ns.enum( "MediaPlayerFormatPurpose", is_class=True ) -MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = { - "default": MediaPlayerFormatPurpose.PURPOSE_DEFAULT, - "announcement": MediaPlayerFormatPurpose.PURPOSE_ANNOUNCEMENT, -} +MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = MediaPlayerFormatPurpose.options( + "default", "announcement", prefix="PURPOSE_" +) # Public API for external components. Do not remove. FORMAT_MAPPING = { diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 20cd194d2ed..cecbf9836ef 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -86,11 +86,9 @@ CUSTOM_PRESETS = { } # How midea_ac.display_toggle reaches the appliance; see do_display_toggle() for the AUTO rules -DISPLAY_CONTROLS = { - "UART": DisplayControl.DISPLAY_CONTROL_UART, - "AUTO": DisplayControl.DISPLAY_CONTROL_AUTO, - "IR": DisplayControl.DISPLAY_CONTROL_IR, -} +DISPLAY_CONTROLS = DisplayControl.options( + "UART", "AUTO", "IR", prefix="DISPLAY_CONTROL_" +) validate_modes = cv.enum(ALLOWED_CLIMATE_MODES, upper=True) validate_presets = cv.enum(ALLOWED_CLIMATE_PRESETS, upper=True) diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 1fbe1d67cf3..0085d062d77 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -24,25 +24,27 @@ CONF_FAHRENHEIT_COMPATIBILITY = "fahrenheit_compatibility" CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = mitsubishi_ns.enum("HorizontalDirections") -HORIZONTAL_DIRECTIONS = { - "left": HorizontalDirections.HORIZONTAL_DIRECTION_LEFT, - "middle-left": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE_LEFT, - "middle": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE, - "middle-right": HorizontalDirections.HORIZONTAL_DIRECTION_MIDDLE_RIGHT, - "right": HorizontalDirections.HORIZONTAL_DIRECTION_RIGHT, - "split": HorizontalDirections.HORIZONTAL_DIRECTION_SPLIT, -} +HORIZONTAL_DIRECTIONS = HorizontalDirections.options( + "left", + "middle-left", + "middle", + "middle-right", + "right", + "split", + prefix="HORIZONTAL_DIRECTION_", +) CONF_VERTICAL_DEFAULT = "vertical_default" VerticalDirections = mitsubishi_ns.enum("VerticalDirections") -VERTICAL_DIRECTIONS = { - "auto": VerticalDirections.VERTICAL_DIRECTION_AUTO, - "up": VerticalDirections.VERTICAL_DIRECTION_UP, - "middle-up": VerticalDirections.VERTICAL_DIRECTION_MIDDLE_UP, - "middle": VerticalDirections.VERTICAL_DIRECTION_MIDDLE, - "middle-down": VerticalDirections.VERTICAL_DIRECTION_MIDDLE_DOWN, - "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, -} +VERTICAL_DIRECTIONS = VerticalDirections.options( + "auto", + "up", + "middle-up", + "middle", + "middle-down", + "down", + prefix="VERTICAL_DIRECTION_", +) CONF_VERTICAL_VANES = "vertical_vanes" diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py index ec95b820455..2d996dc376a 100644 --- a/esphome/components/modbus/helpers.py +++ b/esphome/components/modbus/helpers.py @@ -9,25 +9,21 @@ PduBuffer = modbus_helpers_ns.class_("PduBuffer") FunctionCode_ns = modbus_ns.namespace("FunctionCode") FunctionCode = FunctionCode_ns.enum("FunctionCode") -MODBUS_FUNCTION_CODE = { - "read_coils": FunctionCode.READ_COILS, - "read_discrete_inputs": FunctionCode.READ_DISCRETE_INPUTS, - "read_holding_registers": FunctionCode.READ_HOLDING_REGISTERS, - "read_input_registers": FunctionCode.READ_INPUT_REGISTERS, - "write_single_coil": FunctionCode.WRITE_SINGLE_COIL, - "write_single_register": FunctionCode.WRITE_SINGLE_REGISTER, - "write_multiple_coils": FunctionCode.WRITE_MULTIPLE_COILS, - "write_multiple_registers": FunctionCode.WRITE_MULTIPLE_REGISTERS, -} +MODBUS_FUNCTION_CODE = FunctionCode.options( + "read_coils", + "read_discrete_inputs", + "read_holding_registers", + "read_input_registers", + "write_single_coil", + "write_single_register", + "write_multiple_coils", + "write_multiple_registers", +) EntityType_ns = modbus_ns.namespace("EntityType") EntityType = EntityType_ns.enum("EntityType") -MODBUS_WRITE_REGISTER_TYPE = { - "custom": EntityType.CUSTOM, - "coil": EntityType.COIL, - "holding": EntityType.HOLDING, -} +MODBUS_WRITE_REGISTER_TYPE = EntityType.options("custom", "coil", "holding") MODBUS_REGISTER_TYPE = { **MODBUS_WRITE_REGISTER_TYPE, @@ -38,23 +34,23 @@ MODBUS_REGISTER_TYPE = { SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType") SensorValueType = SensorValueType_ns.enum("SensorValueType") -SENSOR_VALUE_TYPE = { - "RAW": SensorValueType.RAW, - "U_WORD": SensorValueType.U_WORD, - "U_WORD_S": SensorValueType.U_WORD_S, - "S_WORD": SensorValueType.S_WORD, - "S_WORD_S": SensorValueType.S_WORD_S, - "U_DWORD": SensorValueType.U_DWORD, - "U_DWORD_R": SensorValueType.U_DWORD_R, - "S_DWORD": SensorValueType.S_DWORD, - "S_DWORD_R": SensorValueType.S_DWORD_R, - "U_QWORD": SensorValueType.U_QWORD, - "U_QWORD_R": SensorValueType.U_QWORD_R, - "S_QWORD": SensorValueType.S_QWORD, - "S_QWORD_R": SensorValueType.S_QWORD_R, - "FP32": SensorValueType.FP32, - "FP32_R": SensorValueType.FP32_R, -} +SENSOR_VALUE_TYPE = SensorValueType.options( + "RAW", + "U_WORD", + "U_WORD_S", + "S_WORD", + "S_WORD_S", + "U_DWORD", + "U_DWORD_R", + "S_DWORD", + "S_DWORD_R", + "U_QWORD", + "U_QWORD_R", + "S_QWORD", + "S_QWORD_R", + "FP32", + "FP32_R", +) TYPE_REGISTER_MAP = { "RAW": 1, diff --git a/esphome/components/modbus_controller/text_sensor/__init__.py b/esphome/components/modbus_controller/text_sensor/__init__.py index 7ab77700ca5..00955464a3d 100644 --- a/esphome/components/modbus_controller/text_sensor/__init__.py +++ b/esphome/components/modbus_controller/text_sensor/__init__.py @@ -33,12 +33,7 @@ ModbusTextSensor = modbus_controller_ns.class_( RawEncoding_ns = modbus_controller_ns.namespace("RawEncoding") RawEncoding = RawEncoding_ns.enum("RawEncoding") -RAW_ENCODING = { - "NONE": RawEncoding.NONE, - "HEXBYTES": RawEncoding.HEXBYTES, - "COMMA": RawEncoding.COMMA, - "ANSI": RawEncoding.ANSI, -} +RAW_ENCODING = RawEncoding.options("NONE", "HEXBYTES", "COMMA", "ANSI") CONFIG_SCHEMA = cv.All( text_sensor.text_sensor_schema() diff --git a/esphome/components/motion/binary_sensor/__init__.py b/esphome/components/motion/binary_sensor/__init__.py index 0a3876c8ab2..175f859e02e 100644 --- a/esphome/components/motion/binary_sensor/__init__.py +++ b/esphome/components/motion/binary_sensor/__init__.py @@ -24,12 +24,9 @@ MotionBinarySensor = motion_ns.class_( MotionBinarySensorType = motion_ns.enum("MotionBinarySensorType") -SENSOR_TYPES = { - "face_up": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_UP, - "face_down": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_DOWN, - "free_fall": MotionBinarySensorType.MOTION_BINARY_SENSOR_FREE_FALL, - "moving": MotionBinarySensorType.MOTION_BINARY_SENSOR_MOVING, -} +SENSOR_TYPES = MotionBinarySensorType.options( + "face_up", "face_down", "free_fall", "moving", prefix="MOTION_BINARY_SENSOR_" +) # face_up / face_down configure their threshold as a maximum tilt angle in degrees; # the C++ side compares against the cosine of that angle. diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 75260a1b0af..3c491ffc898 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -48,12 +48,7 @@ MSA_MODELS = { } MSARange = msa3xx_ns.enum("Range", True) -MSA_RANGES = { - "2G": MSARange.RANGE_2G, - "4G": MSARange.RANGE_4G, - "8G": MSARange.RANGE_8G, - "16G": MSARange.RANGE_16G, -} +MSA_RANGES = MSARange.options("2G", "4G", "8G", "16G", prefix="RANGE_") MSAResolution = msa3xx_ns.enum("Resolution", True) RESOLUTIONS_MSA301 = { diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 5822c95c7ef..96352a8f1a6 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -177,20 +177,13 @@ NumberMode = number_ns.enum("NumberMode") # skips the setter when the config equals it. DEFAULT_MODE = "AUTO" -NUMBER_MODES = { - "AUTO": NumberMode.NUMBER_MODE_AUTO, - "BOX": NumberMode.NUMBER_MODE_BOX, - "SLIDER": NumberMode.NUMBER_MODE_SLIDER, -} +NUMBER_MODES = NumberMode.options("AUTO", "BOX", "SLIDER", prefix="NUMBER_MODE_") NumberOperation = number_ns.enum("NumberOperation") -NUMBER_OPERATION_OPTIONS = { - "INCREMENT": NumberOperation.NUMBER_OP_INCREMENT, - "DECREMENT": NumberOperation.NUMBER_OP_DECREMENT, - "TO_MIN": NumberOperation.NUMBER_OP_TO_MIN, - "TO_MAX": NumberOperation.NUMBER_OP_TO_MAX, -} +NUMBER_OPERATION_OPTIONS = NumberOperation.options( + "INCREMENT", "DECREMENT", "TO_MIN", "TO_MAX", prefix="NUMBER_OP_" +) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") validate_unit_of_measurement = cv.All( diff --git a/esphome/components/pca9685/__init__.py b/esphome/components/pca9685/__init__.py index 16103aee443..699643f044c 100644 --- a/esphome/components/pca9685/__init__.py +++ b/esphome/components/pca9685/__init__.py @@ -17,10 +17,7 @@ pca9685_ns = cg.esphome_ns.namespace("pca9685") PCA9685Output = pca9685_ns.class_("PCA9685Output", cg.Component, i2c.I2CDevice) phase_balancer = pca9685_ns.enum("PhaseBalancer", is_class=True) -PHASE_BALANCERS = { - "none": phase_balancer.NONE, - "linear": phase_balancer.LINEAR, -} +PHASE_BALANCERS = phase_balancer.options("none", "linear") def validate_frequency(config: ConfigType) -> ConfigType: diff --git a/esphome/components/pcm5122/switch/__init__.py b/esphome/components/pcm5122/switch/__init__.py index 829adeccb7a..638e51a0bc3 100644 --- a/esphome/components/pcm5122/switch/__init__.py +++ b/esphome/components/pcm5122/switch/__init__.py @@ -9,10 +9,9 @@ from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns PCM5122PowerSwitch = pcm5122_ns.class_("PCM5122PowerSwitch", switch.Switch) pcm5122_power_switch_mode = pcm5122_ns.enum("PCM5122PowerSwitchMode") -PCM5122_POWER_SWITCH_MODE_ENUM = { - "standby": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_STANDBY, - "powerdown": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_POWERDOWN, -} +PCM5122_POWER_SWITCH_MODE_ENUM = pcm5122_power_switch_mode.options( + "standby", "powerdown", prefix="PCM5122_POWER_SWITCH_MODE_" +) CONFIG_SCHEMA = switch.switch_schema( PCM5122PowerSwitch, diff --git a/esphome/components/pm2005/sensor.py b/esphome/components/pm2005/sensor.py index a0c133eba10..0e36992141b 100644 --- a/esphome/components/pm2005/sensor.py +++ b/esphome/components/pm2005/sensor.py @@ -27,10 +27,7 @@ PM2005Component = pm2005_ns.class_( ) SensorType = pm2005_ns.enum("SensorType") -SENSOR_TYPE = { - "PM2005": SensorType.PM2005, - "PM2105": SensorType.PM2105, -} +SENSOR_TYPE = SensorType.options("PM2005", "PM2105") CONFIG_SCHEMA = cv.All( diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 4cf88bafe9f..46aa87a8e11 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -28,11 +28,9 @@ CONF_USE_PCNT = "use_pcnt" pulse_counter_ns = cg.esphome_ns.namespace("pulse_counter") PulseCounterCountMode = pulse_counter_ns.enum("PulseCounterCountMode") -COUNT_MODES = { - "DISABLE": PulseCounterCountMode.PULSE_COUNTER_DISABLE, - "INCREMENT": PulseCounterCountMode.PULSE_COUNTER_INCREMENT, - "DECREMENT": PulseCounterCountMode.PULSE_COUNTER_DECREMENT, -} +COUNT_MODES = PulseCounterCountMode.options( + "DISABLE", "INCREMENT", "DECREMENT", prefix="PULSE_COUNTER_" +) COUNT_MODE_SCHEMA = cv.enum(COUNT_MODES, upper=True) diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index e523ebe7569..05708c65c13 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -32,10 +32,7 @@ PulseMeterSensor = pulse_meter_ns.class_( ) PulseMeterInternalFilterMode = PulseMeterSensor.enum("InternalFilterMode") -FILTER_MODES = { - "EDGE": PulseMeterInternalFilterMode.FILTER_EDGE, - "PULSE": PulseMeterInternalFilterMode.FILTER_PULSE, -} +FILTER_MODES = PulseMeterInternalFilterMode.options("EDGE", "PULSE", prefix="FILTER_") def validate_internal_filter(value: Any) -> TimePeriodMicroseconds: diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py index 5e883acd7e1..7788caca442 100644 --- a/esphome/components/pzem6l24/sensor.py +++ b/esphome/components/pzem6l24/sensor.py @@ -40,13 +40,9 @@ PZEM6L24 = pzem6l24_ns.class_( ) ResetPhase = pzem6l24_ns.enum("ResetPhase") -RESET_PHASE_OPTIONS = { - "all": ResetPhase.RESET_PHASE_ALL, - "a": ResetPhase.RESET_PHASE_A, - "b": ResetPhase.RESET_PHASE_B, - "c": ResetPhase.RESET_PHASE_C, - "combined": ResetPhase.RESET_PHASE_COMBINED, -} +RESET_PHASE_OPTIONS = ResetPhase.options( + "all", "a", "b", "c", "combined", prefix="RESET_PHASE_" +) # Combined config keys CONF_TOTAL_ACTIVE_POWER = "total_active_power" diff --git a/esphome/components/qmi8658/motion.py b/esphome/components/qmi8658/motion.py index 2fd12dc9216..2d5a83a1f1b 100644 --- a/esphome/components/qmi8658/motion.py +++ b/esphome/components/qmi8658/motion.py @@ -14,12 +14,9 @@ from . import QMI8658Component, qmi8658_ns # Enum proxies (must match the C++ enum values exactly) QMI8658AccelRange = qmi8658_ns.enum("QMI8658AccelRange") -ACCEL_RANGE_OPTIONS = { - "2G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_2G, - "4G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_4G, - "8G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_8G, - "16G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_16G, -} +ACCEL_RANGE_OPTIONS = QMI8658AccelRange.options( + "2G", "4G", "8G", "16G", prefix="QMI8658_ACCEL_RANGE_" +) QMI8658GyroRange = qmi8658_ns.enum("QMI8658GyroRange") GYRO_RANGE_OPTIONS = { diff --git a/esphome/components/qmp6988/sensor.py b/esphome/components/qmp6988/sensor.py index abc39176c14..955e7026062 100644 --- a/esphome/components/qmp6988/sensor.py +++ b/esphome/components/qmp6988/sensor.py @@ -35,14 +35,9 @@ OVERSAMPLING_OPTIONS = { } QMP6988IIRFilter = qmp6988_ns.enum("QMP6988IIRFilter") -IIR_FILTER_OPTIONS = { - "OFF": QMP6988IIRFilter.QMP6988_IIR_FILTER_OFF, - "2X": QMP6988IIRFilter.QMP6988_IIR_FILTER_2X, - "4X": QMP6988IIRFilter.QMP6988_IIR_FILTER_4X, - "8X": QMP6988IIRFilter.QMP6988_IIR_FILTER_8X, - "16X": QMP6988IIRFilter.QMP6988_IIR_FILTER_16X, - "32X": QMP6988IIRFilter.QMP6988_IIR_FILTER_32X, -} +IIR_FILTER_OPTIONS = QMP6988IIRFilter.options( + "OFF", "2X", "4X", "8X", "16X", "32X", prefix="QMP6988_IIR_FILTER_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py index 356d78469fe..460e097b437 100644 --- a/esphome/components/qr_code/__init__.py +++ b/esphome/components/qr_code/__init__.py @@ -16,12 +16,9 @@ qr_code_ns = cg.esphome_ns.namespace("qr_code") QRCode = qr_code_ns.class_("QrCode", cg.Component) qrcodegen_Ecc = cg.esphome_ns.enum("qrcodegen_Ecc") -ECC = { - "LOW": qrcodegen_Ecc.qrcodegen_Ecc_LOW, - "MEDIUM": qrcodegen_Ecc.qrcodegen_Ecc_MEDIUM, - "QUARTILE": qrcodegen_Ecc.qrcodegen_Ecc_QUARTILE, - "HIGH": qrcodegen_Ecc.qrcodegen_Ecc_HIGH, -} +ECC = qrcodegen_Ecc.options( + "LOW", "MEDIUM", "QUARTILE", "HIGH", prefix="qrcodegen_Ecc_" +) CONFIG_SCHEMA = cv.ensure_list( cv.Schema( diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 286d209e980..0e0a63e9d94 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -2565,15 +2565,16 @@ register_protocol_action( ) = declare_protocol("Hob2Hood") Hob2HoodCommand = remote_base_ns.enum("Hob2HoodCommand") -HOB2HOOD_COMMAND_OPTIONS = { - "light_off": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_OFF, - "light_on": Hob2HoodCommand.HOB2HOOD_COMMAND_LIGHT_ON, - "fan_off": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_OFF, - "fan_low": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_LOW, - "fan_medium": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MEDIUM, - "fan_high": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_HIGH, - "fan_max": Hob2HoodCommand.HOB2HOOD_COMMAND_FAN_MAX, -} +HOB2HOOD_COMMAND_OPTIONS = Hob2HoodCommand.options( + "light_off", + "light_on", + "fan_off", + "fan_low", + "fan_medium", + "fan_high", + "fan_max", + prefix="HOB2HOOD_COMMAND_", +) HOB2HOOD_SCHEMA = cv.Schema( {cv.Required(CONF_COMMAND): cv.enum(HOB2HOOD_COMMAND_OPTIONS, lower=True)} @@ -2841,29 +2842,19 @@ register_protocol_action( ) = declare_protocol("IRA211") IRA211CommandEnum = ns.enum("IRA211Command", is_class=True) -IRA211_COMMANDS = { - "TEMP_UP": IRA211CommandEnum.IRA211_COMMAND_TEMP_UP, - "TEMP_DOWN": IRA211CommandEnum.IRA211_COMMAND_TEMP_DOWN, - "MODE": IRA211CommandEnum.IRA211_COMMAND_MODE, - "FAN": IRA211CommandEnum.IRA211_COMMAND_FAN, - "POWER": IRA211CommandEnum.IRA211_COMMAND_POWER, - "SYNC": IRA211CommandEnum.IRA211_COMMAND_SYNC, -} +IRA211_COMMANDS = IRA211CommandEnum.options( + "TEMP_UP", "TEMP_DOWN", "MODE", "FAN", "POWER", "SYNC", prefix="IRA211_COMMAND_" +) IRA211ModeEnum = ns.enum("IRA211Mode", is_class=True) -IRA211_MODES = { - "PROTECTION": IRA211ModeEnum.IRA211_MODE_PROTECTION, - "TIMER": IRA211ModeEnum.IRA211_MODE_TIMER, - "COMFORT": IRA211ModeEnum.IRA211_MODE_COMFORT, -} +IRA211_MODES = IRA211ModeEnum.options( + "PROTECTION", "TIMER", "COMFORT", prefix="IRA211_MODE_" +) IRA211FanEnum = ns.enum("IRA211Fan", is_class=True) -IRA211_FAN_SPEEDS = { - "AUTO": IRA211FanEnum.IRA211_FAN_AUTO, - "LOW": IRA211FanEnum.IRA211_FAN_LOW, - "MEDIUM": IRA211FanEnum.IRA211_FAN_MEDIUM, - "HIGH": IRA211FanEnum.IRA211_FAN_HIGH, -} +IRA211_FAN_SPEEDS = IRA211FanEnum.options( + "AUTO", "LOW", "MEDIUM", "HIGH", prefix="IRA211_FAN_" +) def _half_degree_step(value: float) -> float: diff --git a/esphome/components/resistance/sensor.py b/esphome/components/resistance/sensor.py index 1333375717e..cbf329713df 100644 --- a/esphome/components/resistance/sensor.py +++ b/esphome/components/resistance/sensor.py @@ -17,10 +17,7 @@ CONF_CONFIGURATION = "configuration" CONF_RESISTOR = "resistor" ResistanceConfiguration = resistance_ns.enum("ResistanceConfiguration") -CONFIGURATIONS = { - "DOWNSTREAM": ResistanceConfiguration.DOWNSTREAM, - "UPSTREAM": ResistanceConfiguration.UPSTREAM, -} +CONFIGURATIONS = ResistanceConfiguration.options("DOWNSTREAM", "UPSTREAM") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index ab5442e6764..31391d9c6ac 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -20,10 +20,9 @@ from esphome.types import ConfigType rotary_encoder_ns = cg.esphome_ns.namespace("rotary_encoder") RotaryEncoderRestoreMode = rotary_encoder_ns.enum("RotaryEncoderRestoreMode") -RESTORE_MODES = { - "RESTORE_DEFAULT_ZERO": RotaryEncoderRestoreMode.ROTARY_ENCODER_RESTORE_DEFAULT_ZERO, - "ALWAYS_ZERO": RotaryEncoderRestoreMode.ROTARY_ENCODER_ALWAYS_ZERO, -} +RESTORE_MODES = RotaryEncoderRestoreMode.options( + "RESTORE_DEFAULT_ZERO", "ALWAYS_ZERO", prefix="ROTARY_ENCODER_" +) RotaryEncoderResolution = rotary_encoder_ns.enum("RotaryEncoderResolution") RESOLUTIONS = { diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 5b7259f9e51..53a9d438292 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -143,12 +143,7 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( Chipset = rp2040_pio_led_strip_ns.enum("Chipset") -CHIPSETS = { - "WS2812": Chipset.CHIPSET_WS2812, - "WS2812B": Chipset.CHIPSET_WS2812B, - "SK6812": Chipset.CHIPSET_SK6812, - "SM16703": Chipset.CHIPSET_SM16703, -} +CHIPSETS = Chipset.options("WS2812", "WS2812B", "SK6812", "SM16703", prefix="CHIPSET_") @dataclass diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 71be6e43b16..e78ae2a6b22 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -37,12 +37,9 @@ SCD4XComponent = scd4x_ns.class_( "SCD4XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) MeasurementMode = scd4x_ns.enum("MEASUREMENT_MODE") -MEASUREMENT_MODE_OPTIONS = { - "periodic": MeasurementMode.PERIODIC, - "low_power_periodic": MeasurementMode.LOW_POWER_PERIODIC, - "single_shot": MeasurementMode.SINGLE_SHOT, - "single_shot_rht_only": MeasurementMode.SINGLE_SHOT_RHT_ONLY, -} +MEASUREMENT_MODE_OPTIONS = MeasurementMode.options( + "periodic", "low_power_periodic", "single_shot", "single_shot_rht_only" +) CONFIG_SCHEMA = ( diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 7b9f616460b..68fe39b6bcb 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -41,12 +41,9 @@ SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) # Enums SelectOperation = select_ns.enum("SelectOperation") -SELECT_OPERATION_OPTIONS = { - "NEXT": SelectOperation.SELECT_OP_NEXT, - "PREVIOUS": SelectOperation.SELECT_OP_PREVIOUS, - "FIRST": SelectOperation.SELECT_OP_FIRST, - "LAST": SelectOperation.SELECT_OP_LAST, -} +SELECT_OPERATION_OPTIONS = SelectOperation.options( + "NEXT", "PREVIOUS", "FIRST", "LAST", prefix="SELECT_OP_" +) _SELECT_SCHEMA = ( diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 7bfce0f26a5..d45bb2c7916 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -66,11 +66,7 @@ ACCELERATION_MODES = { "high": RhtAccelerationMode.HIGH_ACCELERATION, } -MODELS = { - "SEN50": Sen5xType.SEN50, - "SEN54": Sen5xType.SEN54, - "SEN55": Sen5xType.SEN55, -} +MODELS = Sen5xType.options("SEN50", "SEN54", "SEN55") def _gas_sensor( diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py index 24aa2d57346..bd492b0d0b6 100644 --- a/esphome/components/sendspin/text_sensor/__init__.py +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -29,12 +29,9 @@ SendspinPairingCodeTextSensor = sendspin_ns.class_( ) SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True) -SENDSPIN_TEXT_METADATA_TYPES = { - "title": SendspinTextMetadataTypes.TITLE, - "artist": SendspinTextMetadataTypes.ARTIST, - "album": SendspinTextMetadataTypes.ALBUM, - "album_artist": SendspinTextMetadataTypes.ALBUM_ARTIST, -} +SENDSPIN_TEXT_METADATA_TYPES = SendspinTextMetadataTypes.options( + "title", "artist", "album", "album_artist" +) def _request_roles(config: ConfigType) -> ConfigType: diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index d5baac3c9df..6c3327e556f 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -36,12 +36,9 @@ SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap") api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums") SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType") -SERIAL_PROXY_PORT_TYPES = { - "TTL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_TTL, - "RS232": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS232, - "RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485, - "USB_SERIAL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_USB_SERIAL, -} +SERIAL_PROXY_PORT_TYPES = SerialProxyPortType.options( + "TTL", "RS232", "RS485", "USB_SERIAL", prefix="SERIAL_PROXY_PORT_TYPE_" +) CONF_DTR_PIN = "dtr_pin" CONF_PORT_TYPE = "port_type" diff --git a/esphome/components/sht4x/sensor.py b/esphome/components/sht4x/sensor.py index 7fb52e37d4b..d491d94ba14 100644 --- a/esphome/components/sht4x/sensor.py +++ b/esphome/components/sht4x/sensor.py @@ -27,26 +27,21 @@ SHT4XComponent = sht4x_ns.class_( CONF_PRECISION = "precision" SHT4XPRECISION = sht4x_ns.enum("SHT4XPRECISION") -PRECISION_OPTIONS = { - "High": SHT4XPRECISION.SHT4X_PRECISION_HIGH, - "Med": SHT4XPRECISION.SHT4X_PRECISION_MED, - "Low": SHT4XPRECISION.SHT4X_PRECISION_LOW, -} +PRECISION_OPTIONS = SHT4XPRECISION.options( + "High", "Med", "Low", prefix="SHT4X_PRECISION_" +) CONF_HEATER_POWER = "heater_power" SHT4XHEATERPOWER = sht4x_ns.enum("SHT4XHEATERPOWER") -HEATER_POWER_OPTIONS = { - "High": SHT4XHEATERPOWER.SHT4X_HEATERPOWER_HIGH, - "Med": SHT4XHEATERPOWER.SHT4X_HEATERPOWER_MED, - "Low": SHT4XHEATERPOWER.SHT4X_HEATERPOWER_LOW, -} +HEATER_POWER_OPTIONS = SHT4XHEATERPOWER.options( + "High", "Med", "Low", prefix="SHT4X_HEATERPOWER_" +) CONF_HEATER_TIME = "heater_time" SHT4XHEATERTIME = sht4x_ns.enum("SHT4XHEATERTIME") -HEATER_TIME_OPTIONS = { - "Long": SHT4XHEATERTIME.SHT4X_HEATERTIME_LONG, - "Short": SHT4XHEATERTIME.SHT4X_HEATERTIME_SHORT, -} +HEATER_TIME_OPTIONS = SHT4XHEATERTIME.options( + "Long", "Short", prefix="SHT4X_HEATERTIME_" +) CONF_HEATER_MAX_DUTY = "heater_max_duty" diff --git a/esphome/components/sm2135/__init__.py b/esphome/components/sm2135/__init__.py index c42ce4c8b3f..7c5622c041b 100644 --- a/esphome/components/sm2135/__init__.py +++ b/esphome/components/sm2135/__init__.py @@ -17,29 +17,31 @@ CONF_SEPARATE_MODES = "separate_modes" SM2135Current = sm2135_ns.enum("SM2135Current") -DRIVE_STRENGTHS_CW = { - "10mA": SM2135Current.SM2135_CURRENT_10MA, - "15mA": SM2135Current.SM2135_CURRENT_15MA, - "20mA": SM2135Current.SM2135_CURRENT_20MA, - "25mA": SM2135Current.SM2135_CURRENT_25MA, - "30mA": SM2135Current.SM2135_CURRENT_30MA, - "35mA": SM2135Current.SM2135_CURRENT_35MA, - "40mA": SM2135Current.SM2135_CURRENT_40MA, - "45mA": SM2135Current.SM2135_CURRENT_45MA, - "50mA": SM2135Current.SM2135_CURRENT_50MA, - "55mA": SM2135Current.SM2135_CURRENT_55MA, - "60mA": SM2135Current.SM2135_CURRENT_60MA, -} -DRIVE_STRENGTHS_RGB = { - "10mA": SM2135Current.SM2135_CURRENT_10MA, - "15mA": SM2135Current.SM2135_CURRENT_15MA, - "20mA": SM2135Current.SM2135_CURRENT_20MA, - "25mA": SM2135Current.SM2135_CURRENT_25MA, - "30mA": SM2135Current.SM2135_CURRENT_30MA, - "35mA": SM2135Current.SM2135_CURRENT_35MA, - "40mA": SM2135Current.SM2135_CURRENT_40MA, - "45mA": SM2135Current.SM2135_CURRENT_45MA, -} +DRIVE_STRENGTHS_CW = SM2135Current.options( + "10mA", + "15mA", + "20mA", + "25mA", + "30mA", + "35mA", + "40mA", + "45mA", + "50mA", + "55mA", + "60mA", + prefix="SM2135_CURRENT_", +) +DRIVE_STRENGTHS_RGB = SM2135Current.options( + "10mA", + "15mA", + "20mA", + "25mA", + "30mA", + "35mA", + "40mA", + "45mA", + prefix="SM2135_CURRENT_", +) MULTI_CONF = True diff --git a/esphome/components/spa06_base/__init__.py b/esphome/components/spa06_base/__init__.py index c08d92f9d8b..d12f443b8ce 100644 --- a/esphome/components/spa06_base/__init__.py +++ b/esphome/components/spa06_base/__init__.py @@ -24,24 +24,25 @@ DOMAIN = "spa06_base" spa06_ns = cg.esphome_ns.namespace("spa06_base") SampleRate = spa06_ns.enum("SampleRate") -SAMPLE_RATE_OPTIONS = { - "1": SampleRate.SAMPLE_RATE_1, - "2": SampleRate.SAMPLE_RATE_2, - "4": SampleRate.SAMPLE_RATE_4, - "8": SampleRate.SAMPLE_RATE_8, - "16": SampleRate.SAMPLE_RATE_16, - "32": SampleRate.SAMPLE_RATE_32, - "64": SampleRate.SAMPLE_RATE_64, - "128": SampleRate.SAMPLE_RATE_128, - "25p16": SampleRate.SAMPLE_RATE_25P16, - "25p8": SampleRate.SAMPLE_RATE_25P8, - "25p4": SampleRate.SAMPLE_RATE_25P4, - "25p2": SampleRate.SAMPLE_RATE_25P2, - "25": SampleRate.SAMPLE_RATE_25, - "50": SampleRate.SAMPLE_RATE_50, - "100": SampleRate.SAMPLE_RATE_100, - "200": SampleRate.SAMPLE_RATE_200, -} +SAMPLE_RATE_OPTIONS = SampleRate.options( + "1", + "2", + "4", + "8", + "16", + "32", + "64", + "128", + "25p16", + "25p8", + "25p4", + "25p2", + "25", + "50", + "100", + "200", + prefix="SAMPLE_RATE_", +) Oversampling = spa06_ns.enum("Oversampling") OVERSAMPLING_OPTIONS = { diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 159af158086..b37a288d874 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -57,10 +57,7 @@ SpeakerMediaPlayer = speaker_ns.class_( AudioPipeline = speaker_ns.class_("AudioPipeline") AudioPipelineType = speaker_ns.enum("AudioPipelineType", is_class=True) -AUDIO_PIPELINE_TYPE_ENUM = { - "MEDIA": AudioPipelineType.MEDIA, - "ANNOUNCEMENT": AudioPipelineType.ANNOUNCEMENT, -} +AUDIO_PIPELINE_TYPE_ENUM = AudioPipelineType.options("MEDIA", "ANNOUNCEMENT") StopStreamAction = speaker_ns.class_( "StopStreamAction", automation.Action, cg.Parented.template(SpeakerMediaPlayer) diff --git a/esphome/components/spi_device/__init__.py b/esphome/components/spi_device/__init__.py index 3f64dfc3d16..d841987fdc3 100644 --- a/esphome/components/spi_device/__init__.py +++ b/esphome/components/spi_device/__init__.py @@ -14,10 +14,7 @@ spi_device_ns = cg.esphome_ns.namespace("spi_device") spi_device = spi_device_ns.class_("SPIDeviceComponent", cg.Component, spi.SPIDevice) BitOrder = spi.spi_ns.enum("SPIBitOrder") -ORDERS = { - "msb_first": BitOrder.BIT_ORDER_MSB_FIRST, - "lsb_first": BitOrder.BIT_ORDER_LSB_FIRST, -} +ORDERS = BitOrder.options("msb_first", "lsb_first", prefix="BIT_ORDER_") CONF_BIT_ORDER = "bit_order" CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 16d7ef8e862..2805837ee01 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -54,10 +54,7 @@ ST7701S = st7701s_ns.class_("ST7701S", display.Display, cg.Component, spi.SPIDev ColorOrder = display.display_ns.enum("ColorMode") ST7701S_DELAY_FLAG = 0xFF -COLOR_ORDERS = { - "RGB": ColorOrder.COLOR_ORDER_RGB, - "BGR": ColorOrder.COLOR_ORDER_BGR, -} +COLOR_ORDERS = ColorOrder.options("RGB", "BGR", prefix="COLOR_ORDER_") DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py index 212fe6bb489..4385164384a 100644 --- a/esphome/components/stcc4/sensor.py +++ b/esphome/components/stcc4/sensor.py @@ -36,10 +36,7 @@ STCC4Component = stcc4_ns.class_( MeasurementMode = stcc4_ns.enum("MeasurementMode", is_class=True) -MEASUREMENT_MODE_OPTIONS = { - "continuous": MeasurementMode.CONTINUOUS, - "single_shot": MeasurementMode.SINGLE_SHOT, -} +MEASUREMENT_MODE_OPTIONS = MeasurementMode.options("continuous", "single_shot") def validate_config(config: ConfigType) -> ConfigType: diff --git a/esphome/components/sun/sensor/__init__.py b/esphome/components/sun/sensor/__init__.py index d2e9fa750dc..ef4e51d5e5e 100644 --- a/esphome/components/sun/sensor/__init__.py +++ b/esphome/components/sun/sensor/__init__.py @@ -15,10 +15,7 @@ DEPENDENCIES = ["sun"] SunSensor = sun_ns.class_("SunSensor", sensor.Sensor, cg.PollingComponent) SensorType = sun_ns.enum("SensorType") -TYPES = { - "elevation": SensorType.SUN_SENSOR_ELEVATION, - "azimuth": SensorType.SUN_SENSOR_AZIMUTH, -} +TYPES = SensorType.options("elevation", "azimuth", prefix="SUN_SENSOR_") CONFIG_SCHEMA = ( sensor.sensor_schema( diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index c8b920d6cb7..075aad534e7 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -89,29 +89,27 @@ BW = { "500_0kHz": SX126xBw.SX126X_BW_500000, } -CODING_RATE = { - "CR_4_5": SX126xLoraCr.LORA_CR_4_5, - "CR_4_6": SX126xLoraCr.LORA_CR_4_6, - "CR_4_7": SX126xLoraCr.LORA_CR_4_7, - "CR_4_8": SX126xLoraCr.LORA_CR_4_8, -} +CODING_RATE = SX126xLoraCr.options( + "CR_4_5", "CR_4_6", "CR_4_7", "CR_4_8", prefix="LORA_" +) MOD = { "LORA": SX126xPacketType.PACKET_TYPE_LORA, "FSK": SX126xPacketType.PACKET_TYPE_GFSK, } -TCXO_VOLTAGE = { - "1_6V": SX126xTcxoCtrl.TCXO_CTRL_1_6V, - "1_7V": SX126xTcxoCtrl.TCXO_CTRL_1_7V, - "1_8V": SX126xTcxoCtrl.TCXO_CTRL_1_8V, - "2_2V": SX126xTcxoCtrl.TCXO_CTRL_2_2V, - "2_4V": SX126xTcxoCtrl.TCXO_CTRL_2_4V, - "2_7V": SX126xTcxoCtrl.TCXO_CTRL_2_7V, - "3_0V": SX126xTcxoCtrl.TCXO_CTRL_3_0V, - "3_3V": SX126xTcxoCtrl.TCXO_CTRL_3_3V, - "NONE": SX126xTcxoCtrl.TCXO_CTRL_NONE, -} +TCXO_VOLTAGE = SX126xTcxoCtrl.options( + "1_6V", + "1_7V", + "1_8V", + "2_2V", + "2_4V", + "2_7V", + "3_0V", + "3_3V", + "NONE", + prefix="TCXO_CTRL_", +) RAMP = { "10us": SX126xRampTime.PA_RAMP_10, diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index ea7fe38ba3c..4b54c646338 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -82,16 +82,9 @@ CODING_RATE = { "CR_4_8": SX127xModemCfg1.CODING_RATE_4_8, } -MOD = { - "LORA": SX127xOpMode.MOD_LORA, - "FSK": SX127xOpMode.MOD_FSK, - "OOK": SX127xOpMode.MOD_OOK, -} +MOD = SX127xOpMode.options("LORA", "FSK", "OOK", prefix="MOD_") -PA_PIN = { - "RFO": SX127xPaConfig.PA_PIN_RFO, - "BOOST": SX127xPaConfig.PA_PIN_BOOST, -} +PA_PIN = SX127xPaConfig.options("RFO", "BOOST", prefix="PA_PIN_") RAMP = { "10us": SX127xPaRamp.PA_RAMP_10, diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index c1c770aa591..7ed2b4fe869 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -27,12 +27,9 @@ SY6970Listener = sy6970_ns.class_("SY6970Listener") I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout") # REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on -I2C_WATCHDOG_TIMEOUTS = { - "DISABLED": I2CWatchdogTimeout.I2C_WATCHDOG_DISABLED, - "40S": I2CWatchdogTimeout.I2C_WATCHDOG_40S, - "80S": I2CWatchdogTimeout.I2C_WATCHDOG_80S, - "160S": I2CWatchdogTimeout.I2C_WATCHDOG_160S, -} +I2C_WATCHDOG_TIMEOUTS = I2CWatchdogTimeout.options( + "DISABLED", "40S", "80S", "160S", prefix="I2C_WATCHDOG_" +) CONFIG_SCHEMA = ( cv.Schema( diff --git a/esphome/components/syslog/__init__.py b/esphome/components/syslog/__init__.py index b48192ddd29..f1393e9fbd7 100644 --- a/esphome/components/syslog/__init__.py +++ b/esphome/components/syslog/__init__.py @@ -17,10 +17,7 @@ syslog_ns = cg.esphome_ns.namespace("syslog") Syslog = syslog_ns.class_("Syslog", Component, Parented.template(udp.UDPComponent)) SyslogFormat = syslog_ns.enum("SyslogFormat") -SYSLOG_FORMATS = { - "RFC3164": SyslogFormat.SYSLOG_FORMAT_RFC3164, - "RFC5424": SyslogFormat.SYSLOG_FORMAT_RFC5424, -} +SYSLOG_FORMATS = SyslogFormat.options("RFC3164", "RFC5424", prefix="SYSLOG_FORMAT_") CONF_STRIP = "strip" CONF_FACILITY = "facility" diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 7b1e3e239e8..94d37832eb4 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -32,19 +32,12 @@ tas58xx_ns = cg.esphome_ns.namespace("tas58xx") TAS58xx = tas58xx_ns.class_("TAS58xx", AudioDac, cg.PollingComponent, i2c.I2CDevice) DacMode = tas58xx_ns.enum("DacMode") -DAC_MODES = { - "btl": DacMode.DAC_MODE_BTL, - "pbtl": DacMode.DAC_MODE_PBTL, -} +DAC_MODES = DacMode.options("btl", "pbtl", prefix="DAC_MODE_") MixerMode = tas58xx_ns.enum("MixerMode") -MIXER_MODES = { - "stereo": MixerMode.MIXER_MODE_STEREO, - "stereo_inverse": MixerMode.MIXER_MODE_STEREO_INVERSE, - "mono": MixerMode.MIXER_MODE_MONO, - "left": MixerMode.MIXER_MODE_LEFT, - "right": MixerMode.MIXER_MODE_RIGHT, -} +MIXER_MODES = MixerMode.options( + "stereo", "stereo_inverse", "mono", "left", "right", prefix="MIXER_MODE_" +) @dataclass(frozen=True) diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index e19d76a64ae..de327f0eba7 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -32,35 +32,31 @@ TCS34725Component = tcs34725_ns.class_( ) TCS34725IntegrationTime = tcs34725_ns.enum("TCS34725IntegrationTime") -TCS34725_INTEGRATION_TIMES = { - "auto": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_AUTO, - "2.4ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_2_4MS, - "24ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_24MS, - "50ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_50MS, - "101ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_101MS, - "120ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_120MS, - "154ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_154MS, - "180ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_180MS, - "199ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_199MS, - "240ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_240MS, - "300ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_300MS, - "360ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_360MS, - "401ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_401MS, - "420ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_420MS, - "480ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_480MS, - "499ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_499MS, - "540ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_540MS, - "600ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_600MS, - "614ms": TCS34725IntegrationTime.TCS34725_INTEGRATION_TIME_614MS, -} +TCS34725_INTEGRATION_TIMES = TCS34725IntegrationTime.options( + "auto", + "2.4ms", + "24ms", + "50ms", + "101ms", + "120ms", + "154ms", + "180ms", + "199ms", + "240ms", + "300ms", + "360ms", + "401ms", + "420ms", + "480ms", + "499ms", + "540ms", + "600ms", + "614ms", + prefix="TCS34725_INTEGRATION_TIME_", +) TCS34725Gain = tcs34725_ns.enum("TCS34725Gain") -TCS34725_GAINS = { - "1X": TCS34725Gain.TCS34725_GAIN_1X, - "4X": TCS34725Gain.TCS34725_GAIN_4X, - "16X": TCS34725Gain.TCS34725_GAIN_16X, - "60X": TCS34725Gain.TCS34725_GAIN_60X, -} +TCS34725_GAINS = TCS34725Gain.options("1X", "4X", "16X", "60X", prefix="TCS34725_GAIN_") color_channel_schema = sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, diff --git a/esphome/components/template/alarm_control_panel/__init__.py b/esphome/components/template/alarm_control_panel/__init__.py index a708c83d09f..ec59facb2cf 100644 --- a/esphome/components/template/alarm_control_panel/__init__.py +++ b/esphome/components/template/alarm_control_panel/__init__.py @@ -43,19 +43,19 @@ TemplateAlarmControlPanel = template_ns.class_( TemplateAlarmControlPanelRestoreMode = template_ns.enum( "TemplateAlarmControlPanelRestoreMode" ) -RESTORE_MODES = { - "ALWAYS_DISARMED": TemplateAlarmControlPanelRestoreMode.ALARM_CONTROL_PANEL_ALWAYS_DISARMED, - "RESTORE_DEFAULT_DISARMED": TemplateAlarmControlPanelRestoreMode.ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED, -} +RESTORE_MODES = TemplateAlarmControlPanelRestoreMode.options( + "ALWAYS_DISARMED", "RESTORE_DEFAULT_DISARMED", prefix="ALARM_CONTROL_PANEL_" +) AlarmSensorType = template_ns.enum("AlarmSensorType") -ALARM_SENSOR_TYPES = { - "DELAYED": AlarmSensorType.ALARM_SENSOR_TYPE_DELAYED, - "INSTANT": AlarmSensorType.ALARM_SENSOR_TYPE_INSTANT, - "DELAYED_FOLLOWER": AlarmSensorType.ALARM_SENSOR_TYPE_DELAYED_FOLLOWER, - "INSTANT_ALWAYS": AlarmSensorType.ALARM_SENSOR_TYPE_INSTANT_ALWAYS, -} +ALARM_SENSOR_TYPES = AlarmSensorType.options( + "DELAYED", + "INSTANT", + "DELAYED_FOLLOWER", + "INSTANT_ALWAYS", + prefix="ALARM_SENSOR_TYPE_", +) def validate_config(config): diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index 5f7627c5c38..30155de28aa 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -55,10 +55,9 @@ TemplateClimate = template_ns.class_("TemplateClimate", climate.Climate, cg.Comp TemplateClimateRestoreMode = template_ns.enum( "TemplateClimateRestoreMode", is_class=True ) -CLIMATE_RESTORE_MODES = { - "NO_RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE, - "RESTORE": TemplateClimateRestoreMode.TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE, -} +CLIMATE_RESTORE_MODES = TemplateClimateRestoreMode.options( + "NO_RESTORE", "RESTORE", prefix="TEMPLATE_CLIMATE_RESTORE_MODE_" +) # Per-field actions that forward a requested value on. The third item is the type of `x`. SET_ACTIONS = ( diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index 39df5affcf2..e47fdd9a56d 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -26,11 +26,9 @@ from .. import template_ns TemplateCover = template_ns.class_("TemplateCover", cover.Cover, cg.Component) TemplateCoverRestoreMode = template_ns.enum("TemplateCoverRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TemplateCoverRestoreMode.COVER_NO_RESTORE, - "RESTORE": TemplateCoverRestoreMode.COVER_RESTORE, - "RESTORE_AND_CALL": TemplateCoverRestoreMode.COVER_RESTORE_AND_CALL, -} +RESTORE_MODES = TemplateCoverRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="COVER_" +) CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index a7fd6f7632c..a0b8e9c7c51 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -22,11 +22,9 @@ from .. import template_ns TemplateValve = template_ns.class_("TemplateValve", valve.Valve, cg.Component) TemplateValveRestoreMode = template_ns.enum("TemplateValveRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TemplateValveRestoreMode.VALVE_NO_RESTORE, - "RESTORE": TemplateValveRestoreMode.VALVE_RESTORE, - "RESTORE_AND_CALL": TemplateValveRestoreMode.VALVE_RESTORE_AND_CALL, -} +RESTORE_MODES = TemplateValveRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="VALVE_" +) CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index 7f8a82c916d..39e3c56da5f 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -32,11 +32,9 @@ TemplateWaterHeaterPublishAction = template_ns.class_( ) TemplateWaterHeaterRestoreMode = template_ns.enum("TemplateWaterHeaterRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TemplateWaterHeaterRestoreMode.WATER_HEATER_NO_RESTORE, - "RESTORE": TemplateWaterHeaterRestoreMode.WATER_HEATER_RESTORE, - "RESTORE_AND_CALL": TemplateWaterHeaterRestoreMode.WATER_HEATER_RESTORE_AND_CALL, -} +RESTORE_MODES = TemplateWaterHeaterRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="WATER_HEATER_" +) CONFIG_SCHEMA = ( water_heater.water_heater_schema(TemplateWaterHeater) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 6d8c24918d3..bebdd7d0660 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -96,22 +96,20 @@ ThermostatClimateTargetTempConfig = thermostat_ns.struct( ThermostatPresetEntry = thermostat_ns.struct("ThermostatPresetEntry") ThermostatCustomPresetEntry = thermostat_ns.struct("ThermostatCustomPresetEntry") OnBootRestoreFrom = thermostat_ns.enum("OnBootRestoreFrom") -ON_BOOT_RESTORE_FROM = { - "MEMORY": OnBootRestoreFrom.MEMORY, - "DEFAULT_PRESET": OnBootRestoreFrom.DEFAULT_PRESET, -} +ON_BOOT_RESTORE_FROM = OnBootRestoreFrom.options("MEMORY", "DEFAULT_PRESET") validate_on_boot_restore_from = cv.enum(ON_BOOT_RESTORE_FROM, upper=True) ClimateMode = climate_ns.enum("ClimateMode") -CLIMATE_MODES = { - "OFF": ClimateMode.CLIMATE_MODE_OFF, - "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, - "COOL": ClimateMode.CLIMATE_MODE_COOL, - "HEAT": ClimateMode.CLIMATE_MODE_HEAT, - "DRY": ClimateMode.CLIMATE_MODE_DRY, - "FAN_ONLY": ClimateMode.CLIMATE_MODE_FAN_ONLY, - "AUTO": ClimateMode.CLIMATE_MODE_AUTO, -} +CLIMATE_MODES = ClimateMode.options( + "OFF", + "HEAT_COOL", + "COOL", + "HEAT", + "DRY", + "FAN_ONLY", + "AUTO", + prefix="CLIMATE_MODE_", +) validate_climate_mode = cv.enum(CLIMATE_MODES, upper=True) ClimatePreset = climate_ns.enum("ClimatePreset") diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index 82ac29c7d5b..ce06cb82e3c 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -59,12 +59,13 @@ TMC22XXStepper = tmc22xx_ns.class_( StandstillMode = tmc22xx_ns.enum("StandstillMode") DriverEvent = tmc22xx_ns.enum("DriverEvent") -STANDSTILL_MODES = { - "normal": StandstillMode.STANDSTILL_MODE_NORMAL, - "freewheeling": StandstillMode.STANDSTILL_MODE_FREEWHEELING, - "coil_short_ls": StandstillMode.STANDSTILL_MODE_COIL_SHORT_LS, - "coil_short_hs": StandstillMode.STANDSTILL_MODE_COIL_SHORT_HS, -} +STANDSTILL_MODES = StandstillMode.options( + "normal", + "freewheeling", + "coil_short_ls", + "coil_short_hs", + prefix="STANDSTILL_MODE_", +) validate_microsteps = cv.one_of(1, 2, 4, 8, 16, 32, 64, 128, 256, int=True) validate_current = cv.All(cv.current, cv.positive_float) diff --git a/esphome/components/tmp1075/sensor.py b/esphome/components/tmp1075/sensor.py index ab510db0c53..09ac80aa4f9 100644 --- a/esphome/components/tmp1075/sensor.py +++ b/esphome/components/tmp1075/sensor.py @@ -32,10 +32,7 @@ POLARITY = { } EAlertFunction = tmp1075_ns.enum("EAlertFunction") -ALERT_FUNCTION = { - "COMPARATOR": EAlertFunction.ALERT_COMPARATOR, - "INTERRUPT": EAlertFunction.ALERT_INTERRUPT, -} +ALERT_FUNCTION = EAlertFunction.options("COMPARATOR", "INTERRUPT", prefix="ALERT_") CONF_ALERT = "alert" CONF_LIMIT_LOW = "limit_low" diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 69fbb210305..1a17045a410 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -11,14 +11,15 @@ toshiba_ns = cg.esphome_ns.namespace("toshiba") ToshibaClimate = toshiba_ns.class_("ToshibaClimate", climate_ir.ClimateIR) Model = toshiba_ns.enum("Model") -MODELS = { - "GENERIC": Model.MODEL_GENERIC, - "RAC-PT1411HWRU-C": Model.MODEL_RAC_PT1411HWRU_C, - "RAC-PT1411HWRU-F": Model.MODEL_RAC_PT1411HWRU_F, - "RAS-2819T": Model.MODEL_RAS_2819T, - "RAS-B10N3KV2": Model.MODEL_RAS_B10N3KV2, - "SEIYA": Model.MODEL_SEIYA, -} +MODELS = Model.options( + "GENERIC", + "RAC-PT1411HWRU-C", + "RAC-PT1411HWRU-F", + "RAS-2819T", + "RAS-B10N3KV2", + "SEIYA", + prefix="MODEL_", +) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).extend( { diff --git a/esphome/components/total_daily_energy/sensor.py b/esphome/components/total_daily_energy/sensor.py index 343fe917de9..af2f5ff561c 100644 --- a/esphome/components/total_daily_energy/sensor.py +++ b/esphome/components/total_daily_energy/sensor.py @@ -20,11 +20,9 @@ DEPENDENCIES = ["time"] CONF_POWER_ID = "power_id" total_daily_energy_ns = cg.esphome_ns.namespace("total_daily_energy") TotalDailyEnergyMethod = total_daily_energy_ns.enum("TotalDailyEnergyMethod") -TOTAL_DAILY_ENERGY_METHODS = { - "trapezoid": TotalDailyEnergyMethod.TOTAL_DAILY_ENERGY_METHOD_TRAPEZOID, - "left": TotalDailyEnergyMethod.TOTAL_DAILY_ENERGY_METHOD_LEFT, - "right": TotalDailyEnergyMethod.TOTAL_DAILY_ENERGY_METHOD_RIGHT, -} +TOTAL_DAILY_ENERGY_METHODS = TotalDailyEnergyMethod.options( + "trapezoid", "left", "right", prefix="TOTAL_DAILY_ENERGY_METHOD_" +) TotalDailyEnergy = total_daily_energy_ns.class_( "TotalDailyEnergy", sensor.Sensor, cg.Component ) diff --git a/esphome/components/tsl2561/sensor.py b/esphome/components/tsl2561/sensor.py index 79ee2587d33..3771851acc6 100644 --- a/esphome/components/tsl2561/sensor.py +++ b/esphome/components/tsl2561/sensor.py @@ -24,10 +24,7 @@ INTEGRATION_TIMES = { } TSL2561Gain = tsl2561_ns.enum("TSL2561Gain") -GAINS = { - "1X": TSL2561Gain.TSL2561_GAIN_1X, - "16X": TSL2561Gain.TSL2561_GAIN_16X, -} +GAINS = TSL2561Gain.options("1X", "16X", prefix="TSL2561_GAIN_") CONF_IS_CS_PACKAGE = "is_cs_package" diff --git a/esphome/components/tuya/cover/__init__.py b/esphome/components/tuya/cover/__init__.py index 8c610c02720..6e1daef884a 100644 --- a/esphome/components/tuya/cover/__init__.py +++ b/esphome/components/tuya/cover/__init__.py @@ -17,11 +17,9 @@ CONF_INVERT_POSITION_REPORT = "invert_position_report" TuyaCover = tuya_ns.class_("TuyaCover", cover.Cover, cg.Component) TuyaCoverRestoreMode = tuya_ns.enum("TuyaCoverRestoreMode") -RESTORE_MODES = { - "NO_RESTORE": TuyaCoverRestoreMode.COVER_NO_RESTORE, - "RESTORE": TuyaCoverRestoreMode.COVER_RESTORE, - "RESTORE_AND_CALL": TuyaCoverRestoreMode.COVER_RESTORE_AND_CALL, -} +RESTORE_MODES = TuyaCoverRestoreMode.options( + "NO_RESTORE", "RESTORE", "RESTORE_AND_CALL", prefix="COVER_" +) def validate_range(config): diff --git a/esphome/components/tuya/light/__init__.py b/esphome/components/tuya/light/__init__.py index bf2d3daf981..5ba55b6ccbb 100644 --- a/esphome/components/tuya/light/__init__.py +++ b/esphome/components/tuya/light/__init__.py @@ -30,11 +30,7 @@ CONF_COLOR_TYPE_LOWERCASE = "color_type_lowercase" TuyaColorType = tuya_ns.enum("TuyaColorType") -COLOR_TYPES = { - "RGB": TuyaColorType.RGB, - "HSV": TuyaColorType.HSV, - "RGBHSV": TuyaColorType.RGBHSV, -} +COLOR_TYPES = TuyaColorType.options("RGB", "HSV", "RGBHSV") TuyaLight = tuya_ns.class_("TuyaLight", light.LightOutput, cg.Component) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 1cf01f4b486..fc811e385f4 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -199,11 +199,9 @@ def _uart_declare_type(value): UARTParityOptions = uart_ns.enum("UARTParityOptions") -UART_PARITY_OPTIONS = { - "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, - "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, - "ODD": UARTParityOptions.UART_CONFIG_PARITY_ODD, -} +UART_PARITY_OPTIONS = UARTParityOptions.options( + "NONE", "EVEN", "ODD", prefix="UART_CONFIG_PARITY_" +) CONF_FLUSH_TIMEOUT = "flush_timeout" CONF_RX_FULL_THRESHOLD = "rx_full_threshold" @@ -211,13 +209,9 @@ CONF_RX_TIMEOUT = "rx_timeout" CONF_CLOCK_SOURCE = "clock_source" UARTClockSource = cg.global_ns.enum("uart_sclk_t") -UART_CLOCK_SOURCES = { - "DEFAULT": UARTClockSource.UART_SCLK_DEFAULT, - "APB": UARTClockSource.UART_SCLK_APB, - "XTAL": UARTClockSource.UART_SCLK_XTAL, - "RTC": UARTClockSource.UART_SCLK_RTC, - "REF_TICK": UARTClockSource.UART_SCLK_REF_TICK, -} +UART_CLOCK_SOURCES = UARTClockSource.options( + "DEFAULT", "APB", "XTAL", "RTC", "REF_TICK", prefix="UART_SCLK_" +) # Keep in sync with SOC_UART_SUPPORT_* in ESP-IDF's per-variant soc_caps.h. UART_CLOCK_SOURCES_BY_VARIANT = { @@ -237,11 +231,7 @@ UART_CLOCK_SOURCES_BY_VARIANT = { } UARTDirection = uart_ns.enum("UARTDirection") -UART_DIRECTIONS = { - "RX": UARTDirection.UART_DIRECTION_RX, - "TX": UARTDirection.UART_DIRECTION_TX, - "BOTH": UARTDirection.UART_DIRECTION_BOTH, -} +UART_DIRECTIONS = UARTDirection.options("RX", "TX", "BOTH", prefix="UART_DIRECTION_") # The reason for having CONF_BYTES at 150 by default: # diff --git a/esphome/components/uln2003/stepper.py b/esphome/components/uln2003/stepper.py index 03fd254d175..f09b2702c71 100644 --- a/esphome/components/uln2003/stepper.py +++ b/esphome/components/uln2003/stepper.py @@ -16,11 +16,9 @@ from esphome.types import ConfigType uln2003_ns = cg.esphome_ns.namespace("uln2003") ULN2003StepMode = uln2003_ns.enum("ULN2003StepMode") -STEP_MODES = { - "FULL_STEP": ULN2003StepMode.ULN2003_STEP_MODE_FULL_STEP, - "HALF_STEP": ULN2003StepMode.ULN2003_STEP_MODE_HALF_STEP, - "WAVE_DRIVE": ULN2003StepMode.ULN2003_STEP_MODE_WAVE_DRIVE, -} +STEP_MODES = ULN2003StepMode.options( + "FULL_STEP", "HALF_STEP", "WAVE_DRIVE", prefix="ULN2003_STEP_MODE_" +) ULN2003 = uln2003_ns.class_("ULN2003", stepper.Stepper, cg.Component) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 0875966071d..213ff54afb1 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -54,11 +54,9 @@ UART_PARITY_OPTIONS = { } UARTStopBitsOptions = usb_uart_ns.enum("UARTStopBitsOptions") -UART_STOP_BITS_OPTIONS = { - "1": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_1, - "1.5": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_1_5, - "2": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_2, -} +UART_STOP_BITS_OPTIONS = UARTStopBitsOptions.options( + "1", "1.5", "2", prefix="UART_CONFIG_STOP_BITS_" +) DEFAULT_BAUD_RATE = 9600 CONF_CLAIM_COMM_INTERFACE = "claim_comm_interface" diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 41e25d63b15..932d677889a 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -54,11 +54,9 @@ VALVE_STATES = { validate_valve_state = cv.enum(VALVE_STATES, upper=True) ValveOperation = valve_ns.enum("ValveOperation") -VALVE_OPERATIONS = { - "IDLE": ValveOperation.VALVE_OPERATION_IDLE, - "OPENING": ValveOperation.VALVE_OPERATION_OPENING, - "CLOSING": ValveOperation.VALVE_OPERATION_CLOSING, -} +VALVE_OPERATIONS = ValveOperation.options( + "IDLE", "OPENING", "CLOSING", prefix="VALVE_OPERATION_" +) validate_valve_operation = cv.enum(VALVE_OPERATIONS, upper=True) CONF_ON_CLOSED = "on_closed" diff --git a/esphome/components/veml3235/sensor.py b/esphome/components/veml3235/sensor.py index afff5988698..e48e654a7a7 100644 --- a/esphome/components/veml3235/sensor.py +++ b/esphome/components/veml3235/sensor.py @@ -24,24 +24,15 @@ VEML3235Sensor = veml3235_ns.class_( "VEML3235Sensor", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice ) VEML3235ComponentIntegrationTime = veml3235_ns.enum("VEML3235ComponentIntegrationTime") -VEML3235_INTEGRATION_TIMES = { - "50ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_50MS, - "100ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_100MS, - "200ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_200MS, - "400ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_400MS, - "800ms": VEML3235ComponentIntegrationTime.VEML3235_INTEGRATION_TIME_800MS, -} +VEML3235_INTEGRATION_TIMES = VEML3235ComponentIntegrationTime.options( + "50ms", "100ms", "200ms", "400ms", "800ms", prefix="VEML3235_INTEGRATION_TIME_" +) VEML3235ComponentDigitalGain = veml3235_ns.enum("VEML3235ComponentDigitalGain") -DIGITAL_GAINS = { - "1X": VEML3235ComponentDigitalGain.VEML3235_DIGITAL_GAIN_1X, - "2X": VEML3235ComponentDigitalGain.VEML3235_DIGITAL_GAIN_2X, -} +DIGITAL_GAINS = VEML3235ComponentDigitalGain.options( + "1X", "2X", prefix="VEML3235_DIGITAL_GAIN_" +) VEML3235ComponentGain = veml3235_ns.enum("VEML3235ComponentGain") -GAINS = { - "1X": VEML3235ComponentGain.VEML3235_GAIN_1X, - "2X": VEML3235ComponentGain.VEML3235_GAIN_2X, - "4X": VEML3235ComponentGain.VEML3235_GAIN_4X, -} +GAINS = VEML3235ComponentGain.options("1X", "2X", "4X", prefix="VEML3235_GAIN_") def _validate_auto_gain_thresholds(config: ConfigType) -> ConfigType: diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index b5821e55af5..a3a7f047069 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -30,15 +30,16 @@ WaterHeaterTraits = water_heater_ns.class_("WaterHeaterTraits") CONF_TARGET_TEMPERATURE_STEP = "target_temperature_step" WaterHeaterMode = water_heater_ns.enum("WaterHeaterMode") -WATER_HEATER_MODES = { - "OFF": WaterHeaterMode.WATER_HEATER_MODE_OFF, - "ECO": WaterHeaterMode.WATER_HEATER_MODE_ECO, - "ELECTRIC": WaterHeaterMode.WATER_HEATER_MODE_ELECTRIC, - "PERFORMANCE": WaterHeaterMode.WATER_HEATER_MODE_PERFORMANCE, - "HIGH_DEMAND": WaterHeaterMode.WATER_HEATER_MODE_HIGH_DEMAND, - "HEAT_PUMP": WaterHeaterMode.WATER_HEATER_MODE_HEAT_PUMP, - "GAS": WaterHeaterMode.WATER_HEATER_MODE_GAS, -} +WATER_HEATER_MODES = WaterHeaterMode.options( + "OFF", + "ECO", + "ELECTRIC", + "PERFORMANCE", + "HIGH_DEMAND", + "HEAT_PUMP", + "GAS", + prefix="WATER_HEATER_MODE_", +) validate_water_heater_mode = cv.enum(WATER_HEATER_MODES, upper=True) _WATER_HEATER_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( diff --git a/esphome/components/whirlpool/climate.py b/esphome/components/whirlpool/climate.py index efc7e6ca570..4a3a750f503 100644 --- a/esphome/components/whirlpool/climate.py +++ b/esphome/components/whirlpool/climate.py @@ -11,11 +11,7 @@ whirlpool_ns = cg.esphome_ns.namespace("whirlpool") WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR) Model = whirlpool_ns.enum("Model") -MODELS = { - "DG11J1-3A": Model.MODEL_DG11J1_3A, - "DG11J1-91": Model.MODEL_DG11J1_91, - "HISENSE_ARG": Model.MODEL_HISENSE_ARG, -} +MODELS = Model.options("DG11J1-3A", "DG11J1-91", "HISENSE_ARG", prefix="MODEL_") CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend( { diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5879729a22b..5fceb891573 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -194,11 +194,9 @@ WiFiComponent = wifi_ns.class_("WiFiComponent", cg.Component) WiFiAP = wifi_ns.struct("WiFiAP") WiFiPowerSaveMode = wifi_ns.enum("WiFiPowerSaveMode") -WIFI_POWER_SAVE_MODES = { - "NONE": WiFiPowerSaveMode.WIFI_POWER_SAVE_NONE, - "LIGHT": WiFiPowerSaveMode.WIFI_POWER_SAVE_LIGHT, - "HIGH": WiFiPowerSaveMode.WIFI_POWER_SAVE_HIGH, -} +WIFI_POWER_SAVE_MODES = WiFiPowerSaveMode.options( + "NONE", "LIGHT", "HIGH", prefix="WIFI_POWER_SAVE_" +) WiFiBandMode = cg.global_ns.enum("wifi_band_mode_t") WIFI_BAND_MODES = { @@ -208,20 +206,15 @@ WIFI_BAND_MODES = { } WifiMinAuthMode = wifi_ns.enum("WifiMinAuthMode") -WIFI_MIN_AUTH_MODES = { - "WPA": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA, - "WPA2": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA2, - "WPA3": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA3, -} +WIFI_MIN_AUTH_MODES = WifiMinAuthMode.options( + "WPA", "WPA2", "WPA3", prefix="WIFI_MIN_AUTH_MODE_" +) VALIDATE_WIFI_MIN_AUTH_MODE = cv.enum(WIFI_MIN_AUTH_MODES, upper=True) WiFi8266PhyMode = wifi_ns.enum("WiFi8266PhyMode") -WIFI_8266_PHY_MODES = { - "AUTO": WiFi8266PhyMode.WIFI_8266_PHY_MODE_AUTO, - "11B": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11B, - "11G": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11G, - "11N": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11N, -} +WIFI_8266_PHY_MODES = WiFi8266PhyMode.options( + "AUTO", "11B", "11G", "11N", prefix="WIFI_8266_PHY_MODE_" +) WIFI_AP_MODE_FALLBACK = "FALLBACK" WIFI_AP_MODE_WISP = "WISP" diff --git a/esphome/components/xgzp68xx/sensor.py b/esphome/components/xgzp68xx/sensor.py index eb3b042cb7a..b67b2d8a9b4 100644 --- a/esphome/components/xgzp68xx/sensor.py +++ b/esphome/components/xgzp68xx/sensor.py @@ -21,16 +21,17 @@ CONF_K_VALUE = "k_value" xgzp68xx_ns = cg.esphome_ns.namespace("xgzp68xx") XGZP68XXOversampling = xgzp68xx_ns.enum("XGZP68XXOversampling") -OVERSAMPLING_OPTIONS = { - "256X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_256X, - "512X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_512X, - "1024X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_1024X, - "2048X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_2048X, - "4096X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_4096X, - "8192X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_8192X, - "16384X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_16384X, - "32768X": XGZP68XXOversampling.XGZP68XX_OVERSAMPLING_32768X, -} +OVERSAMPLING_OPTIONS = XGZP68XXOversampling.options( + "256X", + "512X", + "1024X", + "2048X", + "4096X", + "8192X", + "16384X", + "32768X", + prefix="XGZP68XX_OVERSAMPLING_", +) XGZP68XXComponent = xgzp68xx_ns.class_( "XGZP68XXComponent", cg.PollingComponent, i2c.I2CDevice ) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 109b953de5c..ebbfa13af6a 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -57,12 +57,9 @@ AnalogAttrs = zigbee_ns.struct("AnalogAttrs") AnalogAttrsOutput = zigbee_ns.struct("AnalogAttrsOutput") report = zigbee_ns.enum("ZigbeeReportT") -REPORT = { - "coordinator": report.ZIGBEE_REPORT_COORDINATOR, - "enable": report.ZIGBEE_REPORT_ENABLE, - "force": report.ZIGBEE_REPORT_FORCE, - "default": report.ZIGBEE_REPORT_DEFAULT, -} +REPORT = report.options( + "coordinator", "enable", "force", "default", prefix="ZIGBEE_REPORT_" +) CONF_CLUSTER = "cluster" CONF_ENDPOINT = "endpoint" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index bb6c153752b..178cb4b6fc8 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -26,38 +26,40 @@ DEVICE_ID = { "CUSTOM_ATTR": 0xFFF2, } cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e") -CLUSTER_ID = { - "BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC, - "ON_OFF": cluster_id.EZB_ZCL_CLUSTER_ID_ON_OFF, - "TIME": cluster_id.EZB_ZCL_CLUSTER_ID_TIME, - "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, - "BINARY_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, - "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, - "ANALOG_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, - "ILLUMINANCE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_ILLUMINANCE_MEASUREMENT, - "TEMPERATURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_TEMPERATURE_MEASUREMENT, - "PRESSURE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PRESSURE_MEASUREMENT, - "FLOW_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_FLOW_MEASUREMENT, - "REL_HUMIDITY_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_REL_HUMIDITY_MEASUREMENT, - "OCCUPANCY_SENSING": cluster_id.EZB_ZCL_CLUSTER_ID_OCCUPANCY_SENSING, - "CARBON_DIOXIDE_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_CARBON_DIOXIDE_MEASUREMENT, - "PM2_5_MEASUREMENT": cluster_id.EZB_ZCL_CLUSTER_ID_PM2_5_MEASUREMENT, -} +CLUSTER_ID = cluster_id.options( + "BASIC", + "ON_OFF", + "TIME", + "BINARY_INPUT", + "BINARY_OUTPUT", + "ANALOG_INPUT", + "ANALOG_OUTPUT", + "ILLUMINANCE_MEASUREMENT", + "TEMPERATURE_MEASUREMENT", + "PRESSURE_MEASUREMENT", + "FLOW_MEASUREMENT", + "REL_HUMIDITY_MEASUREMENT", + "OCCUPANCY_SENSING", + "CARBON_DIOXIDE_MEASUREMENT", + "PM2_5_MEASUREMENT", + prefix="EZB_ZCL_CLUSTER_ID_", +) CLUSTER_ROLE = { "SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"), "CLIENT": cg.RawExpression("EZB_ZCL_CLUSTER_CLIENT"), } attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e") -ATTR_TYPE = { - "BOOL": attr_type.EZB_ZCL_ATTR_TYPE_BOOL, - "MAP8": attr_type.EZB_ZCL_ATTR_TYPE_MAP8, - "UINT8": attr_type.EZB_ZCL_ATTR_TYPE_UINT8, - "UINT16": attr_type.EZB_ZCL_ATTR_TYPE_UINT16, - "INT8": attr_type.EZB_ZCL_ATTR_TYPE_INT8, - "INT16": attr_type.EZB_ZCL_ATTR_TYPE_INT16, - "ENUM8": attr_type.EZB_ZCL_ATTR_TYPE_ENUM8, - "ENUM16": attr_type.EZB_ZCL_ATTR_TYPE_ENUM16, - "STRING": attr_type.EZB_ZCL_ATTR_TYPE_STRING, - "SINGLE": attr_type.EZB_ZCL_ATTR_TYPE_SINGLE, - "DOUBLE": attr_type.EZB_ZCL_ATTR_TYPE_DOUBLE, -} +ATTR_TYPE = attr_type.options( + "BOOL", + "MAP8", + "UINT8", + "UINT16", + "INT8", + "INT16", + "ENUM8", + "ENUM16", + "STRING", + "SINGLE", + "DOUBLE", + prefix="EZB_ZCL_ATTR_TYPE_", +) From 1147b024d6b8b3150fc5846456988864c8aa80aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:17:59 -1000 Subject: [PATCH 008/179] [core] Add sign_extend helper (#20933) --- esphome/core/helpers.h | 6 ++++++ tests/components/core/helpers_test.cpp | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c51ef4a652d..94921e1d088 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -990,6 +990,12 @@ constexpr uint32_t encode_uint32(uint8_t byte1, uint8_t byte2, uint8_t byte3, ui (static_cast(byte3) << 8) | (static_cast(byte4)); } +/// Sign-extend the low \p BITS bits of \p value, e.g. sign_extend<24>(0xFFFFFE) == -2. Higher bits are ignored. +template constexpr int32_t sign_extend(uint32_t value) { + static_assert(BITS > 0 && BITS <= 32, "BITS must be 1 to 32"); + return static_cast(value << (32 - BITS)) >> (32 - BITS); +} + /// Encode a value from its constituent bytes (from most to least significant) in an array with length sizeof(T). template::value, int> = 0> constexpr T encode_value(const uint8_t *bytes) { T val = 0; diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index d0058a634b2..92a7f841830 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -55,6 +55,21 @@ TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { } } +TEST(HelpersTest, SignExtend) { + EXPECT_EQ(sign_extend<24>(0x000000), 0); + EXPECT_EQ(sign_extend<24>(0x7FFFFF), 8388607); + EXPECT_EQ(sign_extend<24>(0x800000), -8388608); + EXPECT_EQ(sign_extend<24>(0xFFFFFE), -2); + EXPECT_EQ(sign_extend<24>(0xAB000001), 1); // bits above BITS are ignored + EXPECT_EQ(sign_extend<12>(0x7FF), 2047); + EXPECT_EQ(sign_extend<12>(0x800), -2048); + EXPECT_EQ(sign_extend<16>(0xFF38), -200); + EXPECT_EQ(sign_extend<1>(1), -1); + EXPECT_EQ(sign_extend<32>(0xFFFFFFFF), -1); + EXPECT_EQ(sign_extend<32>(0x7FFFFFFF), INT32_MAX); + static_assert(sign_extend<12>(0xFFF) == -1, "usable in constant expressions"); +} + TEST(HelpersTest, SumBytes) { static constexpr uint8_t FRAME[] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x10}; EXPECT_EQ(sum_bytes(FRAME, sizeof(FRAME)), 0xE7); // the Daikin comfort frame checksum From 107bebebad04bdfbf53d7150beb01d152b2be6d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:24:45 -1000 Subject: [PATCH 009/179] [components] Use sign_extend for raw sensor readings (#20934) --- esphome/components/ade7880/ade7880_i2c.cpp | 8 +------- esphome/components/bme280_base/bme280_base.cpp | 10 +++++----- esphome/components/cs5460a/cs5460a.cpp | 3 ++- esphome/components/hx711/hx711.cpp | 4 +--- esphome/components/max31856/max31856.cpp | 9 +++------ esphome/components/max9611/max9611.cpp | 11 ++--------- esphome/components/xdb401/xdb401.cpp | 3 +-- esphome/components/xgzp68xx/xgzp68xx.cpp | 4 +--- 8 files changed, 16 insertions(+), 36 deletions(-) diff --git a/esphome/components/ade7880/ade7880_i2c.cpp b/esphome/components/ade7880/ade7880_i2c.cpp index 294fd430d3e..dfc7a58300f 100644 --- a/esphome/components/ade7880/ade7880_i2c.cpp +++ b/esphome/components/ade7880/ade7880_i2c.cpp @@ -11,12 +11,6 @@ namespace esphome::ade7880 { -// adapted from https://stackoverflow.com/a/55912127/1886371 -template inline T sign_extend(const T &v) noexcept { - using S = struct { signed Val : Bits; }; - return reinterpret_cast(&v)->Val; -} - // Register types // unsigned 8-bit (uint8_t) // signed 10-bit - 16-bit ZP on wire (int16_t, needs sign extension) @@ -49,7 +43,7 @@ uint16_t ADE7880::read_u16_register16_(uint16_t a_register) { int32_t ADE7880::read_s24zp_register16_(uint16_t a_register) { // s24zp means 24 bit signed value in the lower 24 bits of a 32-bit register - int32_t in; + uint32_t in; this->read_register16(a_register, reinterpret_cast(&in), sizeof(in)); return sign_extend<24>(convert_big_endian(in)); } diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 4b74a15c0a6..cf61160f77e 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -3,6 +3,7 @@ #include "bme280_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include #include @@ -144,11 +145,10 @@ void BME280Component::setup() { this->calibration_.h1 = read_u8_(BME280_REGISTER_DIG_H1); this->calibration_.h2 = read_s16_le_(BME280_REGISTER_DIG_H2); this->calibration_.h3 = read_u8_(BME280_REGISTER_DIG_H3); - // h4 and h5 are signed 12-bit values; shift left then arithmetic right shift to sign-extend - int16_t h4_raw = read_u8_(BME280_REGISTER_DIG_H4) << 4 | (read_u8_(BME280_REGISTER_DIG_H4 + 1) & 0x0F); - this->calibration_.h4 = static_cast(h4_raw << 4) >> 4; - int16_t h5_raw = read_u8_(BME280_REGISTER_DIG_H5 + 1) << 4 | (read_u8_(BME280_REGISTER_DIG_H5) >> 4); - this->calibration_.h5 = static_cast(h5_raw << 4) >> 4; + this->calibration_.h4 = + sign_extend<12>(read_u8_(BME280_REGISTER_DIG_H4) << 4 | (read_u8_(BME280_REGISTER_DIG_H4 + 1) & 0x0F)); + this->calibration_.h5 = + sign_extend<12>(read_u8_(BME280_REGISTER_DIG_H5 + 1) << 4 | (read_u8_(BME280_REGISTER_DIG_H5) >> 4)); this->calibration_.h6 = read_u8_(BME280_REGISTER_DIG_H6); uint8_t humid_control_val = 0; diff --git a/esphome/components/cs5460a/cs5460a.cpp b/esphome/components/cs5460a/cs5460a.cpp index 7c4e4847911..4631e5ce3cd 100644 --- a/esphome/components/cs5460a/cs5460a.cpp +++ b/esphome/components/cs5460a/cs5460a.cpp @@ -1,4 +1,5 @@ #include "cs5460a.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::cs5460a { @@ -304,7 +305,7 @@ bool CS5460AComponent::check_status_() { voltage_sensor_->publish_state(raw_voltage * voltage_multiplier_); if (power_sensor_ != nullptr && raw_energy != prev_raw_energy_) { - int32_t raw = (int32_t) (raw_energy << 8) >> 8; /* Sign-extend */ + int32_t raw = sign_extend<24>(raw_energy); power_sensor_->publish_state(raw * power_multiplier_); prev_raw_energy_ = raw_energy; } diff --git a/esphome/components/hx711/hx711.cpp b/esphome/components/hx711/hx711.cpp index 48afc0192de..9a81befcc45 100644 --- a/esphome/components/hx711/hx711.cpp +++ b/esphome/components/hx711/hx711.cpp @@ -67,9 +67,7 @@ bool HX711Sensor::read_sensor_(uint32_t *result) { this->status_clear_warning(); - if (data & 0x800000ULL) { - data |= 0xFF000000ULL; - } + data = static_cast(sign_extend<24>(data)); if (result != nullptr) *result = data; diff --git a/esphome/components/max31856/max31856.cpp b/esphome/components/max31856/max31856.cpp index bdb47216daa..b6bfb6127be 100644 --- a/esphome/components/max31856/max31856.cpp +++ b/esphome/components/max31856/max31856.cpp @@ -1,5 +1,6 @@ #include "max31856.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -53,12 +54,8 @@ void MAX31856Sensor::read_thermocouple_temperature_() { // Faults have been logged, clear it for next loop this->clear_fault_(); } else { - int32_t temp24 = this->read_register24_(MAX31856_LTCBH_REG); - if (temp24 & 0x800000) { - temp24 |= 0xFF000000; // fix sign - } - - temp24 >>= 5; // bottom 5 bits are unused + // bottom 5 bits are unused + const int32_t temp24 = sign_extend<24>(this->read_register24_(MAX31856_LTCBH_REG)) >> 5; float temp_c = temp24; temp_c *= 0.0078125; diff --git a/esphome/components/max9611/max9611.cpp b/esphome/components/max9611/max9611.cpp index 420eb7da7cb..486ceaee148 100644 --- a/esphome/components/max9611/max9611.cpp +++ b/esphome/components/max9611/max9611.cpp @@ -1,17 +1,10 @@ #include "max9611.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/components/i2c/i2c_bus.h" namespace esphome::max9611 { using namespace esphome::i2c; -// Sign extend -// http://graphics.stanford.edu/~seander/bithacks.html#FixedSignExtend -template inline T signextend(const T x) { - struct { - T x : B; - } s; - return s.x = x; -} // Map the gain register to in uV/LSB float gain_to_lsb(MAX9611Multiplexer gain) { float lsb = 0.0; @@ -72,7 +65,7 @@ void MAX9611Component::update() { uint16_t t_register = ((register_map_[TEMP_DATA_BYTE_MSB_ADRR] << 8) | (register_map_[TEMP_DATA_BYTE_LSB_ADRR])) >> 7; float voltage = rs_register * VOUT_LSB; float shunt_voltage = (csa_register * gain_to_lsb(gain_)) / MICRO_VOLTS_PER_VOLT; - float temp = signextend(t_register) * TEMP_LSB; + float temp = sign_extend<9>(t_register) * TEMP_LSB; float amps = shunt_voltage / current_resistor_; float watts = amps * voltage; diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp index 5539a1318fd..11a1a569f3f 100644 --- a/esphome/components/xdb401/xdb401.cpp +++ b/esphome/components/xdb401/xdb401.cpp @@ -127,8 +127,7 @@ i2c::ErrorCode XDB401Component::read_pressure_(float &pressure) { format_hex_pretty_to(pressure_buf, sizeof(pressure_buf), p_data, 3); ESP_LOGV(TAG, "Got pressure data: %s", pressure_buf); - // Sign-extend 24-bit big-endian pressure value to int32_t. - int32_t raw_pressure = static_cast(encode_uint24(p_data[0], p_data[1], p_data[2]) << 8) >> 8; + int32_t raw_pressure = sign_extend<24>(encode_uint24(p_data[0], p_data[1], p_data[2])); ESP_LOGD(TAG, "Pressure data raw %" PRId32, raw_pressure); pressure = (static_cast(raw_pressure) / CONVERT_PRESSURE) * diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index 795db448f0b..b184a855f6f 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -76,9 +76,7 @@ void XGZP68XXComponent::update() { ESP_LOGV(TAG, "Got raw pressure=%" PRIu32 ", raw temperature=%u, K value=%u", pressure_raw, temperature_raw, this->k_value_); - // Sign extend the pressure - float pressure_in_pa = (float) (((int32_t) pressure_raw << 8) >> 8); - pressure_in_pa /= (float) (this->k_value_); + const float pressure_in_pa = static_cast(sign_extend<24>(pressure_raw)) / this->k_value_; float temperature = ((float) (int16_t) temperature_raw) / 256.0f; From 9031df757bf4351bdc088f787630a8536ecefa95 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:33:29 +0200 Subject: [PATCH 010/179] [core][scheduler] add missing process_to_add call (#20961) Co-authored-by: J. Nick Koston --- esphome/core/scheduler.cpp | 5 ++- .../test_scheduler_cancelled_callback.cpp | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/components/core/test_scheduler_cancelled_callback.cpp diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 5d0b96c38f2..1dc95c448b6 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -442,10 +442,10 @@ uint32_t HOT Scheduler::call(uint32_t now) { SchedulerNameLog name_log; bool is_cancelled = is_item_removed_(item); - ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64 "%s", + ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRId64 "ms at %" PRIu64 "%s", LOG_STR_ARG(item->get_type_str()), LOG_STR_ARG(item->get_source()), name_log.format(item->get_name_type(), item->get_name(), item->get_name_hash_or_id()), item->interval, - item->get_next_execution() - now_64, item->get_next_execution(), + (int64_t) (item->get_next_execution() - now_64), item->get_next_execution(), is_cancelled ? LOG_STR_LITERAL(" [CANCELLED]") : LOG_STR_LITERAL("")); old_items.push_back(item); @@ -539,6 +539,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { // We were removed/cancelled in the function call, recycle and continue this->to_remove_decrement_locked_(); this->recycle_item_main_loop_(executed_item); + has_added_items |= !this->to_add_.empty(); continue; } diff --git a/tests/components/core/test_scheduler_cancelled_callback.cpp b/tests/components/core/test_scheduler_cancelled_callback.cpp new file mode 100644 index 00000000000..b4cfd21cc5c --- /dev/null +++ b/tests/components/core/test_scheduler_cancelled_callback.cpp @@ -0,0 +1,44 @@ +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/scheduler.h" + +#include + +#include +#include + +namespace esphome::core::testing { + +class SchedulerTestComponent : public Component {}; + +// On a multi-threaded build next_schedule_in() only sees items_, so a timeout a callback leaves in +// to_add_ would let a loop that sleeps on next_schedule_in() oversleep. The case that used to skip +// the merge is a heap item (a zero timeout would run from the defer queue instead) whose callback +// cancels its own item. +TEST(Scheduler, TimeoutAddedByACancelledCallbackIsVisibleAfterCall) { + Scheduler scheduler; + SchedulerTestComponent component; + bool fired = false; + + scheduler.set_timeout(&component, "first", 1, [&]() { + scheduler.cancel_timeout(&component, "first"); + scheduler.set_timeout(&component, "second", 100, [&fired]() { fired = true; }); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + scheduler.call(millis()); + + auto next = scheduler.next_schedule_in(millis()); + ASSERT_TRUE(next.has_value()); + const uint32_t next_ms = next.has_value() ? *next : 0; + EXPECT_GT(next_ms, 50u); + EXPECT_LE(next_ms, 100u); + EXPECT_FALSE(fired); + + std::this_thread::sleep_for(std::chrono::milliseconds(120)); + scheduler.call(millis()); + EXPECT_TRUE(fired); + EXPECT_FALSE(scheduler.next_schedule_in(millis()).has_value()); + scheduler.trim_freelist(); +} + +} // namespace esphome::core::testing From b8afcdfb3c34b385e317272c62ea830279e41969 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:54:53 -1000 Subject: [PATCH 011/179] [i2c] Write 16-bit registers without a heap allocation (#20907) --- esphome/components/i2c/i2c.cpp | 22 ++++++--- esphome/components/i2c/i2c.h | 2 +- tests/components/i2c/write_bytes_16.cpp | 59 +++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 tests/components/i2c/write_bytes_16.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 8fc0a503276..ba23217486e 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -3,7 +3,6 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" -#include namespace esphome::i2c { @@ -59,11 +58,22 @@ bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { } bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) const { - // we have to copy in order to be able to change byte order - std::unique_ptr temp{new uint16_t[len]}; - for (size_t i = 0; i < len; i++) - temp[i] = htoi2cs(data[i]); - return write_register(a_register, reinterpret_cast(temp.get()), len * 2) == ERROR_OK; + const size_t size = 1 + len * 2; + SmallBufferWithHeapFallback<17> buffer_alloc(size); // Register plus up to 8 words + uint8_t *buffer = buffer_alloc.get(); + + buffer[0] = a_register; + for (size_t i = 0; i < len; i++) { + const uint16_t word = data[i]; + buffer[1 + i * 2] = word >> 8; + buffer[2 + i * 2] = word; + } + return this->bus_->write_readv(this->address_, buffer, size, nullptr, 0) == ERROR_OK; +} + +bool I2CDevice::write_byte_16(uint8_t a_register, uint16_t data) const { + const uint8_t buffer[3] = {a_register, static_cast(data >> 8), static_cast(data)}; + return this->bus_->write_readv(this->address_, buffer, sizeof(buffer), nullptr, 0) == ERROR_OK; } I2CRegister &I2CRegister::operator=(uint8_t value) { diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 00929db6207..0d60f519e32 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -264,7 +264,7 @@ class I2CDevice { bool write_byte(uint8_t a_register, uint8_t data) const { return write_bytes(a_register, &data, 1); } - bool write_byte_16(uint8_t a_register, uint16_t data) const { return write_bytes_16(a_register, &data, 1); } + bool write_byte_16(uint8_t a_register, uint16_t data) const; protected: uint8_t address_{0x00}; ///< store the address of the device on the bus diff --git a/tests/components/i2c/write_bytes_16.cpp b/tests/components/i2c/write_bytes_16.cpp new file mode 100644 index 00000000000..1f9670c62ba --- /dev/null +++ b/tests/components/i2c/write_bytes_16.cpp @@ -0,0 +1,59 @@ +#include + +#include + +#include "esphome/components/i2c/i2c.h" + +namespace esphome::i2c::testing { + +class RecordingBus final : public I2CBus { + public: + std::vector written; + ErrorCode result{ERROR_OK}; + + ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, size_t out_size) override { + this->written.assign(data, data + size); + return this->result; + } +}; + +class WriteBytes16Test : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x40); + } + + RecordingBus bus_; + I2CDevice device_; +}; + +TEST_F(WriteBytes16Test, WritesRegisterThenWordBigEndian) { + EXPECT_TRUE(this->device_.write_byte_16(0x12, 0xABCD)); + EXPECT_EQ(this->bus_.written, (std::vector{0x12, 0xAB, 0xCD})); +} + +TEST_F(WriteBytes16Test, WritesEveryWordBigEndian) { + const uint16_t words[3] = {0x0102, 0x0304, 0xFF00}; + EXPECT_TRUE(this->device_.write_bytes_16(0x7E, words, 3)); + EXPECT_EQ(this->bus_.written, (std::vector{0x7E, 0x01, 0x02, 0x03, 0x04, 0xFF, 0x00})); +} + +TEST_F(WriteBytes16Test, MoreWordsThanTheStackBufferHolds) { + uint16_t words[12]; + std::vector expected{0x01}; + for (uint16_t i = 0; i < 12; i++) { + words[i] = 0x1000 + i; + expected.push_back(0x10); + expected.push_back(i); + } + EXPECT_TRUE(this->device_.write_bytes_16(0x01, words, 12)); + EXPECT_EQ(this->bus_.written, expected); +} + +TEST_F(WriteBytes16Test, ReportsBusFailure) { + this->bus_.result = ERROR_NOT_ACKNOWLEDGED; + EXPECT_FALSE(this->device_.write_byte_16(0x12, 0x0001)); +} + +} // namespace esphome::i2c::testing From 298bd5ec7219f339f53808eed2f5dac5a1a5a2f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:55:28 -1000 Subject: [PATCH 012/179] [core] Add ESP_LOGV_HEX, ESP_LOGVV_HEX and ESP_LOGD_HEX (#20931) --- esphome/core/log.h | 32 ++++++++++++ tests/components/core/test_log_hex.cpp | 67 ++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 tests/components/core/test_log_hex.cpp diff --git a/esphome/core/log.h b/esphome/core/log.h index 840da292aee..d7a0ff6b679 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -160,6 +160,38 @@ int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT #define ESP_LOGV(tag, ...) esph_log_v(tag, __VA_ARGS__) #define ESP_LOGVV(tag, ...) esph_log_vv(tag, __VA_ARGS__) +// Log \p format with up to \p max_bytes of \p data as colon separated hex appended as its last %s argument. +// The stack buffer exists only when the level is compiled in. Needs esphome/core/helpers.h. +#define ESPHOME_LOG_HEX_(log_macro, tag, data, len, max_bytes, format, ...) \ + do { \ + char esph_log_hex_buf_[::esphome::format_hex_pretty_size(max_bytes)]; \ + log_macro(tag, format, __VA_ARGS__ __VA_OPT__(, )::esphome::format_hex_pretty_to(esph_log_hex_buf_, data, len)); \ + } while (0) +#ifdef ESPHOME_LOG_HAS_DEBUG +#define ESP_LOGD_HEX(tag, data, len, max_bytes, format, ...) \ + ESPHOME_LOG_HEX_(ESP_LOGD, tag, data, len, max_bytes, format, __VA_ARGS__) +#else +#define ESP_LOGD_HEX(tag, data, len, max_bytes, format, ...) \ + do { \ + } while (0) +#endif +#ifdef ESPHOME_LOG_HAS_VERBOSE +#define ESP_LOGV_HEX(tag, data, len, max_bytes, format, ...) \ + ESPHOME_LOG_HEX_(ESP_LOGV, tag, data, len, max_bytes, format, __VA_ARGS__) +#else +#define ESP_LOGV_HEX(tag, data, len, max_bytes, format, ...) \ + do { \ + } while (0) +#endif +#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE +#define ESP_LOGVV_HEX(tag, data, len, max_bytes, format, ...) \ + ESPHOME_LOG_HEX_(ESP_LOGVV, tag, data, len, max_bytes, format, __VA_ARGS__) +#else +#define ESP_LOGVV_HEX(tag, data, len, max_bytes, format, ...) \ + do { \ + } while (0) +#endif + #define BYTE_TO_BINARY_PATTERN "%c%c%c%c%c%c%c%c" #define BYTE_TO_BINARY(byte) \ ((byte) &0x80 ? '1' : '0'), ((byte) &0x40 ? '1' : '0'), ((byte) &0x20 ? '1' : '0'), ((byte) &0x10 ? '1' : '0'), \ diff --git a/tests/components/core/test_log_hex.cpp b/tests/components/core/test_log_hex.cpp new file mode 100644 index 00000000000..70de9af0828 --- /dev/null +++ b/tests/components/core/test_log_hex.cpp @@ -0,0 +1,67 @@ +#include + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::testing { + +static const char *const TAG = "log_hex_test"; + +TEST(LogHexTest, EvaluatesArgumentsOnlyWhenTheLevelIsCompiledIn) { + static constexpr uint8_t DATA[] = {0x01, 0xAB, 0xFF}; + int debug_calls = 0; + int verbose_calls = 0; + int very_verbose_calls = 0; + auto count = [](int &calls) { + calls++; + return sizeof(DATA); + }; + ESP_LOGD_HEX(TAG, DATA, count(debug_calls), sizeof(DATA), "Data: %s"); + ESP_LOGV_HEX(TAG, DATA, count(verbose_calls), sizeof(DATA), "Frame %u: %s", 7u); + ESP_LOGVV_HEX(TAG, DATA, count(very_verbose_calls), 2, "Type %02X %s: %s", 0x10, "truncated"); +#ifdef ESPHOME_LOG_HAS_DEBUG + EXPECT_EQ(debug_calls, 1); +#else + EXPECT_EQ(debug_calls, 0); +#endif +#ifdef ESPHOME_LOG_HAS_VERBOSE + EXPECT_EQ(verbose_calls, 1); +#else + EXPECT_EQ(verbose_calls, 0); +#endif +#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE + EXPECT_EQ(very_verbose_calls, 1); +#else + EXPECT_EQ(very_verbose_calls, 0); +#endif +} + +TEST(LogHexTest, IsASingleStatement) { + static constexpr uint8_t DATA[] = {0x12, 0x34}; + bool other_branch = false; + if (DATA[0] == 0x12) // NOLINT(readability-braces-around-statements) + ESP_LOGV_HEX(TAG, DATA, sizeof(DATA), sizeof(DATA), "Data: %s"); + else // NOLINT(readability-braces-around-statements) + other_branch = true; + EXPECT_FALSE(other_branch); +} + +// Stands in for a log macro so the expansion can be checked without the logger backend +static char captured[64]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#define CAPTURE_LOG(tag, ...) snprintf(captured, sizeof(captured), __VA_ARGS__) + +TEST(LogHexTest, HexTextIsTheLastArgument) { + static constexpr uint8_t DATA[] = {0x01, 0xAB, 0xFF}; + ESPHOME_LOG_HEX_(CAPTURE_LOG, TAG, DATA, sizeof(DATA), sizeof(DATA), "Data: %s"); + EXPECT_STREQ(captured, "Data: 01:AB:FF"); +} + +TEST(LogHexTest, ExtraArgumentsComeFirstAndLongDataIsCut) { + static constexpr uint8_t DATA[] = {0x01, 0xAB, 0xFF}; + ESPHOME_LOG_HEX_(CAPTURE_LOG, TAG, DATA, sizeof(DATA), 2, "Type %02X %s: %s", 0x10, "truncated"); + EXPECT_STREQ(captured, "Type 10 truncated: 01:AB"); +} + +} // namespace esphome::testing From 7671d4410c3c00ded377572ac32b3c1070e80c32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:55:53 -1000 Subject: [PATCH 013/179] [i2c] Add check_chip_id helper (#20941) --- esphome/components/i2c/i2c.cpp | 4 + esphome/components/i2c/i2c.h | 18 +++++ tests/components/i2c/test_i2c_chip_id.cpp | 94 +++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 tests/components/i2c/test_i2c_chip_id.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index ba23217486e..59b0cb744fb 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -21,6 +21,10 @@ void I2CBus::i2c_scan_() { } } +void log_wrong_chip_id(uint8_t id, uint8_t expected) { + ESP_LOGE(TAG, "Wrong chip ID 0x%02X, expected 0x%02X", id, expected); +} + ErrorCode I2CDevice::read_register(uint8_t a_register, uint8_t *data, size_t len) { return bus_->write_readv(this->address_, &a_register, 1, data, len); } diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 0d60f519e32..4546047d603 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -2,7 +2,9 @@ #include #include +#include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/optional.h" #include "i2c_bus.h" @@ -12,6 +14,9 @@ namespace esphome::i2c { class I2CDevice; // forward declaration +/// Out of line so each check_chip_id() caller only pays for a call. +void log_wrong_chip_id(uint8_t id, uint8_t expected); + /// @brief This class is used to create I2CRegister objects that act as proxies to read/write internal registers on an /// I2C device. /// @details @@ -208,6 +213,19 @@ class I2CDevice { /// @return an i2c::ErrorCode ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len) const; + /// Read the 8-bit ID at \p a_register and accept it when it matches \p expected in the bits set in \p mask. + /// Otherwise mark \p component failed and return false. + bool check_chip_id(Component *component, uint8_t a_register, uint8_t expected, uint8_t mask = 0xFF) { + uint8_t id; + const ErrorCode err = this->read_register(a_register, &id, 1); + if (err == ERROR_OK && (id & mask) == (expected & mask)) + return true; + if (err == ERROR_OK) + log_wrong_chip_id(id, expected & mask); + component->mark_failed(err == ERROR_OK ? LOG_STR("Wrong chip ID") : LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + /// /// Compat APIs /// All methods below have been added for compatibility reasons. They do not bring any functionality and therefore on diff --git a/tests/components/i2c/test_i2c_chip_id.cpp b/tests/components/i2c/test_i2c_chip_id.cpp new file mode 100644 index 00000000000..c3e3fdd4907 --- /dev/null +++ b/tests/components/i2c/test_i2c_chip_id.cpp @@ -0,0 +1,94 @@ +#include + +#include + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/application.h" +#include "esphome/core/component.h" + +namespace esphome::i2c::testing { + +class IdBus : public I2CBus { + public: + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + ++this->transactions; + if (this->fail) + return ERROR_NOT_ACKNOWLEDGED; + if (write_count != 1 || read_count != 1) + return ERROR_INVALID_ARGUMENT; + this->last_register = write_buffer[0]; + read_buffer[0] = this->id; + return ERROR_OK; + } + + uint8_t id{0}; + uint8_t last_register{0}; + unsigned transactions{0}; + bool fail{false}; +}; + +class ChipDevice : public Component, public I2CDevice {}; + +class I2CChipIdTest : public ::testing::Test { + protected: + void SetUp() override { + new (&App) Application; + static char name[] = "i2c-chip-id-tests"; + static char friendly_name[] = "I2C chip ID tests"; + App.pre_setup(name, sizeof(name) - 1, friendly_name, sizeof(friendly_name) - 1); + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x40); + } + IdBus bus_; + ChipDevice device_; +}; + +TEST_F(I2CChipIdTest, MatchingIdPasses) { + this->bus_.id = 0x33; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x0F, 0x33)); + EXPECT_EQ(this->bus_.last_register, 0x0F); + EXPECT_EQ(this->bus_.transactions, 1u); + EXPECT_FALSE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, WrongIdFails) { + this->bus_.id = 0x32; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x0F, 0x33)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, ReadFailureFails) { + this->bus_.fail = true; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x0F, 0x00)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, MaskAcceptsTwoIds) { + // 0xB1 and 0xB3 differ only in bit 1 + for (uint8_t id : {0xB1, 0xB3}) { + this->bus_.id = id; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x06, 0xB1, 0xFD)); + } + EXPECT_FALSE(this->device_.is_failed()); + this->bus_.id = 0xB5; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x06, 0xB1, 0xFD)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, MaskIgnoresRevisionBits) { + this->bus_.id = 0x27; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x92, 0x24, 0xFC)); + EXPECT_FALSE(this->device_.is_failed()); + this->bus_.id = 0x2B; + EXPECT_FALSE(this->device_.check_chip_id(&this->device_, 0x92, 0x24, 0xFC)); + EXPECT_TRUE(this->device_.is_failed()); +} + +TEST_F(I2CChipIdTest, ExpectedBitsOutsideTheMaskAreIgnored) { + this->bus_.id = 0x24; + EXPECT_TRUE(this->device_.check_chip_id(&this->device_, 0x92, 0x27, 0xFC)); + EXPECT_FALSE(this->device_.is_failed()); +} + +} // namespace esphome::i2c::testing From a5a883fd190db7e2f13afe27c6df675ca5da63cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 15:57:00 -1000 Subject: [PATCH 014/179] [core] Add xor_bytes and sum16_bytes checksum helpers (#20921) --- esphome/core/helpers.h | 10 ++++++++++ tests/components/core/helpers_test.cpp | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 94921e1d088..72bd3a0c6ba 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -887,6 +887,16 @@ constexpr uint8_t sum_bytes(const uint8_t *data, size_t len, uint8_t sum = 0) { return std::accumulate(data, data + len, sum); } +/// Add up the bytes of \p data with size \p len, truncated to 16 bits. +constexpr uint16_t sum16_bytes(const uint8_t *data, size_t len, uint16_t sum = 0) { + return std::accumulate(data, data + len, sum); +} + +/// XOR the bytes of \p data with size \p len, starting from \p init. +constexpr uint8_t xor_bytes(const uint8_t *data, size_t len, uint8_t init = 0) { + return std::accumulate(data, data + len, init, std::bit_xor<>()); +} + /// Calculate a CRC-8 checksum of \p data with size \p len. uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc = 0x00, uint8_t poly = 0x8C, bool msb_first = false); diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index 92a7f841830..499aea5a8ad 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -78,6 +78,24 @@ TEST(HelpersTest, SumBytes) { static_assert(sum_bytes(FRAME, 2) == 0xEB, "usable in constant expressions"); } +TEST(HelpersTest, Sum16Bytes) { + static constexpr uint8_t FRAME[] = {0x42, 0x4D, 0xE1, 0x00, 0x01}; + EXPECT_EQ(sum16_bytes(FRAME, sizeof(FRAME)), 0x0171); // the PMSx003 active mode command checksum + EXPECT_EQ(sum16_bytes(FRAME, 0), 0); + EXPECT_EQ(sum16_bytes(FRAME, sizeof(FRAME), 0x1234), 0x13A5); + EXPECT_EQ(sum16_bytes(FRAME, sizeof(FRAME), 0xFFFF), 0x0170); // wraps at 16 bits + static_assert(sum16_bytes(FRAME, 2) == 0x8F, "usable in constant expressions"); +} + +TEST(HelpersTest, XorBytes) { + static constexpr uint8_t FRAME[] = {0x01, 0x03, 0x00, 0x15, 0x00}; + EXPECT_EQ(xor_bytes(FRAME + 1, 3, 0xFF), 0xE9); // the Z-Wave GetVersion frame checksum + EXPECT_EQ(xor_bytes(FRAME, 0), 0); + EXPECT_EQ(xor_bytes(FRAME, 0, 0x5A), 0x5A); + EXPECT_EQ(xor_bytes(FRAME, sizeof(FRAME)), 0x17); + static_assert(xor_bytes(FRAME, 2) == 0x02, "usable in constant expressions"); +} + TEST(StaticVectorTest, ConvertingConstructorFromSmaller) { StaticVector small{0x03, 0x00, 0x10, 0x00, 0x01}; StaticVector big = small; From d2a1f7ee31ea1f51768ea02bd91e573ac0ff1b48 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Fri, 16 Oct 2026 23:02:54 -0400 Subject: [PATCH 015/179] [zephyr] Fix preference log levels and report settings read/save errors (#20810) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/zephyr/preferences.cpp | 29 ++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/esphome/components/zephyr/preferences.cpp b/esphome/components/zephyr/preferences.cpp index 6be5f1ce4a5..d5522da03aa 100644 --- a/esphome/components/zephyr/preferences.cpp +++ b/esphome/components/zephyr/preferences.cpp @@ -5,6 +5,7 @@ #include "preferences.h" #include "esphome/core/log.h" #include +#include #include #include @@ -23,7 +24,11 @@ bool ZephyrPreferenceBackend::load(uint8_t *data, size_t len) { if (len != this->data.size()) { char key_buf[KEY_BUFFER_SIZE]; this->format_key(key_buf, sizeof(key_buf)); - ESP_LOGE(TAG, "size of setting key %s changed, from: %zu, to: %zu", key_buf, this->data.size(), len); + if (this->data.empty()) { + ESP_LOGV(TAG, "setting key %s not found", key_buf); + } else { + ESP_LOGE(TAG, "size of setting key %s changed, from: %zu, to: %zu", key_buf, this->data.size(), len); + } return false; } std::memcpy(data, this->data.data(), len); @@ -87,7 +92,7 @@ bool ZephyrPreferences::load_from_key(uint32_t type, uint8_t *data, size_t len) } bool ZephyrPreferences::sync() { - ESP_LOGD(TAG, "Save settings"); + ESP_LOGV(TAG, "Save settings"); int err = settings_save(); if (err) { ESP_LOGE(TAG, "Cannot save settings, err: %d", err); @@ -117,22 +122,34 @@ int ZephyrPreferences::load_setting(const char *name, size_t len, settings_read_ return 1; } std::vector data(len); - int err = read_cb(cb_arg, data.data(), len); + ssize_t read_len = read_cb(cb_arg, data.data(), len); + if (read_len != static_cast(len)) { + ESP_LOGE(TAG, "Cannot read setting %s, got %d of %zu", name, static_cast(read_len), len); + return 0; + } - ESP_LOGD(TAG, "load setting, name: %s(%" PRIu32 "), len %zu, err %d", name, *type, len, err); + ESP_LOGD(TAG, "load setting, name: %s(%" PRIu32 "), len %zu", name, *type, len); auto *pref = new ZephyrPreferenceBackend(*type, std::move(data)); // NOLINT(cppcoreguidelines-owning-memory) get_preferences()->backends_.push_back(pref); return 0; } int ZephyrPreferences::export_settings(int (*cb)(const char *name, const void *value, size_t val_len)) { + int first_err = 0; for (auto *backend : get_preferences()->backends_) { char name[KEY_BUFFER_SIZE]; backend->format_key(name, sizeof(name)); int err = cb(name, backend->data.data(), backend->data.size()); - ESP_LOGD(TAG, "save in flash, name %s, len %zu, err %d", name, backend->data.size(), err); + if (err) { + ESP_LOGE(TAG, "Cannot save setting %s, err %d", name, err); + if (first_err == 0) { + first_err = err; + } + continue; + } + ESP_LOGV(TAG, "save in flash, name %s, len %zu", name, backend->data.size()); } - return 0; + return first_err; } static ZephyrPreferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From dcdc042a4b88cf6555cd60adcfdbfe6588792555 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 17:53:33 -1000 Subject: [PATCH 016/179] [host] Define a no-op InterruptLock (#20974) --- esphome/components/host/helpers.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index f8b7a0c5f08..4896c441f7d 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -31,6 +31,10 @@ bool random_bytes(uint8_t *data, size_t len) { return true; } +// The host has no interrupts to mask +InterruptLock::InterruptLock() {} +InterruptLock::~InterruptLock() {} + // Host platform uses std::mutex for proper thread synchronization Mutex::Mutex() { handle_ = new std::mutex(); } Mutex::~Mutex() { delete static_cast(handle_); } From 687466b6cddef38738e9c93422e8ea1fee38ab55 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 17 Oct 2026 06:14:51 +0200 Subject: [PATCH 017/179] [logger][usb_cdc_acm][core] Add CDC uart to NRF52 (#11634) --- esphome/components/logger/__init__.py | 16 +- esphome/components/usb_cdc_acm/__init__.py | 119 +++++++-- .../components/usb_cdc_acm/usb_cdc_acm.cpp | 43 +++- esphome/components/usb_cdc_acm/usb_cdc_acm.h | 70 +++++- .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 53 +--- .../usb_cdc_acm/usb_cdc_acm_zephyr.cpp | 230 ++++++++++++++++++ esphome/components/zephyr/__init__.py | 67 ++++- .../components/zephyr_mcumgr/ota/__init__.py | 13 +- esphome/core/defines.h | 2 + .../usb_cdc_acm/test.nrf52-adafruit.yaml | 7 + .../usb_cdc_acm/test.nrf52-mcumgr.yaml | 5 + .../usb_cdc_acm/test.nrf52-xiao-ble.yaml | 8 + tests/unit_tests/test_zephyr_cdc_acm.py | 67 +++++ 13 files changed, 605 insertions(+), 95 deletions(-) create mode 100644 esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp create mode 100644 tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml create mode 100644 tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml create mode 100644 tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml create mode 100644 tests/unit_tests/test_zephyr_cdc_acm.py diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 67137214c1e..cb222b39e34 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -31,9 +31,9 @@ from esphome.components.libretiny.const import ( COMPONENT_RTL87XX, ) from esphome.components.zephyr import ( - zephyr_add_cdc_acm, zephyr_add_overlay, zephyr_add_prj_conf, + zephyr_claim_cdc_acm, ) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -360,6 +360,19 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config: ConfigType) -> None: + # Serial logging over USB CDC writes to CDC ACM port 0 with uart_poll_out() + if ( + CORE.is_nrf52 + and config[CONF_BAUD_RATE] != 0 + and config.get(CONF_HARDWARE_UART) == USB_CDC + ): + zephyr_claim_cdc_acm(0, "logger", write_only=True) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(CoroPriority.EARLY_INIT) async def to_code(config: ConfigType) -> None: baud_rate: int = config[CONF_BAUD_RATE] @@ -521,7 +534,6 @@ async def _late_logger_init(config: ConfigType) -> None: if config[CONF_HARDWARE_UART] == USB_CDC: cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") zephyr_add_prj_conf("UART_LINE_CTRL", True) - zephyr_add_cdc_acm(config, 0) # Register at end for safe mode await cg.register_component(log, config) diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index 08cd13e3d27..bdc1a964ee1 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator + import esphome.codegen as cg from esphome.components import esp32, uart from esphome.components.esp32 import ( @@ -8,15 +10,30 @@ from esphome.components.esp32 import ( VARIANT_ESP32S31, add_idf_sdkconfig_option, ) +from esphome.components.uart import debug_to_code, maybe_empty_debug, uart_ns +from esphome.components.zephyr import zephyr_add_prj_conf, zephyr_claim_cdc_acm import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_RX_BUFFER_SIZE, CONF_TX_BUFFER_SIZE +from esphome.const import ( + CONF_DEBUG, + CONF_DISABLED, + CONF_ID, + CONF_RX_BUFFER_SIZE, + CONF_TX_BUFFER_SIZE, +) +from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] -AUTO_LOAD = ["uart"] -DEPENDENCIES = ["tinyusb"] DOMAIN = "usb_cdc_acm" + +def AUTO_LOAD() -> list[str]: + if CORE.using_zephyr: + return ["uart"] + return ["uart", "tinyusb"] + + CONF_INTERFACES = "interfaces" usb_cdc_acm_ns = cg.esphome_ns.namespace("usb_cdc_acm") @@ -30,9 +47,24 @@ USBCDCACMInstance = usb_cdc_acm_ns.class_( INTERFACE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.All(cv.declare_id(USBCDCACMInstance), uart.mark_unclocked), + cv.Optional(CONF_DEBUG): maybe_empty_debug, + cv.Optional(CONF_DISABLED): cv.All(cv.boolean, cv.only_on_nrf52), } ) + +def _enabled_interfaces(config: ConfigType) -> Iterator[tuple[int, ConfigType]]: + for index, conf in enumerate(config[CONF_INTERFACES]): + if not conf.get(CONF_DISABLED, False): + yield index, conf + + +def _validate_any_interface_enabled(config: ConfigType) -> ConfigType: + if next(_enabled_interfaces(config), None) is None: + raise cv.Invalid("At least one interface must be enabled") + return config + + # Main component schema CONFIG_SCHEMA = cv.All( cv.Schema( @@ -50,37 +82,70 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), - esp32.only_on_variant( - supported=[ - VARIANT_ESP32H4, - VARIANT_ESP32P4, - VARIANT_ESP32S2, - VARIANT_ESP32S3, - VARIANT_ESP32S31, - ], + _validate_any_interface_enabled, + cv.Any( + cv.only_on_nrf52, + cv.All( + cv.only_on_esp32, + esp32.only_on_variant( + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], + ), + ), ), ) +def _final_validate(config: ConfigType) -> None: + if not CORE.using_zephyr: + return + for index, _ in _enabled_interfaces(config): + zephyr_claim_cdc_acm(index, DOMAIN) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - # Create and register interface instances - for interface_index, interface_conf in enumerate(config[CONF_INTERFACES]): - interface = cg.new_Pvariable(interface_conf[CONF_ID]) - await cg.register_parented(interface, var) - cg.add(interface.set_interface_number(interface_index)) - cg.add(var.add_interface(interface)) - - # Configure TinyUSB with the correct number of CDC interfaces num_interfaces = len(config[CONF_INTERFACES]) - add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_ENABLED", True) - add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_COUNT", num_interfaces) - add_idf_sdkconfig_option( - "CONFIG_TINYUSB_CDC_RX_BUFSIZE", config[CONF_RX_BUFFER_SIZE] - ) - add_idf_sdkconfig_option( - "CONFIG_TINYUSB_CDC_TX_BUFSIZE", config[CONF_TX_BUFFER_SIZE] - ) cg.add_define("ESPHOME_MAX_USB_CDC_INSTANCES", num_interfaces) + # Create and register interface instances + for index, conf in _enabled_interfaces(config): + args = ( + (MockObj(f"DEVICE_DT_GET_OR_NULL(DT_NODELABEL(cdc_acm_uart{index}))"),) + if CORE.using_zephyr + else () + ) + interface = cg.new_Pvariable(conf[CONF_ID], *args) + await cg.register_parented(interface, var) + cg.add(interface.set_interface_number(index)) + cg.add(var.add_interface(interface)) + if (debug := conf.get(CONF_DEBUG)) is not None: + cg.add_global(uart_ns.using) + await debug_to_code(debug, interface) + if CORE.using_zephyr: + zephyr_add_prj_conf("UART_LINE_CTRL", True) + zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True) + # Every CDC ACM instance (the logger's and any the board tree adds) holds a transfer + # slot per endpoint; the default of 4 leaves a third instance without any + zephyr_add_prj_conf("USB_MAX_NUM_TRANSFERS", 8) + cg.add_define("ESPHOME_CDC_RX_RING_BUFFER_SIZE", config[CONF_RX_BUFFER_SIZE]) + cg.add_define("ESPHOME_CDC_TX_RING_BUFFER_SIZE", config[CONF_TX_BUFFER_SIZE]) + else: + # Configure TinyUSB with the correct number of CDC interfaces + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_COUNT", num_interfaces) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_RX_BUFSIZE", config[CONF_RX_BUFFER_SIZE] + ) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_TX_BUFSIZE", config[CONF_TX_BUFFER_SIZE] + ) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index f24339b4bf3..da12a10b16a 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -1,7 +1,8 @@ #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ - defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ZEPHYR) #include "usb_cdc_acm.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::usb_cdc_acm { @@ -15,6 +16,7 @@ USBCDCACMComponent *global_usb_cdc_component = nullptr; // NOLINT(cppcoreguidel // USBCDCACMInstance Implementation //============================================================================== +// NOLINTBEGIN(clang-analyzer-unix.Malloc) void USBCDCACMInstance::queue_line_state_event(bool dtr, bool rts) { // Allocate event from pool CDCEvent *event = this->event_pool_.allocate(); @@ -53,6 +55,7 @@ void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_ this->event_queue_.push(event); App.wake_loop_threadsafe(); } +// NOLINTEND(clang-analyzer-unix.Malloc) void USBCDCACMInstance::process_events_() { // Process all pending events from the queue @@ -113,6 +116,44 @@ void USBCDCACMInstance::process_events_() { } } +bool USBCDCACMInstance::peek_byte(uint8_t *data) { + if (this->has_peek_) { + *data = this->peek_buffer_; + return true; + } + + if (this->read_byte(&this->peek_buffer_)) { + *data = this->peek_buffer_; + this->has_peek_ = true; + return true; + } + + return false; +} + +bool USBCDCACMInstance::consume_peek_(uint8_t *&data, size_t &len) { + if (!this->has_peek_) { + return false; + } + // No debug callback here: peek_byte() read this byte through read_array(), which already reported it + *data++ = this->peek_buffer_; + this->has_peek_ = false; + return --len == 0; +} + +void USBCDCACMInstance::record_tx_drop_(size_t dropped) { + // During a sustained host stall the buffer stays full, so this runs for every write; throttle the + // warning. The total is never reset, so each line accounts for every byte dropped so far and the + // first drop since boot is logged unthrottled. + const bool first_drop = this->tx_dropped_bytes_ == 0; + this->tx_dropped_bytes_ += dropped; + const uint32_t now = millis(); + if (first_drop || (now - this->tx_dropped_log_ms_) >= LOG_THROTTLE_MS) { + this->tx_dropped_log_ms_ = now; + ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %zu bytes dropped total", this->itf_, this->tx_dropped_bytes_); + } +} + //============================================================================== // USBCDCACMComponent Implementation //============================================================================== diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 83cb5de89f8..4dc9c54c985 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -1,6 +1,6 @@ #pragma once #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ - defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ZEPHYR) #include "esphome/core/component.h" #include "esphome/core/event_pool.h" @@ -11,14 +11,22 @@ #include #include #include +#ifdef USE_ZEPHYR +#include +#include +#else #include "freertos/ringbuf.h" #include "esp_err.h" #include "tinyusb_cdc_acm.h" +#endif namespace esphome::usb_cdc_acm { static const uint8_t EVENT_QUEUE_SIZE = 12; +// Minimum interval between repeated warnings while a host stall persists +static constexpr uint32_t LOG_THROTTLE_MS = 1000; +#ifndef USE_ZEPHYR // Drain up to out_buf_sz bytes from a byte ring buffer, handling FreeRTOS's wrapped // case with a second read. Shared with the cdc_acm_uart bridge platform, whose worker // tasks drain the same ring buffers. @@ -47,6 +55,7 @@ inline esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, return ESP_OK; } +#endif // Callback types for line coding and line state changes using LineCodingCallback = std::function; @@ -93,12 +102,19 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parenteditf_ = itf; } // Get the CDC port number for this instance uint8_t get_itf() const { return this->itf_; } +#ifdef USE_ZEPHYR + explicit USBCDCACMInstance(const device *uart_dev) : uart_dev_(uart_dev) {} +#else + static void usb_tx_task_fn(void *arg); + void usb_tx_task(); + // Ring buffer accessors for bridge components RingbufHandle_t get_tx_ringbuf() const { return this->usb_tx_ringbuf_; } RingbufHandle_t get_rx_ringbuf() const { return this->usb_rx_ringbuf_; } // Task handle accessor for notifying TX task TaskHandle_t get_tx_task_handle() const { return this->usb_tx_task_handle_; } +#endif // Callback registration for line coding and line state changes void set_line_coding_callback(LineCodingCallback callback) { this->line_coding_callback_ = std::move(callback); } @@ -108,9 +124,6 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parenteddebug_callback_.call(uart::UART_DIRECTION_RX, data[i]); + } +#endif + } + void report_tx_debug_(const uint8_t *data, size_t len) { +#ifdef USE_UART_DEBUGGER + for (size_t i = 0; i < len; i++) { + this->debug_callback_.call(uart::UART_DIRECTION_TX, data[i]); + } +#endif + } +#ifdef USE_ZEPHYR + static void uart_irq_handler(const device *dev, void *instance); + void uart_rx_process_(); + void uart_tx_process_(); + + const device *uart_dev_; + uint32_t last_line_poll_ms_{0}; + ring_buf rx_ringbuf_; + ring_buf tx_ringbuf_; + std::atomic rx_irq_disabled_{false}; + std::atomic tx_irq_disabled_{false}; + // Set by the IRQ handler when our TX ring buffer and the driver's are both empty + std::atomic tx_drained_{false}; + bool dtr_{false}; + bool rts_{false}; + uint8_t rx_ringbuf_data_[ESPHOME_CDC_RX_RING_BUFFER_SIZE]; + uint8_t tx_ringbuf_data_[ESPHOME_CDC_TX_RING_BUFFER_SIZE]; +#else // True while TX bytes are still in the ring buffer or held by the TX task bool tx_pending_(); TaskHandle_t usb_tx_task_handle_{nullptr}; @@ -146,18 +197,19 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented ops instead of inlining // them; atomic inlines correctly on all platforms. std::atomic usb_tx_busy_{0}; +#endif // Running total of bytes dropped by write_array() (never reset), and the timestamp // of the last "buffer full" log line (throttled so a sustained host stall doesn't // flood the log). - uint32_t tx_dropped_bytes_{0}; + size_t tx_dropped_bytes_{0}; uint32_t tx_dropped_log_ms_{0}; + // User-registered callbacks (called from main loop) + LineCodingCallback line_coding_callback_{nullptr}; + LineStateCallback line_state_callback_{nullptr}; // RX buffer for peek functionality uint8_t peek_buffer_{0}; bool has_peek_{false}; uint8_t itf_{0}; - // User-registered callbacks (called from main loop) - LineCodingCallback line_coding_callback_{nullptr}; - LineStateCallback line_state_callback_{nullptr}; // Lock-free queue and event pool for cross-task event passing // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 9ac94802e17..0f02ddf3c7a 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -29,9 +29,6 @@ static constexpr size_t USB_TX_TASK_STACK_SIZE_VV = 8192; // the final TinyUSB flush share this budget. static constexpr uint32_t FLUSH_TIMEOUT_MS = 100; -// Minimum interval between repeated warnings while a host stall persists. -static constexpr uint32_t LOG_THROTTLE_MS = 1000; - static USBCDCACMInstance *get_instance_by_itf(int itf) { if (global_usb_cdc_component == nullptr) { return nullptr; @@ -278,41 +275,15 @@ void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { // Write data to TX ring buffer BaseType_t send_res = xRingbufferSend(this->usb_tx_ringbuf_, data, len, 0); if (send_res != pdTRUE) { - // During a sustained host stall the ring buffer stays full (that is the intended - // backpressure), so this path runs for every write; throttle the warning so the - // log stays readable. The counter is a running total that is never reset: each - // line reports all bytes dropped so far, so bytes dropped in the tail of one - // stall are still accounted for by the next line, whenever that is. It also makes - // the very first drop since boot detectable, which is logged unthrottled. - const bool first_drop = this->tx_dropped_bytes_ == 0; - this->tx_dropped_bytes_ += len; - const uint32_t now = millis(); - if (first_drop || (now - this->tx_dropped_log_ms_) >= LOG_THROTTLE_MS) { - this->tx_dropped_log_ms_ = now; - ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %" PRIu32 " bytes dropped total", this->itf_, this->tx_dropped_bytes_); - } + // A full ring buffer is the intended backpressure during a host stall + this->record_tx_drop_(len); return; } - // Notify TX task that data is available if (this->usb_tx_task_handle_ != nullptr) { xTaskNotifyGive(this->usb_tx_task_handle_); } -} - -bool USBCDCACMInstance::peek_byte(uint8_t *data) { - if (this->has_peek_) { - *data = this->peek_buffer_; - return true; - } - - if (this->read_byte(&this->peek_buffer_)) { - *data = this->peek_buffer_; - this->has_peek_ = true; - return true; - } - - return false; + this->report_tx_debug_(data, len); } bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { @@ -320,20 +291,12 @@ bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { return true; } + if (this->consume_peek_(data, len)) { + return true; + } size_t original_len = len; size_t bytes_read = 0; - // First, use the peek buffer if available - if (this->has_peek_) { - data[0] = this->peek_buffer_; - this->has_peek_ = false; - bytes_read = 1; - data++; - if (--len == 0) { // Decrement len first, then check it... - return true; // No more to read - } - } - // Read remaining bytes from RX ring buffer size_t rx_size = 0; uint8_t *buf = static_cast(xRingbufferReceiveUpTo(this->usb_rx_ringbuf_, &rx_size, 0, len)); @@ -343,6 +306,7 @@ bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { memcpy(data, buf, rx_size); vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + this->report_rx_debug_(data, rx_size); bytes_read += rx_size; data += rx_size; len -= rx_size; @@ -358,6 +322,7 @@ bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { memcpy(data, buf, rx_size); vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + this->report_rx_debug_(data, rx_size); bytes_read += rx_size; return bytes_read == original_len; @@ -421,7 +386,5 @@ uart::UARTFlushResult USBCDCACMInstance::flush() { } } -void USBCDCACMInstance::check_logger_conflict() {} - } // namespace esphome::usb_cdc_acm #endif diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp new file mode 100644 index 00000000000..ab3f0531b88 --- /dev/null +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_zephyr.cpp @@ -0,0 +1,230 @@ +#if defined(USE_ZEPHYR) +#include "usb_cdc_acm.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include +#include +#include "esphome/components/zephyr/cdc_acm.h" + +// flush() depends on how the legacy cdc_acm driver reports TX state (see uart_tx_process_()) +#ifdef CONFIG_USB_DEVICE_STACK_NEXT +#error "usb_cdc_acm supports only the legacy USB device stack (CONFIG_USB_DEVICE_STACK_NEXT must be disabled)" +#endif + +//============================================================================== +// USBCDCACMInstance Implementation +//============================================================================== +namespace esphome::usb_cdc_acm { + +ESPHOME_LOG_TAG(TAG, "usb_cdc_acm"); +static constexpr uint32_t FLUSH_TIMEOUT_MS = 500; +static constexpr uint32_t LINE_STATE_POLL_MS = 100; + +void USBCDCACMInstance::uart_tx_process_() { + uint8_t *data; + uint32_t send_len = ring_buf_get_claim(&this->tx_ringbuf_, &data, UINT32_MAX); + if (send_len == 0) { + // Our ring buffer is empty. Here (tx_ready set) cdc_acm reports the free space in its own + // TX ring buffer. tx_ready is cleared before each USB transfer and set again when it + // completes, so a fully free driver buffer means the host has received everything. + if (uart_irq_tx_ready(this->uart_dev_) == CONFIG_USB_CDC_ACM_RINGBUF_SIZE) { + this->tx_drained_ = true; + } + uart_irq_tx_disable(this->uart_dev_); + return; + } + int sent = uart_fifo_fill(this->uart_dev_, data, send_len); + if (sent <= 0) { + // No progress: the USB device is suspended (host asleep, hub power save) or not configured. + // fill() then returns 0 without clearing tx_ready, and irq_update() always returns 1 for + // cdc_acm, so the IRQ handler would loop forever on the cooperative USB work queue and + // block the main loop. Disabling TX ends that loop, because irq_is_pending() only reports + // TX while the TX IRQ is enabled. loop() turns it back on while data is waiting. + ring_buf_get_finish(&this->tx_ringbuf_, 0); + uart_irq_tx_disable(this->uart_dev_); + this->tx_irq_disabled_ = true; + return; + } + ring_buf_get_finish(&this->tx_ringbuf_, sent); +} + +void USBCDCACMInstance::uart_rx_process_() { + uint8_t *data; + uint32_t recv_len = ring_buf_put_claim(&this->rx_ringbuf_, &data, UINT32_MAX); + if (recv_len == 0) { + // Ring buffer full: disable RX IRQ so the ISR stops firing. read_array() re-enables it + // once it has freed space. + uart_irq_rx_disable(this->uart_dev_); + this->rx_irq_disabled_ = true; + return; + } + int rx = uart_fifo_read(this->uart_dev_, data, recv_len); + if (rx < 0) { + ESP_LOGE(TAG, "Failed to read UART FIFO, err %d", rx); + } + ring_buf_put_finish(&this->rx_ringbuf_, rx > 0 ? rx : 0); +} + +void USBCDCACMInstance::uart_irq_handler(const device *dev, void *instance) { + USBCDCACMInstance *thiz = static_cast(instance); + while (uart_irq_update(dev) && uart_irq_is_pending(dev)) { + if (uart_irq_rx_ready(dev)) { + thiz->uart_rx_process_(); + } + + // No early exit here: pending RX data must still be read. The loop ends once neither + // RX nor TX is pending (both process functions disable their IRQ when they cannot progress). + if (uart_irq_tx_ready(dev)) { + thiz->uart_tx_process_(); + } + } +} + +void USBCDCACMInstance::setup() { + if (!device_is_ready(this->uart_dev_)) { + ESP_LOGE(TAG, "UART Bus %s: is not ready.", this->uart_dev_->name); + this->get_parent()->mark_failed(); + return; + } + usb_enable(nullptr); + ring_buf_init(&this->rx_ringbuf_, ESPHOME_CDC_RX_RING_BUFFER_SIZE, this->rx_ringbuf_data_); + ring_buf_init(&this->tx_ringbuf_, ESPHOME_CDC_TX_RING_BUFFER_SIZE, this->tx_ringbuf_data_); + // The driver only reports the bit rate; the frame format is fixed on USB + this->data_bits_ = 8; + this->stop_bits_ = 1; +#if defined(CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT) + zephyr::global_cdc_acm->add_on_rate_callback([this](const device *dev, uint32_t bit_rate) { + if (dev == this->uart_dev_) { + // Queue event for processing in main loop + this->queue_line_coding_event(bit_rate, this->stop_bits_, this->parity_, this->data_bits_); + } + }); +#endif + uart_irq_callback_user_data_set(this->uart_dev_, uart_irq_handler, this); + + uart_irq_rx_enable(this->uart_dev_); +} + +void USBCDCACMInstance::loop() { + // Retry TX stopped by uart_tx_process_() (e.g. after the host resumes from suspend) + if (this->tx_irq_disabled_ && !ring_buf_is_empty(&this->tx_ringbuf_)) { + this->tx_irq_disabled_ = false; + uart_irq_tx_enable(this->uart_dev_); + } + // Line state is polled here on the main loop, so it needs no event queue + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_line_poll_ms_ >= LINE_STATE_POLL_MS) { + this->last_line_poll_ms_ = now; + uint32_t dtr_val = 0; + uint32_t rts_val = 0; + uart_line_ctrl_get(this->uart_dev_, UART_LINE_CTRL_DTR, &dtr_val); + uart_line_ctrl_get(this->uart_dev_, UART_LINE_CTRL_RTS, &rts_val); + const bool dtr = dtr_val != 0; + const bool rts = rts_val != 0; + if (dtr != this->dtr_) { + ESP_LOGD(TAG, "UART Bus %s: %s", this->uart_dev_->name, + dtr ? LOG_STR_LITERAL("Opened") : LOG_STR_LITERAL("Closed")); + } + if (dtr != this->dtr_ || rts != this->rts_) { + ESP_LOGV(TAG, "Line state device %s: DTR=%d, RTS=%d", this->uart_dev_->name, dtr, rts); + this->dtr_ = dtr; + this->rts_ = rts; + if (this->line_state_callback_ != nullptr) { + this->line_state_callback_(dtr, rts); + } + } + } + // Line coding changes arrive from the USB thread through the event queue + this->process_events_(); +} + +void USBCDCACMInstance::dump_config() { + ESP_LOGCONFIG(TAG, + "UART Bus %s:\n" + " Opened: %s\n" + " Baud Rate: %" PRIu32 " baud\n" + " Data Bits: %u\n" + " Parity: %s\n" + " Stop bits: %u", + this->uart_dev_->name, YESNO(this->dtr_), this->baud_rate_, this->data_bits_, + LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); +} + +size_t USBCDCACMInstance::available() { + uint32_t size = ring_buf_size_get(&this->rx_ringbuf_) + (this->has_peek_ ? 1 : 0); + ESP_LOGVV(TAG, "UART Bus %s: available %" PRIu32, this->uart_dev_->name, size); + return size; +} + +bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { + if (len == 0) { + return true; + } + if (this->available() < len) { + return false; + } + + if (this->consume_peek_(data, len)) { + return true; + } + const uint32_t got = ring_buf_get(&this->rx_ringbuf_, data, len); + // Space is free again: resume RX stopped by uart_rx_process_() + if (got > 0 && this->rx_irq_disabled_.exchange(false)) { + uart_irq_rx_enable(this->uart_dev_); + } + if (got != len) { + ESP_LOGE(TAG, "UART CDC unexpected size"); + return false; + } + this->report_rx_debug_(data, len); + return true; +} + +uart::UARTFlushResult USBCDCACMInstance::flush() { + // The driver only moves data while the host reads the port. With no host listening (DTR off) + // the data cannot drain, so do not stall the caller waiting for it. + if (!this->dtr_) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } + // flush() and write_array() both run on the main loop, so nothing is added while we wait. + // The IRQ handler sets tx_drained_ once both our ring buffer and the driver are empty. + this->tx_drained_ = false; + // A stall may have left the flag set; clear it once so a handler run during the wait is not undone. + // The handler sets it again if the device is still suspended. + this->tx_irq_disabled_ = false; + uint32_t start = millis(); + while (true) { + // Wakes the IRQ handler now if the driver is idle, or after the transfer in progress + uart_irq_tx_enable(this->uart_dev_); + // ring_buf_is_empty() is not enough: it is already true while the IRQ handler holds + // claimed bytes that it has not yet copied into the driver. + if (this->tx_drained_ && ring_buf_space_get(&this->tx_ringbuf_) == ESPHOME_CDC_TX_RING_BUFFER_SIZE) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; + } + // USB device suspended or not configured (see uart_tx_process_()): nothing will drain + if (this->tx_irq_disabled_) { + return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } + if (millis() - start > FLUSH_TIMEOUT_MS) { + ESP_LOGW(TAG, "Flush timeout"); + return uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; + } + delay(1); + } +} + +void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { + if (!device_is_ready(this->uart_dev_)) { + return; + } + uint32_t recv_len = ring_buf_put(&this->tx_ringbuf_, data, len); + if (recv_len < len) { + this->record_tx_drop_(len - recv_len); + } + uart_irq_tx_enable(this->uart_dev_); + // Only report the bytes that were queued, not the dropped ones + this->report_tx_debug_(data, recv_len); +} + +} // namespace esphome::usb_cdc_acm +#endif diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 4f69f00339e..1ba3ec098cd 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -1,7 +1,7 @@ from collections.abc import Callable from pathlib import Path import textwrap -from typing import TypedDict +from typing import NamedTuple, TypedDict import esphome.codegen as cg import esphome.config_validation as cv @@ -68,6 +68,14 @@ class Section: ) +class CdcAcmUser(NamedTuple): + name: str + # Only writes with uart_poll_out() and never sets the IRQ callback (the logger) + write_only: bool + # Can share the port with write-only users (protocols that skip unframed text) + allow_write_only: bool + + class ZephyrData(TypedDict): board: str bootloader: str @@ -78,6 +86,8 @@ class ZephyrData(TypedDict): kconfig: str sysbuild: bool overlay_builder: list[Callable[[], str]] + # CDC ACM port number -> components that use it + cdc_acm_users: dict[int, list[CdcAcmUser]] def zephyr_set_core_data(config: ConfigType) -> None: @@ -89,6 +99,7 @@ def zephyr_set_core_data(config: ConfigType) -> None: "": "", }, # set empty to make sure that overlay is cleared after config change overlay_builder=[], + cdc_acm_users={}, extra_build_files={}, pm_static=[], kconfig="", @@ -177,6 +188,9 @@ def zephyr_to_code(config: ConfigType) -> None: @coroutine_with_priority(CoroPriority.FINAL) async def _cdc_acm_to_code(config: ConfigType) -> None: + # Runs last so these required options override the optional defaults that + # platforms (nrf52) set during their own to_code + _cdc_acm_add_ports() need_cdc_cb = zephyr_data()[KEY_PRJ_CONF][""].get( "CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", (False,) )[0] @@ -205,7 +219,46 @@ def _format_prj_conf_val(value: PrjConfValueType) -> str: raise ValueError -def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None: +def zephyr_claim_cdc_acm( + id: int, + user: str, + *, + write_only: bool = False, + allow_write_only: bool = False, +) -> None: + """Reserve CDC ACM port `id` for `user`. Call from FINAL_VALIDATE_SCHEMA. + + zephyr_to_code() later adds the device tree node and Kconfig options for + every claimed port, so components do not set up the port themselves. + + Components on the same port share one device, and the device has only one + IRQ callback slot: the component set up last takes all received data from + the other. So a port normally has one user. The exception is a `write_only` + user (the logger), which never sets the IRQ callback; it may share the port + with a user that sets `allow_write_only` because its protocol skips text + that is not part of its own frames (mcumgr). + """ + new = CdcAcmUser(user, write_only, allow_write_only) + users = zephyr_data()["cdc_acm_users"].setdefault(id, []) + for other in users: + if other.name == user: + return + if (new.write_only and other.allow_write_only) or ( + other.write_only and new.allow_write_only + ): + continue + raise cv.Invalid( + f"USB CDC ACM port {id} (cdc_acm_uart{id}) is used by both " + f"'{other.name}' and '{user}'. Each port can only be used by one component." + ) + users.append(new) + + +def _cdc_acm_add_ports() -> None: + """Set up every CDC ACM port claimed with zephyr_claim_cdc_acm().""" + users = zephyr_data()["cdc_acm_users"] + if not users: + return framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if CORE.is_nrf52 and framework_ver >= cv.Version(3, 2, 0): zephyr_add_prj_conf("CONFIG_USB_DEVICE_STACK_NEXT", False) @@ -217,12 +270,16 @@ def zephyr_add_cdc_acm(config: ConfigType, id: int) -> None: zephyr_add_prj_conf("USB_DEVICE_REMOTE_WAKEUP", False) # prevent logging when buffer is full zephyr_add_prj_conf("USB_CDC_ACM_LOG_LEVEL_WRN", True) - zephyr_add_overlay( + nodes = "".join( f""" - &zephyr_udc0 {{ cdc_acm_uart{id}: cdc_acm_uart{id} {{ compatible = "zephyr,cdc-acm-uart"; - }}; + }};""" + for id in sorted(users) + ) + zephyr_add_overlay( + f""" + &zephyr_udc0 {{{nodes} }}; """ ) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 44c8be9e374..5b438bbda1c 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -5,9 +5,9 @@ from esphome.components.nrf52.framework import include_west_project from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( HexValue, - zephyr_add_cdc_acm, zephyr_add_overlay, zephyr_add_prj_conf, + zephyr_claim_cdc_acm, zephyr_data, ) from esphome.components.zephyr.const import ( @@ -114,6 +114,11 @@ def _validate_bootloader(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> None: _validate_ble_server(config) _validate_bootloader(config) + if (uart := config[CONF_TRANSPORT].get(CONF_HARDWARE_UART)) is not None: + cdc_id = UARTS[uart][1] + if cdc_id >= 0: + # SMP frames are marked, so log text from the logger on the same port is skipped + zephyr_claim_cdc_acm(cdc_id, "zephyr_mcumgr", allow_write_only=True) FINAL_VALIDATE_SCHEMA = _final_validate @@ -160,11 +165,7 @@ async def to_code(config: ConfigType) -> None: request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: zephyr_add_prj_conf("SERIAL", True) - uart = UARTS[transport[CONF_HARDWARE_UART]] - uart_name = uart[0] - cdc_id = uart[1] - if cdc_id >= 0: - zephyr_add_cdc_acm(config, cdc_id) + uart_name = UARTS[transport[CONF_HARDWARE_UART]][0] zephyr_add_prj_conf("MCUMGR_TRANSPORT_UART", True) zephyr_add_prj_conf("BASE64", True) zephyr_add_prj_conf("CONSOLE", True) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index cd0f0d39103..a5fb354199b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -761,6 +761,8 @@ // Default counts for static analysis #define ESPHOME_AREA_COUNT 10 +#define ESPHOME_CDC_RX_RING_BUFFER_SIZE 256 +#define ESPHOME_CDC_TX_RING_BUFFER_SIZE 256 #define ESPHOME_COMPONENT_COUNT 50 #define ESPHOME_DEVICE_COUNT 10 #define ESPHOME_ENTITY_ALARM_CONTROL_PANEL_COUNT 1 diff --git a/tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml b/tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..8657c03ecf8 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.nrf52-adafruit.yaml @@ -0,0 +1,7 @@ +# Interface 0 needs CDC ACM port 0, which the logger uses by default on nRF52 +logger: + hardware_uart: UART0 + +usb_cdc_acm: + interfaces: + id: usb_cdc_acm1 diff --git a/tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml b/tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml new file mode 100644 index 00000000000..40d58596e59 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.nrf52-mcumgr.yaml @@ -0,0 +1,5 @@ +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 + disabled: true + - id: usb_cdc_acm2 diff --git a/tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml b/tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml new file mode 100644 index 00000000000..871e11705ce --- /dev/null +++ b/tests/components/usb_cdc_acm/test.nrf52-xiao-ble.yaml @@ -0,0 +1,8 @@ +# Interface 0 needs CDC ACM port 0, which the logger uses by default on nRF52 +logger: + baud_rate: 0 + +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 + - id: usb_cdc_acm2 diff --git a/tests/unit_tests/test_zephyr_cdc_acm.py b/tests/unit_tests/test_zephyr_cdc_acm.py new file mode 100644 index 00000000000..8d010cd0388 --- /dev/null +++ b/tests/unit_tests/test_zephyr_cdc_acm.py @@ -0,0 +1,67 @@ +"""Tests for CDC ACM port reservation in the Zephyr component.""" + +import pytest + +from esphome.components.zephyr import CdcAcmUser, zephyr_claim_cdc_acm +from esphome.components.zephyr.const import KEY_ZEPHYR +import esphome.config_validation as cv +from esphome.core import CORE + + +@pytest.fixture(autouse=True) +def clean_zephyr_data() -> None: + CORE.data[KEY_ZEPHYR] = {"cdc_acm_users": {}} + + +def _users(id: int) -> list[str]: + return [user.name for user in CORE.data[KEY_ZEPHYR]["cdc_acm_users"][id]] + + +def test_different_ports_can_be_claimed() -> None: + zephyr_claim_cdc_acm(0, "logger", write_only=True) + zephyr_claim_cdc_acm(1, "usb_cdc_acm") + + assert _users(0) == ["logger"] + assert _users(1) == ["usb_cdc_acm"] + + +def test_same_user_can_claim_a_port_again() -> None: + zephyr_claim_cdc_acm(0, "usb_cdc_acm") + zephyr_claim_cdc_acm(0, "usb_cdc_acm") + + assert CORE.data[KEY_ZEPHYR]["cdc_acm_users"][0] == [ + CdcAcmUser("usb_cdc_acm", False, False) + ] + + +LOGGER = ("logger", {"write_only": True}) +RAW = ("usb_cdc_acm", {}) +MCUMGR = ("zephyr_mcumgr", {"allow_write_only": True}) + + +@pytest.mark.parametrize( + ("first", "second", "ok"), + [ + (LOGGER, RAW, False), + (RAW, LOGGER, False), + (LOGGER, MCUMGR, True), + (MCUMGR, LOGGER, True), + ], +) +def test_logger_sharing( + first: tuple[str, dict[str, bool]], second: tuple[str, dict[str, bool]], ok: bool +) -> None: + zephyr_claim_cdc_acm(0, first[0], **first[1]) + if not ok: + with pytest.raises(cv.Invalid, match="is used by both"): + zephyr_claim_cdc_acm(0, second[0], **second[1]) + return + zephyr_claim_cdc_acm(0, second[0], **second[1]) + assert _users(0) == [first[0], second[0]] + + +def test_two_irq_users_are_rejected_even_if_they_allow_the_logger() -> None: + zephyr_claim_cdc_acm(0, "zephyr_mcumgr", allow_write_only=True) + + with pytest.raises(cv.Invalid, match="'zephyr_mcumgr' and 'usb_cdc_acm'"): + zephyr_claim_cdc_acm(0, "usb_cdc_acm") From 4dc2b5911bd86a6a4cc9634c4e7e98a32f809938 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 18:21:04 -1000 Subject: [PATCH 018/179] [core] Move CONF_DRIVE_STRENGTH to the shared component constants (#20975) --- esphome/components/const/__init__.py | 1 + esphome/components/esp32/gpio.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 5d900bbd5b4..966db92c6fd 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -30,6 +30,7 @@ CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DESCRIPTION = "description" CONF_DRAW_ROUNDING = "draw_rounding" +CONF_DRIVE_STRENGTH = "drive_strength" CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GREEN_CHANNEL = "green_channel" diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 7753574bc85..f81584891b6 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -5,7 +5,7 @@ from typing import Any from esphome import pins import esphome.codegen as cg -from esphome.components.const import CONF_HOLD_STATE +from esphome.components.const import CONF_DRIVE_STRENGTH, CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -238,7 +238,6 @@ DRIVE_STRENGTHS = { } gpio_num_t = cg.global_ns.enum("gpio_num_t") -CONF_DRIVE_STRENGTH = "drive_strength" ESP32_PIN_SCHEMA = cv.All( pins.gpio_base_schema(ESP32InternalGPIOPin, gpio_pin_number_validator).extend( From 6d664d3fef9598588e102abd3c6be9bcbafd8160 Mon Sep 17 00:00:00 2001 From: Zhaoqi Xu Date: Sat, 17 Oct 2026 13:15:04 +0800 Subject: [PATCH 019/179] [web_server] Report numbers without a state as NA (#20973) --- esphome/components/web_server/web_server.cpp | 13 ++++++++----- esphome/components/web_server/web_server.h | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 04cd0ff43d2..48a2ce4a63b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1216,7 +1216,7 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); json::JsonBuilder builder; - this->number_json_(obj, obj->state, detail, builder); + this->number_json_(obj, detail, builder); send_json(request, builder); return; } @@ -1236,13 +1236,16 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM } void WebServer::number_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { - web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_STATE, builder); + auto *obj = (number::Number *) (source); + web_server->number_json_(obj, DETAIL_STATE, builder); } void WebServer::number_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { - web_server->number_json_((number::Number *) (source), ((number::Number *) (source))->state, DETAIL_ALL, builder); + auto *obj = (number::Number *) (source); + web_server->number_json_(obj, DETAIL_ALL, builder); } -void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_config, json::JsonBuilder &builder) { +void WebServer::number_json_(number::Number *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); + const float value = obj->has_state() ? obj->state : NAN; const auto uom_ref = obj->get_unit_of_measurement_ref(); const int8_t accuracy = step_to_accuracy_decimals(obj->traits.get_step()); @@ -1250,7 +1253,7 @@ void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_ // Need two buffers: one for value, one for state with UOM char val_buf[VALUE_ACCURACY_MAX_LEN]; char state_buf[VALUE_ACCURACY_MAX_LEN]; - JsonString val_str = linked("\"NaN\""); + JsonString val_str = linked("NaN"); JsonString state_str = linked("NA"); if (!std::isnan(value)) { value_accuracy_to_buf(val_buf, value, accuracy); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index f8ef12f9bff..d545fbf95dd 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -670,7 +670,7 @@ class WebServer final : public Component, public AsyncWebHandler { void cover_json_(cover::Cover *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_NUMBER - void number_json_(number::Number *obj, float value, JsonDetail start_config, json::JsonBuilder &builder); + void number_json_(number::Number *obj, JsonDetail start_config, json::JsonBuilder &builder); #endif #ifdef USE_DATETIME_DATE void date_json_(datetime::DateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder); From 375900bfcde799161dd3d72f876ec195bfc930c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 19:54:11 -1000 Subject: [PATCH 020/179] [core] Add LineReader to collect lines in a fixed buffer (#20952) --- esphome/core/config.py | 5 +- esphome/core/defines.h | 1 + esphome/core/line_reader.cpp | 10 ++ esphome/core/line_reader.h | 99 ++++++++++++ script/build_helpers.py | 7 +- tests/components/README.md | 5 +- tests/components/core/__init__.py | 5 +- tests/components/core/line_reader_test.cpp | 180 +++++++++++++++++++++ tests/components/esphome/__init__.py | 4 +- tests/script/test_test_helpers.py | 73 ++++++++- tests/testing_helpers.py | 17 +- 11 files changed, 394 insertions(+), 12 deletions(-) create mode 100644 esphome/core/line_reader.cpp create mode 100644 esphome/core/line_reader.h create mode 100644 tests/components/core/line_reader_test.cpp diff --git a/esphome/core/config.py b/esphome/core/config.py index 531a0276471..667d780f71f 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -1019,7 +1019,10 @@ _filter_platform_sources = filter_source_files_from_platform( # util.cpp holds only the out-of-line MQTT connectivity helpers; without # USE_MQTT the header provides inline stubs and the file must not compile. -_filter_define_sources = filter_source_files_from_defines({"util.cpp": "USE_MQTT"}) +# line_reader.cpp compiles only for components that opt in with USE_LINE_READER. +_filter_define_sources = filter_source_files_from_defines( + {"util.cpp": "USE_MQTT", "line_reader.cpp": "USE_LINE_READER"} +) def FILTER_SOURCE_FILES() -> list[str]: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a5fb354199b..756485e0157 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -114,6 +114,7 @@ #define USE_LIGHT_GAMMA_LUT #define USE_LIGHT_RESUME_EFFECT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL +#define USE_LINE_READER #define USE_LOCK #define USE_LOGGER #define USE_LOGGER_LEVEL_LISTENERS diff --git a/esphome/core/line_reader.cpp b/esphome/core/line_reader.cpp new file mode 100644 index 00000000000..e821dd0bc4e --- /dev/null +++ b/esphome/core/line_reader.cpp @@ -0,0 +1,10 @@ +#include "esphome/core/line_reader.h" +#include "esphome/core/log.h" + +namespace esphome { + +void log_line_dropped(const char *tag, size_t max_len) { + ESP_LOGW(tag, "Line over %u chars dropped", static_cast(max_len)); +} + +} // namespace esphome diff --git a/esphome/core/line_reader.h b/esphome/core/line_reader.h new file mode 100644 index 00000000000..201267ec3d8 --- /dev/null +++ b/esphome/core/line_reader.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include + +namespace esphome { + +/// Logs the shared "line dropped" warning; out of line to keep each caller small. Components that call it +/// (directly or via log_if_overflowed) add cg.add_define("USE_LINE_READER") so line_reader.cpp is compiled. +void log_line_dropped(const char *tag, size_t max_len); + +/// Collects bytes into NUL terminated lines of up to N - 1 characters; IGNORE_CHAR bytes are skipped and an +/// overlong line is dropped up to its terminator. +template class LineReader { + // pos_ is the length so far; N means "overflowed on this byte", N + 1 "dropping up to the terminator" + using pos_t = std::conditional_t<(N < 0xFE), uint8_t, uint16_t>; + static_assert(N >= 2 && N < 0xFFFE, "LineReader size out of range"); + static_assert(TERMINATOR_CHAR != IGNORE_CHAR, "LineReader terminator and ignore byte must differ"); + + public: + /// Feed one byte; returns the completed line (empty lines included), valid until the next call, or nullptr. + char *feed(uint8_t c) { + size_t len = 0; + return this->feed(c, len); + } + + /// As feed(c), and sets len to the line length when a line is returned. + char *feed(uint8_t c, size_t &len) { + pos_t pos = this->pos_; + if (pos == N) { + this->pos_ = pos = N + 1; + } + if (c == static_cast(IGNORE_CHAR)) { + return nullptr; + } + if (c == static_cast(TERMINATOR_CHAR)) { + this->pos_ = 0; + if (pos > N) { + return nullptr; + } + this->buf_[pos] = '\0'; + len = pos; + return this->buf_; + } + if (pos < N - 1) { + this->buf_[pos] = static_cast(c); + this->pos_ = pos + 1; + } else if (pos == N - 1) { + this->pos_ = N; + } + return nullptr; + } + + /// Length of the partial line; 0 after a line is returned and while an overlong line is dropped. + size_t length() const { return this->pos_ < N ? this->pos_ : 0; } + + /// The partial line, NUL terminated, for prompts with no line end; the first N - 1 characters on the byte + /// that overflowed, empty while the rest of that line is dropped. + const char *partial() { + this->buf_[this->end_()] = '\0'; + return this->buf_; + } + + /// True only right after the feed() that found the line too long; the rest of that line is then dropped. + bool overflowed() const { return this->pos_ == N; } + + /// Log the shared "line dropped" warning when the last feed() overflowed. + void log_if_overflowed(const char *tag) const { + if (this->overflowed()) { + log_line_dropped(tag, N - 1); + } + } + + /// Forget the partial line and start a new one with the next byte. + void reset() { this->pos_ = 0; } + + /// Start a new line with c, e.g. the byte that overflowed; c must not be TERMINATOR_CHAR or IGNORE_CHAR. + void restart_with(uint8_t c) { + this->buf_[0] = static_cast(c); + this->pos_ = 1; + } + + /// Drop everything up to and including the next terminator. + void discard_line() { this->pos_ = N + 1; } + + protected: + pos_t end_() const { + if (this->pos_ < N) { + return this->pos_; + } + return this->pos_ == N ? N - 1 : 0; + } + + char buf_[N]; + pos_t pos_{0}; +}; + +} // namespace esphome diff --git a/script/build_helpers.py b/script/build_helpers.py index 885a5a14758..c15c7118b23 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -285,10 +285,15 @@ def load_test_manifest_overrides( defines ``override_manifest(manifest)``, it is called to customise the override (e.g. ``manifest.enable_codegen()``). """ + wrapped: dict[str, ComponentManifestOverride] = {} for comp_name in components: - override = _wrap_manifest(comp_name) + # tests/components/core and tests/components/esphome both override the + # "esphome" core manifest, so they share one wrapper + manifest_name = ESPHOME_KEY if comp_name == "core" else comp_name + override = wrapped.get(manifest_name) or _wrap_manifest(manifest_name) if override is None: continue + wrapped[manifest_name] = override if "." in comp_name: domain, component = comp_name.split(".", maxsplit=1) diff --git a/tests/components/README.md b/tests/components/README.md index be5e887767e..a28e7f4f89c 100644 --- a/tests/components/README.md +++ b/tests/components/README.md @@ -69,8 +69,9 @@ Key methods: | Method | Effect | |---|---| -| `manifest.enable_codegen()` | Remove the `to_code` suppression, re-enabling code generation | -| `manifest.restore()` | Clear **all** overrides, reverting every attribute to the original | +| `manifest.enable_codegen()` | Remove the `to_code` suppression, re-enabling code generation; a custom `to_code` set by another override is kept | +| `manifest.add_resources(*files)` | Copy extra `FileResource` files into the build; the component's own file list is still read at build time, after `FILTER_SOURCE_FILES` sees the defines | +| `manifest.restore()` | Clear **all** overrides and added resources, reverting every attribute to the original | The function is called after `to_code` has already been set to `None`, so calling `enable_codegen()` is a deliberate opt-in. diff --git a/tests/components/core/__init__.py b/tests/components/core/__init__.py index 34ca4fbe4f0..6d2829511a7 100644 --- a/tests/components/core/__init__.py +++ b/tests/components/core/__init__.py @@ -1,8 +1,9 @@ -from tests.testing_helpers import ComponentManifestOverride +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code def override_manifest(manifest: ComponentManifestOverride) -> None: # core (esphome/core/config.py) must run its to_code during C++ test builds # because it bootstraps the fundamental application infrastructure that all # components depend on (component registration, event loop, etc.). - manifest.enable_codegen() + # USE_LINE_READER keeps line_reader.cpp in the build for its tests. + add_defines_to_code(manifest, "USE_LINE_READER") diff --git a/tests/components/core/line_reader_test.cpp b/tests/components/core/line_reader_test.cpp new file mode 100644 index 00000000000..3b2fad1ce61 --- /dev/null +++ b/tests/components/core/line_reader_test.cpp @@ -0,0 +1,180 @@ +#include +#include +#include +#include "esphome/core/line_reader.h" + +namespace esphome::core::testing { + +using Lines = std::vector; + +// Feeds a string and collects every completed line. +template static Lines feed_all(R &reader, const std::string &input) { + Lines lines; + for (char c : input) { + const char *line = reader.feed(static_cast(c)); + if (line != nullptr) { + EXPECT_EQ(reader.length(), 0u); + lines.emplace_back(line); + } + } + return lines; +} + +static_assert(sizeof(LineReader<80>) == 81); +static_assert(sizeof(LineReader<1025>) == 1028); +// 0xFD is the largest size with a one byte index; 0xFE switches to two bytes +static_assert(sizeof(LineReader<0xFD>) == 0xFE); +static_assert(sizeof(LineReader<0xFE>) == 0x100); + +TEST(LineReaderTest, CrLfLines) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "abc\r\ndef\r\n"), (Lines{"abc", "def"})); +} + +TEST(LineReaderTest, LfOnlyLines) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "abc\ndef\n"), (Lines{"abc", "def"})); +} + +TEST(LineReaderTest, CrOnlyLinesWithCrTerminator) { + LineReader<16, '\r', '\n'> reader; + EXPECT_EQ(feed_all(reader, "R1234\rR0567\r"), (Lines{"R1234", "R0567"})); + // The LF of a CRLF pair is ignored with this setup + EXPECT_EQ(feed_all(reader, "x\r\ny\r\n"), (Lines{"x", "y"})); +} + +TEST(LineReaderTest, CrInsideLineIsIgnored) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "a\rb\n"), (Lines{"ab"})); +} + +TEST(LineReaderTest, EmptyLinesAreReturned) { + LineReader<16> reader; + EXPECT_EQ(feed_all(reader, "\r\n\nx\n"), (Lines{"", "", "x"})); +} + +TEST(LineReaderTest, LineOfExactlyMaxLengthFits) { + LineReader<6> reader; + EXPECT_EQ(feed_all(reader, "12345\n"), (Lines{"12345"})); + EXPECT_FALSE(reader.overflowed()); +} + +TEST(LineReaderTest, OverlongLineIsDroppedUpToTerminator) { + LineReader<6> reader; + int overflow_count = 0; + Lines lines; + for (char c : std::string("123456789\r\nok\n")) { + const char *line = reader.feed(c); + if (reader.overflowed()) { + overflow_count++; + EXPECT_STREQ(reader.partial(), "12345"); + EXPECT_EQ(reader.length(), 0u); + reader.log_if_overflowed("line_reader_test"); + } + if (line != nullptr) + lines.emplace_back(line); + } + EXPECT_EQ(overflow_count, 1); + EXPECT_EQ(lines, (Lines{"ok"})); +} + +TEST(LineReaderTest, SixteenBitIndexOverflowAndRecovery) { + LineReader<300> reader; + const std::string full(299, 'a'); + EXPECT_EQ(feed_all(reader, full + "\n"), (Lines{full})); + int overflow_count = 0; + Lines lines; + for (char c : full + "bcd\r\nok\n") { + const char *line = reader.feed(c); + if (reader.overflowed()) { + overflow_count++; + EXPECT_EQ(std::string(reader.partial()), full); + } + if (line != nullptr) + lines.emplace_back(line); + } + EXPECT_EQ(overflow_count, 1); + EXPECT_EQ(lines, (Lines{"ok"})); +} + +TEST(LineReaderTest, ResetAfterOverflowRestartsAtNextByte) { + LineReader<4> reader; + Lines lines; + for (char c : std::string("abcdefg\n")) { + const char *line = reader.feed(c); + if (reader.overflowed()) + reader.reset(); + if (line != nullptr) + lines.emplace_back(line); + } + // "abc" fills the buffer, 'd' overflows and is dropped, "efg" is the next line + EXPECT_EQ(lines, (Lines{"efg"})); +} + +TEST(LineReaderTest, RestartWithKeepsOverflowByte) { + LineReader<4> reader; + Lines lines; + for (char c : std::string("abcdef\n")) { + const char *line = reader.feed(c); + if (reader.overflowed()) { + reader.restart_with(c); + } + if (line != nullptr) { + lines.emplace_back(line); + } + } + // 'd' overflows and starts the next line + EXPECT_EQ(lines, (Lines{"def"})); +} + +TEST(LineReaderTest, FeedReportsLength) { + LineReader<16> reader; + size_t len = 99; + char *line = nullptr; + for (char c : std::string("ab\rc\n")) { + line = reader.feed(c, len); + } + ASSERT_NE(line, nullptr); + EXPECT_STREQ(line, "abc"); + EXPECT_EQ(len, 3u); + EXPECT_NE(reader.feed('\n', len), nullptr); + EXPECT_EQ(len, 0u); +} + +TEST(LineReaderTest, PartialForPromptWithoutLineEnd) { + LineReader<32> reader; + EXPECT_EQ(feed_all(reader, "DFRobot:/>"), Lines{}); + EXPECT_STREQ(reader.partial(), "DFRobot:/>"); + EXPECT_EQ(reader.length(), 10u); + reader.reset(); + EXPECT_STREQ(reader.partial(), ""); + EXPECT_EQ(feed_all(reader, "sensorStart\r\n"), (Lines{"sensorStart"})); +} + +TEST(LineReaderTest, DiscardLineDropsUntilTerminator) { + LineReader<16> reader; + feed_all(reader, "half"); + reader.discard_line(); + EXPECT_EQ(reader.length(), 0u); + EXPECT_STREQ(reader.partial(), ""); + EXPECT_EQ(feed_all(reader, "rest\nnext\n"), (Lines{"next"})); +} + +TEST(LineReaderTest, CustomIgnoreByte) { + LineReader<8, '\r', static_cast(0xFF)> reader; + EXPECT_EQ(feed_all(reader, "R12\xFF" + "34\r"), + (Lines{"R1234"})); +} + +TEST(LineReaderTest, LineIsWritable) { + LineReader<16> reader; + char *line = nullptr; + for (char c : std::string("a,b\n")) + line = reader.feed(c); + ASSERT_NE(line, nullptr); + line[1] = '\0'; + EXPECT_STREQ(line, "a"); +} + +} // namespace esphome::core::testing diff --git a/tests/components/esphome/__init__.py b/tests/components/esphome/__init__.py index 713c0d5a63a..c1d86f29db4 100644 --- a/tests/components/esphome/__init__.py +++ b/tests/components/esphome/__init__.py @@ -6,7 +6,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # to_code emits the component count the application needs manifest.enable_codegen() # Only the decoder is under test; its ota platform is not in this build - manifest.resources = manifest.resources + [ + manifest.add_resources( FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.c"), FileResource("esphome.components.esphome", "ota/ota_esphome_inflate.h"), - ] + ) diff --git a/tests/script/test_test_helpers.py b/tests/script/test_test_helpers.py index 4b05cab3767..d67d7071aba 100644 --- a/tests/script/test_test_helpers.py +++ b/tests/script/test_test_helpers.py @@ -12,7 +12,7 @@ sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve( import build_helpers # noqa: E402 -from esphome.loader import ComponentManifest # noqa: E402 +from esphome.loader import ComponentManifest, FileResource # noqa: E402 from tests.testing_helpers import ComponentManifestOverride # noqa: E402 # --------------------------------------------------------------------------- @@ -241,6 +241,77 @@ def test_load_wraps_top_level_component(tmp_path: Path) -> None: assert installed.to_code is None +def test_load_core_overrides_esphome_manifest(tmp_path: Path) -> None: + """tests/components/core/__init__.py overrides the "esphome" core manifest.""" + core_dir = tmp_path / "core" + core_dir.mkdir() + (core_dir / "__init__.py").write_text( + textwrap.dedent("""\ + def override_manifest(manifest): + manifest.dependencies = ["injected"] + """) + ) + inner = _make_component_manifest() + + with ( + patch("build_helpers.get_component", return_value=inner) as mock_get, + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["core"], tmp_path) + + mock_get.assert_called_once_with("esphome") + key, installed = mock_set.call_args[0] + assert key == "esphome" + assert installed.dependencies == ["injected"] + + +def test_load_core_and_esphome_share_one_wrapper(tmp_path: Path) -> None: + """Both test dirs override the "esphome" manifest; both overrides apply to one wrapper.""" + for name, dep in (("core", "from_core"), ("esphome", "from_esphome")): + (tmp_path / name).mkdir() + (tmp_path / name / "__init__.py").write_text( + textwrap.dedent(f"""\ + def override_manifest(manifest): + manifest.dependencies = manifest.dependencies + ["{dep}"] + """) + ) + inner = _make_component_manifest() + + with ( + patch("build_helpers.get_component", return_value=inner), + patch("build_helpers.set_testing_manifest") as mock_set, + ): + build_helpers.load_test_manifest_overrides(["core", "esphome"], tmp_path) + + mock_set.assert_called_once() + installed: ComponentManifestOverride = mock_set.call_args[0][1] + assert installed.dependencies == ["from_core", "from_esphome"] + + +def test_enable_codegen_keeps_custom_to_code() -> None: + """enable_codegen() lifts the suppression but keeps a to_code another override set.""" + + async def custom_to_code(config): + pass + + override = ComponentManifestOverride(_make_component_manifest()) + override.to_code = custom_to_code + override.enable_codegen() + assert override.to_code is custom_to_code + + +def test_add_resources_reads_component_resources_lazily() -> None: + """add_resources() appends to the resources computed at read time.""" + inner = MagicMock() + inner.resources = [FileResource("pkg", "a.cpp")] + override = ComponentManifestOverride(inner) + override.add_resources(FileResource("pkg", "extra.c")) + inner.resources = [FileResource("pkg", "a.cpp"), FileResource("pkg", "b.cpp")] + assert [r.resource for r in override.resources] == ["a.cpp", "b.cpp", "extra.c"] + override.restore() + assert [r.resource for r in override.resources] == ["a.cpp", "b.cpp"] + + def test_load_wraps_platform_component(tmp_path: Path) -> None: inner = _make_component_manifest() diff --git a/tests/testing_helpers.py b/tests/testing_helpers.py index 23bef718694..8459a552bad 100644 --- a/tests/testing_helpers.py +++ b/tests/testing_helpers.py @@ -1,6 +1,6 @@ from typing import Any -from esphome.loader import ComponentManifest, _replace_component_manifest +from esphome.loader import ComponentManifest, FileResource, _replace_component_manifest from esphome.types import ConfigType @@ -29,12 +29,16 @@ class ComponentManifestOverride: def __init__(self, wrapped: "ComponentManifest") -> None: object.__setattr__(self, "_wrapped", wrapped) object.__setattr__(self, "_overrides", {}) + object.__setattr__(self, "_extra_resources", []) def __getattr__(self, name: str) -> Any: overrides: dict[str, Any] = object.__getattribute__(self, "_overrides") if name in overrides: return overrides[name] wrapped: ComponentManifest = object.__getattribute__(self, "_wrapped") + if name == "resources": + # Read lazily so FILTER_SOURCE_FILES sees the defines to_code added + return wrapped.resources + object.__getattribute__(self, "_extra_resources") return getattr(wrapped, name) def __setattr__(self, name: str, value: Any) -> None: @@ -48,11 +52,18 @@ class ComponentManifestOverride: custom stub) ``to_code`` to run during C++ unit test builds. """ overrides: dict[str, Any] = object.__getattribute__(self, "_overrides") - overrides.pop("to_code", None) + # Keep a custom to_code another override of the same manifest installed + if overrides.get("to_code") is None: + overrides.pop("to_code", None) + + def add_resources(self, *resources: FileResource) -> None: + """Copy extra source files into the build next to the component's own.""" + object.__getattribute__(self, "_extra_resources").extend(resources) def restore(self) -> None: - """Clear all overrides, reverting to the wrapped manifest's values.""" + """Clear all overrides and added resources, reverting to the wrapped manifest's values.""" object.__getattribute__(self, "_overrides").clear() + object.__getattribute__(self, "_extra_resources").clear() def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> None: From 4e27b2fc125933d94cb9f0362550a55a3fb99816 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 16 Oct 2026 19:54:11 -1000 Subject: [PATCH 021/179] [components] Collect UART lines with LineReader (#20956) --- .../apc_proteous/apc_proteous_cover.cpp | 40 +++--- .../apc_proteous/apc_proteous_cover.h | 6 +- .../components/dfrobot_sen0609/__init__.py | 1 + .../dfrobot_sen0609/dfrobot_sen0609.cpp | 27 +--- .../dfrobot_sen0609/dfrobot_sen0609.h | 5 +- esphome/components/emontx/__init__.py | 1 + esphome/components/emontx/emontx.cpp | 67 ++++------ esphome/components/emontx/emontx.h | 8 +- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp | 55 ++++---- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.h | 6 +- .../components/hydreon_rgxx/hydreon_rgxx.cpp | 122 +++++++++--------- .../components/hydreon_rgxx/hydreon_rgxx.h | 9 +- esphome/components/hydreon_rgxx/sensor.py | 1 + esphome/components/sim800l/__init__.py | 1 + esphome/components/sim800l/sim800l.cpp | 26 ++-- esphome/components/sim800l/sim800l.h | 4 +- esphome/components/smt100/sensor.py | 1 + esphome/components/smt100/smt100.cpp | 31 +---- esphome/components/smt100/smt100.h | 9 +- tests/components/apc_proteous/common.h | 2 +- .../cover/apc_proteous_cover_test.cpp | 8 +- tests/components/dfrobot_sen0609/__init__.py | 14 ++ tests/components/emontx/__init__.py | 11 ++ 23 files changed, 203 insertions(+), 252 deletions(-) create mode 100644 tests/components/dfrobot_sen0609/__init__.py create mode 100644 tests/components/emontx/__init__.py diff --git a/esphome/components/apc_proteous/apc_proteous_cover.cpp b/esphome/components/apc_proteous/apc_proteous_cover.cpp index f6a8432faa0..c17773da29c 100644 --- a/esphome/components/apc_proteous/apc_proteous_cover.cpp +++ b/esphome/components/apc_proteous/apc_proteous_cover.cpp @@ -32,40 +32,35 @@ void APCProteousCover::loop() { if (!this->read_byte(&data)) { continue; } - if (data == '\r') { - if (this->rx_len_ != 0) { - this->rx_buffer_[this->rx_len_] = '\0'; - ESP_LOGVV(TAG, "rx: '%s'", this->rx_buffer_); - this->parse_response_(); - this->rx_len_ = 0; - } - } else if (data != '\n') { - if (this->rx_len_ >= MAX_RESPONSE_LEN) { - ESP_LOGVV(TAG, "rx overflow, discarding"); - this->rx_len_ = 0; - } - this->rx_buffer_[this->rx_len_++] = static_cast(data); + const char *line = this->line_reader_.feed(data); + if (this->line_reader_.overflowed()) { + // Start over from this byte, so a frame right after unterminated noise is still read + ESP_LOGVV(TAG, "rx overflow, discarding"); + this->line_reader_.restart_with(data); + } else if (line != nullptr && line[0] != '\0') { + ESP_LOGVV(TAG, "rx: '%s'", line); + this->parse_response_(line); } } } -void APCProteousCover::parse_response_() { +void APCProteousCover::parse_response_(const char *line) { // Expected format: "?s=XX" or "?x=XX" where XX is a hex value - if (this->rx_len_ < 4 || this->rx_buffer_[0] != '?' || this->rx_buffer_[2] != '=') { - ESP_LOGV(TAG, "Invalid response: %s", this->rx_buffer_); + if (line[0] != '?' || line[1] == '\0' || line[2] != '=' || line[3] == '\0') { + ESP_LOGV(TAG, "Invalid response: %s", line); return; } - const char *hex_str = this->rx_buffer_ + 3; + const char *hex_str = line + 3; char *end_ptr; uint8_t value = static_cast(strtol(hex_str, &end_ptr, 16)); if (end_ptr == hex_str) { - ESP_LOGW(TAG, "Failed to parse hex value: %s", this->rx_buffer_); + ESP_LOGW(TAG, "Failed to parse hex value: %s", line); return; } bool state_changed = false; - char type = this->rx_buffer_[1]; + char type = line[1]; if (type == 's') { // bit 0 = operating, bit 1 = direction (0 = opening, 1 = closing) CoverOperation new_operation = COVER_OPERATION_IDLE; @@ -154,11 +149,10 @@ void APCProteousCover::update() { return; } // The line is idle, so anything left in the buffer is an unterminated echo - if (this->rx_len_ != 0) { - this->rx_buffer_[this->rx_len_] = '\0'; - ESP_LOGVV(TAG, "discarding stale rx '%s' before query", this->rx_buffer_); - this->rx_len_ = 0; + if (this->line_reader_.length() != 0) { + ESP_LOGVV(TAG, "discarding stale rx '%s' before query", this->line_reader_.partial()); } + this->line_reader_.reset(); const char *query = this->query_s_next_ ? QUERY_S : QUERY_X; ESP_LOGVV(TAG, "tx: %s", query); diff --git a/esphome/components/apc_proteous/apc_proteous_cover.h b/esphome/components/apc_proteous/apc_proteous_cover.h index 03c8eb5c4d1..dd15a1a15c7 100644 --- a/esphome/components/apc_proteous/apc_proteous_cover.h +++ b/esphome/components/apc_proteous/apc_proteous_cover.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include "esphome/components/cover/cover.h" @@ -17,7 +18,7 @@ class APCProteousCover : public cover::Cover, public PollingComponent, public ua protected: void control(const cover::CoverCall &call) override; - void parse_response_(); + void parse_response_(const char *line); void send_command_(const char *cmd); void write_command_(const char *cmd, uint32_t now); void stop_cmd_(uint32_t now); @@ -43,8 +44,7 @@ class APCProteousCover : public cover::Cover, public PollingComponent, public ua const char *pending_command_{nullptr}; uint32_t pending_command_time_{0}; uint32_t last_command_tx_{0}; - char rx_buffer_[MAX_RESPONSE_LEN + 1]; - uint8_t rx_len_{0}; + LineReader line_reader_; uint8_t command_retries_{0}; bool query_s_next_{true}; }; diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py index c1d2f806377..76ca362949a 100644 --- a/esphome/components/dfrobot_sen0609/__init__.py +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -80,5 +80,6 @@ def final_validate_mode(keys: dict[str, str]) -> Callable[[ConfigType], None]: async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], RUN_MODES[config[CONF_MODE]]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp index 5f42d05a827..e1a1dbe89d0 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -210,30 +210,15 @@ void DFRobotSEN0609Component::loop() { } void DFRobotSEN0609Component::readline_(uint8_t c) { - if (c == '\r') - return; - if (c == '\n') { - if (!this->line_overflow_) { - this->line_[this->line_pos_] = '\0'; - this->handle_line_(this->line_); - } - this->line_overflow_ = false; - this->line_pos_ = 0; + char *line = this->line_reader_.feed(c); + if (line != nullptr) { + this->handle_line_(line); return; } - if (this->line_overflow_) - return; - if (this->line_pos_ >= MAX_LINE_LENGTH - 1) { - ESP_LOGW(TAG, "Line too long, dropped"); - this->line_overflow_ = true; - return; - } - this->line_[this->line_pos_++] = static_cast(c); + this->line_reader_.log_if_overflowed(TAG); // The prompt has no line end, and the echo of the next command follows it on the same line - if (c == '>') { - this->line_[this->line_pos_] = '\0'; - if (ESPHOME_strcmp_P(this->line_, ESPHOME_PSTR("DFRobot:/>")) == 0) - this->line_pos_ = 0; + if (c == '>' && ESPHOME_strcmp_P(this->line_reader_.partial(), ESPHOME_PSTR("DFRobot:/>")) == 0) { + this->line_reader_.reset(); } } diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h index ca70fa14943..2a017492691 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.h @@ -24,6 +24,7 @@ #ifdef USE_BUTTON #include "esphome/components/button/button.h" #endif +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include @@ -188,7 +189,7 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice std::array switch_states_{}; #endif - char line_[MAX_LINE_LENGTH]; + LineReader line_reader_; char command_[MAX_COMMAND_LENGTH]{}; float response_[MAX_RESPONSE_VALUES]{}; uint32_t sent_ms_{0}; @@ -210,7 +211,6 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice RunMode mode_; Operation operation_{Operation::OPERATION_NONE}; Param param_{PARAM_COUNT}; - uint8_t line_pos_{0}; uint8_t response_count_{0}; uint8_t out_pin_level_{0}; uint8_t attempts_{0}; @@ -221,7 +221,6 @@ class DFRobotSEN0609Component final : public Component, public uart::UARTDevice uint8_t led_on_{0}; bool sent_led_on_{false}; #endif - bool line_overflow_{false}; bool read_failed_{false}; bool bad_frame_logged_{false}; bool echo_seen_{false}; diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 8d2351f1fad..bdb07ed4990 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -132,6 +132,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") # Initialize sensor storage with count from final_validate before any # await, so platform to_code() calls always see it initialized diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index dca5ae2131d..888b12f4bf4 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -55,7 +55,6 @@ static bool supports_lock(const char *version, const char *end) { #endif void EmonTx::setup() { - this->buffer_pos_ = 0; if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { // The line feed ends any boot noise this->write_byte('\n'); @@ -111,11 +110,14 @@ void EmonTx::loop() { if (this->was_paused_) { // Drop what arrived during the pause and resync at the next line start this->was_paused_ = false; - this->buffer_pos_ = 0; - this->discarding_line_ = true; - uint8_t dropped; - while (this->available() > 0 && this->read_byte(&dropped)) - this->discarding_line_ = dropped != '\n'; + uint8_t dropped = 0; + while (this->available() > 0 && this->read_byte(&dropped)) { + } + if (dropped == '\n') { + this->line_reader_.reset(); + } else { + this->line_reader_.discard_line(); + } #ifdef USE_EMONTX_VERSION // The emonTx may have been flashed, or a request dropped, during the pause if (this->wants_version_()) @@ -129,43 +131,24 @@ void EmonTx::loop() { if (!this->read_byte(&received)) break; - if (received == '\n') { - // End of line - process the buffer - if (this->discarding_line_) { - this->discarding_line_ = false; - } else if (this->buffer_pos_ > 0) { - // Null-terminate for safe logging and c_str() use - size_t len = this->buffer_pos_; - this->buffer_[len] = '\0'; - this->buffer_pos_ = 0; - - StringRef line(this->buffer_.data(), len); - ESP_LOGD(TAG, "Received line: %s", line.c_str()); - - // Fire data callbacks for all received lines - this->data_callbacks_.call(line); - - // Check if this line is JSON (starts with '{') - if (this->buffer_[0] == '{') { - ESP_LOGV(TAG, "Line is JSON, parsing..."); - this->parse_json_(this->buffer_.data(), len); - } -#ifdef USE_EMONTX_VERSION - if (this->wants_version_()) { - this->parse_msg_number_(this->buffer_.data(), len); - this->parse_version_line_(this->buffer_.data(), len); - } -#endif - } - } else if (received == '\r' || this->discarding_line_) { - // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause - } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { - ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding line", MAX_LINE_LENGTH); - this->buffer_pos_ = 0; - this->discarding_line_ = true; - } else { - this->buffer_[this->buffer_pos_++] = static_cast(received); + size_t len; + char *line = this->line_reader_.feed(received, len); + this->line_reader_.log_if_overflowed(TAG); + if (line == nullptr || len == 0) { + continue; } + ESP_LOGD(TAG, "Received line: %s", line); + this->data_callbacks_.call(StringRef(line, len)); + if (line[0] == '{') { + ESP_LOGV(TAG, "Line is JSON, parsing..."); + this->parse_json_(line, len); + } +#ifdef USE_EMONTX_VERSION + if (this->wants_version_()) { + this->parse_msg_number_(line, len); + this->parse_version_line_(line, len); + } +#endif } } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index a8d91d36eb6..520e7772a06 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -4,10 +4,10 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include "esphome/components/json/json_util.h" -#include #include #ifdef USE_SENSOR @@ -19,7 +19,7 @@ namespace esphome::emontx { -/// Maximum line length in bytes (plus one byte reserved for null terminator) +/// Maximum line length in characters static constexpr size_t MAX_LINE_LENGTH = 1024; enum class LockOnReboot : uint8_t { @@ -92,18 +92,16 @@ class EmonTx final : public Component, public uart::UARTDevice { #endif LazyCallbackManager json_callbacks_; LazyCallbackManager data_callbacks_; - uint16_t buffer_pos_{0}; std::atomic paused_{false}; bool was_paused_{false}; LockOnReboot lock_on_reboot_{LockOnReboot::LOCK_ON_REBOOT_NEVER}; - bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line #ifdef USE_EMONTX_VERSION bool lock_supported_{false}; // The last version reported is v1.2.0 or later bool unlock_sent_{false}; // AUTO has sent 'emonunlock' since boot uint8_t version_frames_left_{0}; // Data lines still to wait for a version reply; 0 when none is owed uint32_t last_msg_number_{0}; #endif - std::array buffer_{}; + LineReader line_reader_; }; } // namespace esphome::emontx diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 1d58a4317a6..5415b7af74f 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -12,9 +12,6 @@ namespace esphome::hrxl_maxsonar_wr { ESPHOME_LOG_TAG(TAG, "hrxl.maxsonar.wr.sensor"); -static const uint8_t ASCII_CR = 0x0D; -static const uint8_t ASCII_NBSP = 0xFF; -static const int MAX_DATA_LENGTH_BYTES = 6; /** * HRXL sensors output the format "R1234\r" at 6Hz @@ -25,44 +22,36 @@ static const int MAX_DATA_LENGTH_BYTES = 6; void HrxlMaxsonarWrComponent::loop() { uint8_t data; while (this->available() > 0) { - if (this->read_byte(&data)) { - buffer_ += (char) data; - this->check_buffer_(); + if (!this->read_byte(&data)) + continue; + size_t len; + const char *line = this->line_reader_.feed(data, len); + if (line != nullptr) { + ESP_LOGV(TAG, "Read from serial: %s", line); + this->handle_line_(line, len); + } else if (this->line_reader_.overflowed()) { + // No CR in time: report it and start over with the next byte + ESP_LOGW(TAG, "Invalid data read from sensor: %s", this->line_reader_.partial()); + this->line_reader_.reset(); } } } -void HrxlMaxsonarWrComponent::check_buffer_() { - // The sensor seems to inject a rogue ASCII 255 byte from time to time. Get rid of that. - if (this->buffer_.back() == static_cast(ASCII_NBSP)) { - this->buffer_.pop_back(); +void HrxlMaxsonarWrComponent::handle_line_(const char *line, size_t len) { + if (line[0] != 'R') { + ESP_LOGW(TAG, "Invalid data read from sensor: %s", line); return; } + int millimeters = parse_number(line + 1).value_or(0); - // Stop reading at ASCII_CR. Also prevent the buffer from growing - // indefinitely if no ASCII_CR is received after MAX_DATA_LENGTH_BYTES. - if (this->buffer_.back() == static_cast(ASCII_CR) || this->buffer_.length() >= MAX_DATA_LENGTH_BYTES) { - ESP_LOGV(TAG, "Read from serial: %s", this->buffer_.c_str()); - - size_t rpos = this->buffer_.find(static_cast(ASCII_CR)); - - if (this->buffer_.length() <= MAX_DATA_LENGTH_BYTES && this->buffer_[0] == 'R' && rpos != std::string::npos) { - std::string distance = this->buffer_.substr(1, rpos - 1); - int millimeters = parse_number(distance).value_or(0); - - // XL reports in cm instead of mm and reports 3 digits instead of 4 - if (distance.length() == 3) { - millimeters = millimeters * 10; - } - - float meters = float(millimeters) / 1000.0f; - ESP_LOGV(TAG, "Distance from sensor: %d mm, %f m", millimeters, meters); - this->publish_state(meters); - } else { - ESP_LOGW(TAG, "Invalid data read from sensor: %s", this->buffer_.c_str()); - } - this->buffer_.clear(); + // XL reports in cm instead of mm and reports 3 digits instead of 4 + if (len == 4) { + millimeters *= 10; } + + float meters = float(millimeters) / 1000.0f; + ESP_LOGV(TAG, "Distance from sensor: %d mm, %f m", millimeters, meters); + this->publish_state(meters); } void HrxlMaxsonarWrComponent::dump_config() { diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h index 36346c8293e..ada26d21e29 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::hrxl_maxsonar_wr { @@ -15,9 +16,10 @@ class HrxlMaxsonarWrComponent final : public sensor::Sensor, public Component, p void dump_config() override; protected: - void check_buffer_(); + void handle_line_(const char *line, size_t len); - std::string buffer_; + // "R1234" plus the NUL; the sensor ends each reading with CR and sometimes injects a rogue 0xFF byte + LineReader<6, '\r', static_cast(0xFF)> line_reader_; }; } // namespace esphome::hrxl_maxsonar_wr diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index a2f8fc50862..a1d6553073a 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -1,5 +1,6 @@ #include "hydreon_rgxx.h" #include "esphome/core/log.h" +#include namespace esphome::hydreon_rgxx { @@ -7,8 +8,6 @@ ESPHOME_LOG_TAG(TAG, "hydreon_rgxx.sensor"); static constexpr uint32_t REBOOT_INTERVAL_ID = 0; -static const int MAX_DATA_LENGTH_BYTES = 80; -static const uint8_t ASCII_LF = 0x0A; #define HYDREON_RGXX_COMMA , static const char *const PROTOCOL_NAMES[] = {HYDREON_RGXX_PROTOCOL_LIST(, HYDREON_RGXX_COMMA)}; static const char *const IGNORE_STRINGS[] = {HYDREON_RGXX_IGNORE_LIST(, HYDREON_RGXX_COMMA)}; @@ -111,13 +110,14 @@ void HydreonRGxxComponent::update() { void HydreonRGxxComponent::loop() { uint8_t data; while (this->available() > 0) { - if (this->read_byte(&data)) { - buffer_ += (char) data; - if (this->buffer_.back() == static_cast(ASCII_LF) || this->buffer_.length() >= MAX_DATA_LENGTH_BYTES) { - // complete line received - this->process_line_(); - this->buffer_.clear(); - } + if (!this->read_byte(&data)) { + continue; + } + size_t len; + const char *line = this->line_reader_.feed(data, len); + this->line_reader_.log_if_overflowed(TAG); + if (line != nullptr) { + this->process_line_(StringRef(line, len)); } } } @@ -156,33 +156,31 @@ void HydreonRGxxComponent::schedule_reboot_() { }); } -void HydreonRGxxComponent::process_line_() { - ESP_LOGV(TAG, "Read from serial: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); +void HydreonRGxxComponent::process_line_(StringRef line) { + ESP_LOGV(TAG, "Read from serial: %s", line.c_str()); - if (buffer_[0] == ';') { - ESP_LOGI(TAG, "Comment: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + if (line[0] == ';') { + ESP_LOGI(TAG, "Comment: %s", line.c_str()); return; } - std::string::size_type newlineposn = this->buffer_.find('\n'); - if (newlineposn <= 1) { - // allow both \r\n and \n + if (line.empty()) { ESP_LOGD(TAG, "Received empty line"); return; } - if (newlineposn <= 2) { + if (line.size() == 1) { // single character lines, such as acknowledgements - ESP_LOGD(TAG, "Received ack: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGD(TAG, "Received ack: %s", line.c_str()); return; } - if (this->buffer_.find("LensBad") != std::string::npos) { + if (line.find("LensBad") != std::string::npos) { ESP_LOGW(TAG, "Received LensBad!"); this->lens_bad_ = true; } - if (this->buffer_.find("EmSat") != std::string::npos) { + if (line.find("EmSat") != std::string::npos) { ESP_LOGW(TAG, "Received EmSat!"); this->em_sat_ = true; } - if (buffer_.starts_with("PwrDays")) { + if (line.starts_with("PwrDays")) { if (this->boot_count_ <= 0) { this->boot_count_ = 1; } else { @@ -190,7 +188,7 @@ void HydreonRGxxComponent::process_line_() { } this->cancel_interval(REBOOT_INTERVAL_ID); this->no_response_count_ = 0; - ESP_LOGI(TAG, "Boot detected: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGI(TAG, "Boot detected: %s", line.c_str()); if (this->model_ == RG15) { if (this->resolution_ == FORCE_HIGH) { @@ -211,17 +209,18 @@ void HydreonRGxxComponent::process_line_() { } return; } - if (buffer_.starts_with("SW")) { - std::string::size_type majend = this->buffer_.find('.'); - std::string::size_type endversion = this->buffer_.find(' ', 3); + if (line.starts_with("SW")) { + StringRef::size_type majend = line.find('.'); + StringRef::size_type endversion = line.find(' ', 3); if (majend == std::string::npos || endversion == std::string::npos || majend > endversion) { - ESP_LOGW(TAG, "invalid version string: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGW(TAG, "invalid version string: %s", line.c_str()); } - int major = strtol(this->buffer_.substr(3, majend - 3).c_str(), nullptr, 10); - int minor = strtol(this->buffer_.substr(majend + 1, endversion - (majend + 1)).c_str(), nullptr, 10); + // strtol stops at the '.' and the ' ' + int major = line.size() > 3 ? strtol(line.c_str() + 3, nullptr, 10) : 0; + int minor = strtol(line.c_str() + (majend == std::string::npos ? 0 : majend + 1), nullptr, 10); if (major > 10 || minor >= 1000 || minor < 0 || major < 0) { - ESP_LOGW(TAG, "invalid version: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + ESP_LOGW(TAG, "invalid version: %s", line.c_str()); } this->sw_version_ = major * 1000 + minor; ESP_LOGI(TAG, "detected sw version %i", this->sw_version_); @@ -229,56 +228,51 @@ void HydreonRGxxComponent::process_line_() { } bool is_data_line = false; for (int i = 0; i < NUM_SENSORS; i++) { - if (this->sensors_[i] != nullptr && this->buffer_.find(PROTOCOL_NAMES[i]) != std::string::npos) { - is_data_line = true; - break; + if (this->sensors_[i] == nullptr) { + continue; } - } - if (is_data_line) { - std::string::size_type tc = this->buffer_.find("TooCold"); - this->too_cold_ |= tc != std::string::npos; - if (this->too_cold_) { - ESP_LOGD(TAG, "Received TooCold"); + StringRef::size_type n = line.find(PROTOCOL_NAMES[i]); + if (n == std::string::npos) { + continue; } - for (int i = 0; i < NUM_SENSORS; i++) { - if (this->sensors_[i] == nullptr) { - continue; - } - std::string::size_type n = this->buffer_.find(PROTOCOL_NAMES[i]); + is_data_line = true; + + if (n == line.find('t', n)) { + // The device temperature ('t') response contains both °C and °F values: + // "t 72F 22C". + // ESPHome uses only °C, only parse °C value (move past 'F'). + n = line.find('F', n); if (n == std::string::npos) { continue; } + n += 1; // move past 'F' + } else { + n += strlen(PROTOCOL_NAMES[i]); // move past protocol name + } - if (n == this->buffer_.find('t', n)) { - // The device temperature ('t') response contains both °C and °F values: - // "t 72F 22C". - // ESPHome uses only °C, only parse °C value (move past 'F'). - n = this->buffer_.find('F', n); - if (n == std::string::npos) { - continue; - } - n += 1; // move past 'F' - } else { - n += strlen(PROTOCOL_NAMES[i]); // move past protocol name - } - - // parse value, starting at str position n - float data = strtof(this->buffer_.substr(n).c_str(), nullptr); - this->sensors_[i]->publish_state(data); - ESP_LOGD(TAG, "Received %s: %f", PROTOCOL_NAMES[i], this->sensors_[i]->get_raw_state()); - this->sensors_received_ |= (1 << i); + // parse value, starting at str position n + float data = strtof(line.c_str() + n, nullptr); + this->sensors_[i]->publish_state(data); + ESP_LOGD(TAG, "Received %s: %f", PROTOCOL_NAMES[i], this->sensors_[i]->get_raw_state()); + this->sensors_received_ |= (1 << i); + } + if (is_data_line) { + StringRef::size_type tc = line.find("TooCold"); + this->too_cold_ |= tc != std::string::npos; + if (this->too_cold_) { + ESP_LOGD(TAG, "Received TooCold"); } if (this->request_temperature_ && this->num_sensors_missing_() == 1) { this->write_str("T\n"); } } else { for (const auto *ignore : IGNORE_STRINGS) { - if (buffer_.starts_with(ignore)) { - ESP_LOGI(TAG, "Ignoring %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); + if (line.starts_with(ignore)) { + ESP_LOGI(TAG, "Ignoring %s", line.c_str()); return; } } - ESP_LOGI(TAG, "Got unknown line: %s", this->buffer_.c_str()); + ESP_LOGI(TAG, "Got unknown line: %s", line.c_str()); } } diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.h b/esphome/components/hydreon_rgxx/hydreon_rgxx.h index a7bde6105c6..e0fa8fc2b4a 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.h +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.h @@ -2,10 +2,12 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/string_ref.h" #include "esphome/components/sensor/sensor.h" #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::hydreon_rgxx { @@ -55,10 +57,9 @@ class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDev void set_disable_led(bool disable_led) { this->disable_led_ = disable_led; } protected: - void process_line_(); + /// line is NUL terminated + void process_line_(StringRef line); void schedule_reboot_(); - bool buffer_starts_with_(const std::string &prefix); - bool buffer_starts_with_(const char *prefix); int num_sensors_missing_(); sensor::Sensor *sensors_[NUM_SENSORS] = {nullptr}; @@ -70,7 +71,6 @@ class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDev int16_t boot_count_ = 0; int16_t no_response_count_ = 0; - std::string buffer_; RGModel model_ = RG9; RG15Resolution resolution_ = FORCE_HIGH; @@ -81,6 +81,7 @@ class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDev bool em_sat_ = false; bool request_temperature_ = false; bool disable_led_ = false; + LineReader<80> line_reader_; // bit field showing which sensors we have received data for int sensors_received_ = -1; diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index 8e269fef9a0..222a5308ae1 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -141,6 +141,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index e20c091b75c..efc742610d9 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -67,6 +67,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index e75e5e95dd3..ba07bc3d6d3 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -426,26 +426,20 @@ void Sim800LComponent::loop() { uint8_t byte; this->read_byte(&byte); - if (this->read_pos_ == SIM800L_READ_BUFFER_LENGTH) - this->read_pos_ = 0; + ESP_LOGVV(TAG, "Buffer pos: %u %d", this->line_reader_.length(), byte); // NOLINT - ESP_LOGVV(TAG, "Buffer pos: %u %d", this->read_pos_, byte); // NOLINT - - if (byte == ASCII_CR) - continue; if (byte >= 0x7F) byte = '?'; // need to be valid utf8 string for log functions. - this->read_buffer_[this->read_pos_] = byte; - - if (this->state_ == STATE_SENDING_SMS_2 && this->read_pos_ == 0 && byte == '>') - this->read_buffer_[++this->read_pos_] = ASCII_LF; - - if (this->read_buffer_[this->read_pos_] == ASCII_LF) { - this->read_buffer_[this->read_pos_] = 0; - this->read_pos_ = 0; - this->parse_cmd_(this->read_buffer_); + const char *line = this->line_reader_.feed(byte); + if (line == nullptr && this->state_ == STATE_SENDING_SMS_2 && byte == '>' && this->line_reader_.length() == 1) { + // The send prompt has no line end + line = this->line_reader_.partial(); + this->line_reader_.reset(); + } + if (line != nullptr) { + this->parse_cmd_(line); } else { - this->read_pos_++; + this->line_reader_.log_if_overflowed(TAG); } } if (state_ == STATE_INIT && this->registered_ && diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index bbe8e14cf7e..a6f9544781d 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -10,6 +10,7 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" #include "esphome/core/automation.h" @@ -95,8 +96,7 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent #endif std::string sender_; std::string message_; - char read_buffer_[SIM800L_READ_BUFFER_LENGTH]; - size_t read_pos_{0}; + LineReader line_reader_; uint8_t parse_index_{0}; uint8_t watch_dog_{0}; bool expect_ack_{false}; diff --git a/esphome/components/smt100/sensor.py b/esphome/components/smt100/sensor.py index dae98bc1897..fba10eab9ae 100644 --- a/esphome/components/smt100/sensor.py +++ b/esphome/components/smt100/sensor.py @@ -80,6 +80,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index 7785bbca2d7..8a8f82b8504 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -12,8 +12,13 @@ void SMT100Component::update() { void SMT100Component::loop() { while (this->available() != 0) { - if (this->readline_(this->read(), this->readline_buffer_, MAX_LINE_LENGTH) > 0) { - char *token = strtok(this->readline_buffer_, ","); + const int readch = this->read(); + if (readch <= 0) + continue; + char *line = this->line_reader_.feed(readch); + this->line_reader_.log_if_overflowed(TAG); + if (line != nullptr && line[0] != '\0') { + char *token = strtok(line, ","); if (!token) continue; int counts = (int) strtol(token, nullptr, 10); @@ -67,26 +72,4 @@ void SMT100Component::dump_config() { LOG_UPDATE_INTERVAL(this); } -int SMT100Component::readline_(int readch, char *buffer, int len) { - int rpos; - - if (readch > 0) { - switch (readch) { - case '\n': // Ignore new-lines - break; - case '\r': // Return on CR - rpos = this->readline_pos_; - this->readline_pos_ = 0; // Reset position index ready for next time - return rpos; - default: - if (this->readline_pos_ < len - 1) { - buffer[this->readline_pos_++] = readch; - buffer[this->readline_pos_] = 0; - } - } - } - // No end of line has been found, so return -1. - return -1; -} - } // namespace esphome::smt100 diff --git a/esphome/components/smt100/smt100.h b/esphome/components/smt100/smt100.h index 55977a5caf6..12641121fe6 100644 --- a/esphome/components/smt100/smt100.h +++ b/esphome/components/smt100/smt100.h @@ -2,12 +2,14 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::smt100 { class SMT100Component final : public PollingComponent, public uart::UARTDevice { - static const uint16_t MAX_LINE_LENGTH = 31; + // 33 characters plus the NUL; the longest line, "15000,80.00,100.00,-20.00,12.00", is 31 + static constexpr uint16_t LINE_BUFFER_SIZE = 34; public: SMT100Component() = default; @@ -25,10 +27,7 @@ class SMT100Component final : public PollingComponent, public uart::UARTDevice { void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } protected: - int readline_(int readch, char *buffer, int len); - - char readline_buffer_[MAX_LINE_LENGTH]{}; - int readline_pos_{0}; + LineReader line_reader_; sensor::Sensor *counts_sensor_{nullptr}; sensor::Sensor *permittivity_sensor_{nullptr}; diff --git a/tests/components/apc_proteous/common.h b/tests/components/apc_proteous/common.h index a3baa5131de..1b595104f75 100644 --- a/tests/components/apc_proteous/common.h +++ b/tests/components/apc_proteous/common.h @@ -51,9 +51,9 @@ class TestableAPCProteousCover : public APCProteousCover { using APCProteousCover::MAX_RESPONSE_LEN; using APCProteousCover::command_retries_; using APCProteousCover::last_command_tx_; + using APCProteousCover::line_reader_; using APCProteousCover::pending_command_; using APCProteousCover::pending_command_time_; - using APCProteousCover::rx_len_; using APCProteousCover::target_position_; }; diff --git a/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp index 566701338b3..3e784f06bfc 100644 --- a/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp +++ b/tests/components/apc_proteous/cover/apc_proteous_cover_test.cpp @@ -111,7 +111,7 @@ TEST_F(APCProteousCoverTest, MalformedFramesAreIgnored) { EXPECT_FLOAT_EQ(this->cover_.position, 0.5f); EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_IDLE); EXPECT_EQ(this->publishes_, 0); - EXPECT_EQ(this->cover_.rx_len_, 0); + EXPECT_EQ(this->cover_.line_reader_.length(), 0); } TEST_F(APCProteousCoverTest, LineFeedsAreIgnored) { @@ -134,7 +134,7 @@ TEST_F(APCProteousCoverTest, UnterminatedNoiseIsDiscarded) { EXPECT_FLOAT_EQ(this->cover_.position, 0.25f); this->feed_("0123456789ABCDEF0123456789ABCDEF"); - EXPECT_LE(this->cover_.rx_len_, TestableAPCProteousCover::MAX_RESPONSE_LEN); + EXPECT_LE(this->cover_.line_reader_.length(), TestableAPCProteousCover::MAX_RESPONSE_LEN); } TEST_F(APCProteousCoverTest, OpenSendsOpenCommand) { @@ -405,9 +405,9 @@ TEST_F(APCProteousCoverTest, UpdateHoldsOffPollingAfterCommand) { // A command echo left without a terminator must not merge with the next response. TEST_F(APCProteousCoverTest, UpdateDiscardsStaleEcho) { this->feed_("*6"); - EXPECT_NE(this->cover_.rx_len_, 0); + EXPECT_NE(this->cover_.line_reader_.length(), 0); this->cover_.update(); - EXPECT_EQ(this->cover_.rx_len_, 0); + EXPECT_EQ(this->cover_.line_reader_.length(), 0); this->feed_("?s=01\r"); EXPECT_EQ(this->cover_.current_operation, COVER_OPERATION_OPENING); } diff --git a/tests/components/dfrobot_sen0609/__init__.py b/tests/components/dfrobot_sen0609/__init__.py new file mode 100644 index 00000000000..5cf3c18e999 --- /dev/null +++ b/tests/components/dfrobot_sen0609/__init__.py @@ -0,0 +1,14 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # One empty config entry so to_code runs and sets the define that keeps + # core/line_reader.cpp in the test build. + manifest.multi_conf = False + + async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LINE_READER") + + manifest.to_code = to_code diff --git a/tests/components/emontx/__init__.py b/tests/components/emontx/__init__.py new file mode 100644 index 00000000000..143c58c8699 --- /dev/null +++ b/tests/components/emontx/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Only the define that keeps core/line_reader.cpp in the test build + async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LINE_READER") + + manifest.to_code = to_code From 885f2fe5d86853de93f70f1e6ad500b3d5cf7b12 Mon Sep 17 00:00:00 2001 From: HugoPeters1024 Date: Sat, 17 Oct 2026 10:04:01 +0200 Subject: [PATCH 022/179] [kamstrup_kmp] Fix Kamstrup KMP reads with echoing optical heads (#20897) Co-authored-by: J. Nick Koston --- esphome/components/kamstrup_kmp/kamstrup_kmp.cpp | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index fcf648c31f3..396f4514cfe 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -142,6 +142,9 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { if (data > -1) { if (data == 0x40) { // start of message buffer_len = 0; + } else if (buffer_len == 0) { + // Ignore request echoes until the response starts. + continue; } buffer[buffer_len++] = (uint8_t) data; if (data == 0x0D) { @@ -161,12 +164,7 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { return; } - // Validate message (prefix and suffix) - if (buffer[0] != 0x40) { - ESP_LOGE(TAG, "Received invalid message (prefix mismatch received 0x%02X, expected 0x40)", buffer[0]); - return; - } - + // Only bytes from the 0x40 start byte on are stored, so just the end of the message needs checking if (buffer[buffer_len - 1] != 0x0D) { ESP_LOGE(TAG, "Received invalid message (EOM mismatch received 0x%02X, expected 0x0D)", buffer[buffer_len - 1]); return; @@ -175,7 +173,7 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { // Decode uint8_t msg[20] = {0}; int msg_len = 0; - for (size_t i = 1; i < buffer_len - 1; i++) { + for (size_t i = 1; i < buffer_len - 1 && msg_len < (int) sizeof(msg); i++) { if (buffer[i] == 0x1B) { msg[msg_len++] = buffer[i + 1] ^ 0xFF; i++; @@ -184,8 +182,8 @@ void KamstrupKMPComponent::read_command_(uint16_t command) { } } - // Validate CRC - if (crc16be(msg, msg_len - 2) != encode_uint16(msg[msg_len - 2], msg[msg_len - 1])) { + // Validate CRC; a frame shorter than the CRC itself has nothing to check + if (msg_len < 2 || crc16be(msg, msg_len - 2) != encode_uint16(msg[msg_len - 2], msg[msg_len - 1])) { ESP_LOGE(TAG, "Received invalid message (CRC mismatch)"); return; } From 06b585d3733d634427abb492099d1373a58598fa Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Sat, 17 Oct 2026 10:05:21 +0200 Subject: [PATCH 023/179] [i2s_audio] Fix speaker restart loop with full_duplex (#20984) --- .../components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 73ad924fad7..026a66c1cf1 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -456,6 +456,7 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream } return ESP_ERR_NOT_SUPPORTED; } + this->current_stream_info_ = audio_stream_info; // The speaker task will enable the channel after preloading. return this->acquire_full_duplex_channel_(I2S_EVENT_QUEUE_COUNT); } From eafcca827429681fcd1f0ce6916e391f37b48032 Mon Sep 17 00:00:00 2001 From: Nicolas Liaudat Date: Sat, 17 Oct 2026 10:47:28 +0200 Subject: [PATCH 024/179] [mics_5524] Add datasheet model, diagnostics and advanced wiring (2/2) (#20521) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/mics_5524/__init__.py | 66 +- esphome/components/mics_5524/coefficients.py | 166 ++++- esphome/components/mics_5524/mics_5524.cpp | 255 ++++++-- esphome/components/mics_5524/mics_5524.h | 77 ++- esphome/components/mics_5524/mics_math.h | 101 ++- esphome/components/mics_5524/sensor.py | 406 +++++++++++- esphome/core/defines.h | 1 + .../mics_5524/config/mics_5524.yaml | 26 + .../mics_5524/test_mics_5524.py | 597 +++++++++++++++--- tests/components/mics_5524/common.h | 4 + tests/components/mics_5524/common.yaml | 7 +- .../mics_5524/sensor/mics_math_test.cpp | 87 +++ .../validate-datasheet.esp32-idf.yaml | 31 + 13 files changed, 1611 insertions(+), 213 deletions(-) create mode 100644 tests/components/mics_5524/validate-datasheet.esp32-idf.yaml diff --git a/esphome/components/mics_5524/__init__.py b/esphome/components/mics_5524/__init__.py index 1b1c4111470..0fdec90a944 100644 --- a/esphome/components/mics_5524/__init__.py +++ b/esphome/components/mics_5524/__init__.py @@ -1,19 +1,83 @@ -"""MiCS-5524 gas sensor platform: the DFRobot_MICS vendor model on a voltage sampler.""" +"""MiCS-5524 gas sensor platform with the DFRobot vendor model and the datasheet fit.""" import esphome.codegen as cg from esphome.components import sensor +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C3, + VARIANT_ESP32C6, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +from esphome.const import PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2 CODEOWNERS = ["@nliaudat"] DOMAIN = "mics_5524" mics_5524_ns = cg.esphome_ns.namespace("mics_5524") +micsmath_ns = mics_5524_ns.namespace("micsmath") +ConversionModel = micsmath_ns.enum("ConversionModel") MiCS5524Component = mics_5524_ns.class_("MiCS5524Component", cg.PollingComponent) MiCS5524GasSensor = mics_5524_ns.class_("MiCS5524GasSensor", sensor.Sensor) +CONF_CONVERSION = "conversion" +CONF_COEFFICIENT_A = "coefficient_a" +CONF_COEFFICIENT_B = "coefficient_b" CONF_VOLTAGE_MULTIPLIER = "voltage_multiplier" +CONF_ADC_INPUT_MAX = "adc_input_max" +CONF_ADC_PIN_MAX = "adc_pin_max" +CONF_ADC_ATTENUATION = "adc_attenuation" +CONF_R1 = "r1" +CONF_R2 = "r2" CONF_VCC = "vcc" +CONF_RL = "rl" +CONF_R0 = "r0" CONF_AIR_REFERENCE = "air_reference" CONF_SAMPLE_COUNT = "sample_count" CONF_SAMPLE_INTERVAL = "sample_interval" +CONF_PERSIST = "persist" +CONF_MIN_PPM = "min_ppm" CONF_MAX_PPM = "max_ppm" +CONF_RATIO_SENSOR = "ratio_sensor" +CONF_RS_SENSOR = "rs_sensor" +CONF_VOLTAGE_SENSOR = "voltage_sensor" + +CONVERSION_MODELS = { + "dfrobot": ConversionModel.CONVERSION_MODEL_DFROBOT, + "datasheet": ConversionModel.CONVERSION_MODEL_DATASHEET, +} + +# Divider guard limits (input max, pin max) in volts by target platform. ESP32 and RP2: VDD and +# the absolute maximum VDD + 0.3 V; a bare ESP8266 A0 measures 0 - 1 V, boards with an onboard +# divider declare more. Any other platform declares both limits. +ADC_LIMITS_V = { + PLATFORM_ESP32: (3.3, 3.6), + PLATFORM_ESP8266: (1.0, 1.0), + PLATFORM_RP2: (3.3, 3.6), +} + +# Top of the recommended input range of each ESP32 ADC attenuation, in volts, from the ESP-IDF ADC +# calibration documentation; above it the reading flattens. The ESP32 row is the most conservative +# and stands in for variants without their own row. "auto" ends up at the widest range. +_ESP32_ATTENUATION_INPUT_MAX_V = { + VARIANT_ESP32: {"0db": 0.95, "2.5db": 1.25, "6db": 1.75, "12db": 2.45}, + VARIANT_ESP32S2: {"0db": 0.75, "2.5db": 1.05, "6db": 1.3, "12db": 2.5}, + VARIANT_ESP32S3: {"0db": 0.95, "2.5db": 1.25, "6db": 1.75, "12db": 3.1}, + VARIANT_ESP32C3: {"0db": 0.75, "2.5db": 1.05, "6db": 1.3, "12db": 2.5}, + VARIANT_ESP32C6: {"0db": 0.95, "2.5db": 1.25, "6db": 1.75, "12db": 3.3}, +} + + +def esp32_attenuation_input_max(variant: str, attenuation: str) -> float: + """Largest input the internal ADC measures at the attenuation, for the variant.""" + table = _ESP32_ATTENUATION_INPUT_MAX_V.get( + variant, _ESP32_ATTENUATION_INPUT_MAX_V[VARIANT_ESP32] + ) + if attenuation in ("11db", "auto"): + attenuation = "12db" + return table[attenuation] + + +# Guard margin (V): the 3.3333 V of a 10k/20k divider still passes adc_input_max: 3.33. +ADC_LIMIT_MARGIN_V = 0.01 diff --git a/esphome/components/mics_5524/coefficients.py b/esphome/components/mics_5524/coefficients.py index cb1b0551f75..2050e982e1a 100644 --- a/esphome/components/mics_5524/coefficients.py +++ b/esphome/components/mics_5524/coefficients.py @@ -1,4 +1,4 @@ -"""Gas table of the DFRobot_MICS vendor model; the formula lives in mics_math.h.""" +"""Gas tables of both MiCS-5524 conversion models; the formulas live in mics_math.h.""" from dataclasses import dataclass @@ -10,6 +10,9 @@ from esphome.const import ( CONF_METHANE, ) +CONVERSION_DFROBOT = "dfrobot" +CONVERSION_DATASHEET = "datasheet" + @dataclass(frozen=True) class VendorCurve: @@ -22,11 +25,158 @@ class VendorCurve: max_ppm: float # upper end of the measuring range -# DFRobot_MICS getGasData() thresholds and gains with the vendor measuring ranges. -GASES: dict[str, VendorCurve] = { - CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), - CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), - CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), - CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), - CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), +@dataclass(frozen=True) +class DatasheetCurve: + """Coefficients of the datasheet fit ppm = a * ratio^b and its detection range.""" + + a: float + b: float + min_ppm: float # bottom of the detection range, read as 0 below it + max_ppm: float + + +@dataclass(frozen=True) +class GasDefinition: + """One gas with the constants of both models.""" + + vendor: VendorCurve + datasheet: DatasheetCurve | None + + +# DFRobot_MICS getGasData() thresholds and gains with the vendor measuring ranges. The CO +# datasheet fit is read off the sensitivity graph of SGX data sheet 1084 rev 8: RS/R0 falls +# from about 3.5 at 1 ppm to about 0.011 at 1000 ppm, a slope of -0.83 per decade. R0 here is +# the resistance in clean air, where the sensor sits at the 1 ppm bottom of its detection +# range, so the ratio is 1 there and ppm = ratio^-1.2. The data sheet calls its own curve +# indicative: the sensitivity factor RS(air) / RS(60 ppm CO) spans 1.2 to 50 between units. +GASES: dict[str, GasDefinition] = { + CONF_CARBON_MONOXIDE: GasDefinition( + vendor=VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + datasheet=DatasheetCurve(1.0, -1.2, 1.0, 1000.0), + ), + CONF_HYDROGEN: GasDefinition( + vendor=VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), + datasheet=None, # no published fit + ), + CONF_ETHANOL: GasDefinition( + vendor=VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), + datasheet=None, + ), + CONF_AMMONIA: GasDefinition( + vendor=VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), + datasheet=None, + ), + CONF_METHANE: GasDefinition( + vendor=VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), + datasheet=None, + ), } + + +def datasheet_gases() -> list[str]: + """Labels of the gases that ship with datasheet coefficients.""" + return [ + definition.vendor.label + for definition in GASES.values() + if definition.datasheet is not None + ] + + +@dataclass(frozen=True) +class ResolvedGas: + """Resolved gas configuration handed to the C++ component.""" + + label: str + conversion: str + p0: float # threshold (vendor model) or a (datasheet model) + p1: float # gain (vendor model) or b (datasheet model) + min_ppm: float + max_ppm: float + + +def resolve_gas( + gas_key: str, + conversion: str, + *, + a: float | None = None, + b: float | None = None, + min_ppm: float | None = None, + max_ppm: float | None = None, +) -> ResolvedGas: + """Merge the built-in curve with the YAML overrides; ValueError on a bad combination.""" + if conversion not in (CONVERSION_DFROBOT, CONVERSION_DATASHEET): + raise ValueError(f"unknown conversion '{conversion}'") + if gas_key not in GASES: + raise ValueError( + f"unknown gas '{gas_key}', expected one of: {', '.join(GASES)}" + ) + + definition = GASES[gas_key] + curve = definition.vendor + + if conversion == CONVERSION_DFROBOT: + if a is not None or b is not None: + raise ValueError( + "'coefficient_a:'/'coefficient_b:' are the coefficients of " + f"conversion: {CONVERSION_DATASHEET} - remove them or switch the " + "conversion" + ) + return ResolvedGas( + label=curve.label, + conversion=conversion, + p0=curve.threshold, + p1=curve.gain, + # The vendor floor is where it stops reporting gas, so it is the default min_ppm. + min_ppm=float(curve.min_ppm if min_ppm is None else min_ppm), + max_ppm=float(curve.max_ppm if max_ppm is None else max_ppm), + ) + + fitted = definition.datasheet + # One coefficient with the shipped other one would mix two fits. + if (a is None) != (b is None): + raise ValueError( + "set both 'coefficient_a:' and 'coefficient_b:' together, or neither " + f"(only {', '.join(datasheet_gases())} ship with built-in " + f"'conversion: {CONVERSION_DATASHEET}' coefficients)" + ) + resolved_a = ( + float(a) if a is not None else (fitted.a if fitted is not None else None) + ) + resolved_b = ( + float(b) if b is not None else (fitted.b if fitted is not None else None) + ) + if resolved_a is None or resolved_b is None: + shipped = ", ".join(datasheet_gases()) + raise ValueError( + f"{curve.label} has no built-in 'conversion: {CONVERSION_DATASHEET}' " + f"coefficients (only {shipped}) - set both 'coefficient_a:' and " + "'coefficient_b:' in the configuration" + ) + if resolved_a <= 0.0: + raise ValueError( + f"'coefficient_a:' must be positive, got {resolved_a:g} - " + "a non-positive fit cannot produce a reading" + ) + # The resistance of a MOS sensor falls as a reducing gas rises; a flat or rising fit + # would read a saturated sensor (ratio 0) as no gas. + if resolved_b >= 0.0: + raise ValueError( + f"'coefficient_b:' must be negative, got {resolved_b:g} - " + "the sensor resistance falls as the gas concentration rises" + ) + if min_ppm is None: + # A user fit has no published floor; the shipped one reads 0 below its range. + min_ppm = fitted.min_ppm if a is None and fitted is not None else 0.0 + return ResolvedGas( + label=curve.label, + conversion=conversion, + p0=resolved_a, + p1=resolved_b, + min_ppm=float(min_ppm), + # A user fit without a published range falls back to the vendor range of the gas. + max_ppm=float( + (fitted.max_ppm if fitted is not None else curve.max_ppm) + if max_ppm is None + else max_ppm + ), + ) diff --git a/esphome/components/mics_5524/mics_5524.cpp b/esphome/components/mics_5524/mics_5524.cpp index 2023661b369..bc559209afe 100644 --- a/esphome/components/mics_5524/mics_5524.cpp +++ b/esphome/components/mics_5524/mics_5524.cpp @@ -10,31 +10,90 @@ namespace esphome::mics_5524 { ESPHOME_LOG_TAG(TAG, "mics_5524"); -void MiCS5524GasSensor::publish_ratio(float ratio) { - const float ppm = micsmath::ppm_from_vendor_curve(ratio, this->curve_); +/// Added to the conversion model, so a value stored for one model is never loaded by the other. +static constexpr uint32_t REFERENCE_PREFERENCE_VERSION = 0x00000010; + +/// Log strings of one conversion model. +struct ModelStrings { + const LogString *conversion; ///< YAML value of `conversion:` + const LogString *reference; ///< name of the clean-air reference + const LogString *key; ///< YAML key of the reference +}; + +static ModelStrings model_strings(micsmath::ConversionModel model) { + if (model == micsmath::CONVERSION_MODEL_DFROBOT) + return {LOG_STR("dfrobot"), LOG_STR("air reference"), LOG_STR("air_reference")}; + return {LOG_STR("datasheet"), LOG_STR("R0"), LOG_STR("r0")}; +} + +void MiCS5524GasSensor::publish_ratio(float ratio, micsmath::ConversionModel model) { + float ppm = micsmath::ppm_from_vendor_curve(ratio, {this->p0_, this->p1_, this->min_ppm_, this->max_ppm_}); +#ifdef USE_MICS_5524_DATASHEET + // One firmware can hold a hub of each model, so the check stays inside the guard + if (model == micsmath::CONVERSION_MODEL_DATASHEET) { + ppm = + micsmath::clamp_ppm(micsmath::ppm_from_power_law(this->p0_, this->p1_, ratio), this->min_ppm_, this->max_ppm_); + } +#endif ESP_LOGD(TAG, "'%s': ratio=%.4f -> %.1f ppm", this->get_name().c_str(), ratio, ppm); this->publish_state(ppm); } void MiCS5524Component::setup() { + if (this->persist_) { + // Written once per calibration, so flash rather than the RTC memory an ESP8266 loses at power off + this->reference_pref_ = global_preferences->make_preference( + this->preference_key_ ^ (REFERENCE_PREFERENCE_VERSION + this->conversion_), true); + } + + if (this->enable_pin_ != nullptr) { + // EN of the breakout is active low; `inverted: true` makes this write switch the module on. + this->enable_pin_->setup(); + this->enable_pin_->digital_write(true); + } + if (this->has_reference_()) { - ESP_LOGI(TAG, "using the air reference from the configuration (%.4f V)", this->reference_); + ESP_LOGI(TAG, "using the %s from the configuration (%.4f) - the sensor is not calibrated at boot", + LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + } else if (this->persist_ && this->load_reference_()) { + ESP_LOGI(TAG, "restored the %s from flash (%.4f)", LOG_STR_ARG(model_strings(this->conversion_).reference), + this->reference_); } if (this->warmup_time_ > 0) { + this->warming_up_ = true; this->stop_poller(); - this->set_timeout(this->warmup_time_, [this]() { this->start_poller(); }); + this->set_timeout(this->warmup_time_, [this]() { + this->warming_up_ = false; + ESP_LOGI(TAG, "warm-up finished, readings are published from now on"); + this->start_poller(); + // A calibration waiting for the warm-up starts when one at boot would have. + if (this->calibration_pending_) { + this->schedule_calibration_( + this->calibration_delay_ > this->warmup_time_ ? this->calibration_delay_ - this->warmup_time_ : 0); + } + }); ESP_LOGI(TAG, "warm-up of %" PRIu32 " s - no readings published before that", this->warmup_time_ / 1000); } - if (this->calibration_samples_ > 0) { - // Nothing is stored across reboots; the warm-up window starts here, so the delay covers it. - const uint32_t delay = std::max(this->calibration_delay_, this->warmup_time_); - this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); - ESP_LOGI(TAG, "air reference calibration in %" PRIu32 " s - the sensor must be in clean air", delay / 1000); + if (this->calibrate_at_boot_) { + // A fixed or restored reference wins. + if (this->has_reference_()) { + ESP_LOGI(TAG, "%s already known (%.4f) - automatic calibration skipped", + LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + } else { + const uint32_t boot_delay = std::max(this->calibration_delay_, this->warmup_time_); + this->calibration_pending_ = true; + // During a warm-up the warm-up timeout starts it, at the same boot_delay. + if (!this->warming_up_) + this->schedule_calibration_(boot_delay); + ESP_LOGI(TAG, "clean-air calibration of the %s in %" PRIu32 " s - the sensor must be in clean air", + LOG_STR_ARG(model_strings(this->conversion_).reference), boot_delay / 1000); + } } else if (!this->has_reference_()) { - ESP_LOGW(TAG, "no air reference available, the PPM values stay unknown\n" - " set 'air_reference:' or enable 'calibration:'"); + ESP_LOGW(TAG, + "no %s available, the PPM values stay unknown\n set it in the configuration or enable 'calibration:'", + LOG_STR_ARG(model_strings(this->conversion_).reference)); } // The loop only samples while a clean-air calibration runs; `update()` publishes. @@ -44,17 +103,25 @@ void MiCS5524Component::setup() { void MiCS5524Component::dump_config() { ESP_LOGCONFIG(TAG, "MiCS-5524:\n" - " VCC: %.2f V, AO multiplier: x%.3f, AO samples per reading: %u\n" + " Conversion: %s, VCC: %.2f V, AO multiplier: x%.3f, AO samples per reading: %u\n" " Warm-up: %" PRIu32 " s, calibration: %s, sample spacing: %" PRIu32 " ms", - this->vcc_, this->voltage_multiplier_, static_cast(this->samples_), this->warmup_time_ / 1000, - this->calibration_samples_ > 0 ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"), + LOG_STR_ARG(model_strings(this->conversion_).conversion), this->vcc_, this->voltage_multiplier_, + static_cast(this->samples_), this->warmup_time_ / 1000, + this->calibrate_at_boot_ ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"), this->sample_interval_); LOG_UPDATE_INTERVAL(this); - if (this->has_reference_()) { - ESP_LOGCONFIG(TAG, " Air reference (vcc - V_AO): %.4f V", this->reference_); - } else { - ESP_LOGCONFIG(TAG, " Air reference: not available yet"); + LOG_PIN(" Enable pin: ", this->enable_pin_); + if (!this->is_vendor_model_()) { + ESP_LOGCONFIG(TAG, " Load resistor: %.1f kOhm", this->rl_); } + if (this->has_reference_()) { + ESP_LOGCONFIG(TAG, " %s: %.4f", LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + } else { + ESP_LOGCONFIG(TAG, " %s: not available yet", LOG_STR_ARG(model_strings(this->conversion_).reference)); + } + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Ratio", this->ratio_sensor_); + LOG_SENSOR(" ", "RS", this->rs_sensor_); #ifdef MICS_5524_GAS_COUNT for (auto *gas : this->gases_) { LOG_SENSOR(" ", "Gas", gas); @@ -71,21 +138,19 @@ float MiCS5524Component::sample_voltage_() { return (sum / static_cast(this->samples_)) * this->voltage_multiplier_; } -void MiCS5524Component::publish_ratio_(float ratio) { -#ifdef MICS_5524_GAS_COUNT - for (auto *gas : this->gases_) { - gas->publish_ratio(ratio); - } +float MiCS5524Component::model_value_(float voltage) { + float value = micsmath::air_value_from_voltage(voltage, this->vcc_); +#ifdef USE_MICS_5524_DATASHEET + if (!this->is_vendor_model_()) + value = micsmath::rs_from_voltage(voltage, this->vcc_, this->rl_); #endif -} - -float MiCS5524Component::read_air_value_() { - const float voltage = this->sample_voltage_(); - const float x = micsmath::air_value_from_voltage(voltage, this->vcc_); - if (std::isfinite(x)) - return x; - // The status flag makes this log once per fault, not once per update. - if (!this->status_has_warning()) { + if (!std::isnan(value)) { + this->input_fault_ = false; + return value; + } + // Logged once per fault; the status flag cannot tell it from a failed calibration. + if (!this->input_fault_) { + this->input_fault_ = true; ESP_LOGW(TAG, "analog output reads %.4f V with vcc %.2f V\n check the AO wiring, 'vcc:' and 'voltage_multiplier:'", voltage, this->vcc_); } @@ -93,27 +158,76 @@ float MiCS5524Component::read_air_value_() { return NAN; } +void MiCS5524Component::publish_ratio_(float ratio) { +#ifdef MICS_5524_GAS_COUNT + for (auto *gas : this->gases_) { + gas->publish_ratio(ratio, this->conversion_); + } +#endif +} + +void MiCS5524Component::publish_diagnostics_(float voltage, float ratio, float rs) { + if (this->voltage_sensor_ != nullptr) + this->voltage_sensor_->publish_state(voltage); + if (this->ratio_sensor_ != nullptr) + this->ratio_sensor_->publish_state(ratio); + // Validation rejects `rs_sensor:` under the vendor model. + if (this->rs_sensor_ != nullptr) + this->rs_sensor_->publish_state(rs); +} + void MiCS5524Component::update() { + if (!this->has_reference_() && this->voltage_sensor_ == nullptr && this->ratio_sensor_ == nullptr && + this->rs_sensor_ == nullptr) + return; + + const float voltage = this->sample_voltage_(); + const float value = this->model_value_(voltage); + float ratio = NAN; + if (this->has_reference_()) { + ratio = this->is_vendor_model_() ? micsmath::ratio_from_air_value(value, this->reference_) + : micsmath::ratio_from_rs(value, this->reference_); + } + this->publish_diagnostics_(voltage, ratio, value); + + // A failed calibration keeps its warning until the retry succeeds. + if (!std::isnan(value) && !this->calibrating_ && !this->calibration_pending_) + this->status_clear_warning(); + // Every gas starts unknown and the reference is never cleared, so there is nothing to republish. if (!this->has_reference_()) return; - const float air_value = this->read_air_value_(); - if (std::isnan(air_value)) { - this->publish_ratio_(NAN); - return; - } - this->status_clear_warning(); - - const float ratio = micsmath::ratio_from_air_value(air_value, this->reference_); - ESP_LOGD(TAG, "V_AO=%.4f V, x=%.4f, ratio=%.4f", this->vcc_ - air_value, air_value, ratio); + ESP_LOGD(TAG, "V_AO=%.4f V, ratio=%.4f", voltage, ratio); this->publish_ratio_(ratio); } -void MiCS5524Component::set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval) { +void MiCS5524Component::set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval, bool persist, + bool at_boot) { this->calibration_delay_ = delay; this->calibration_samples_ = samples; this->sample_interval_ = sample_interval; + this->persist_ = persist; + this->calibrate_at_boot_ = at_boot; +} + +void MiCS5524Component::request_calibration() { + if (this->calibrating_) { + ESP_LOGW(TAG, "calibration already running"); + return; + } + if (this->calibration_pending_) { + ESP_LOGW(TAG, "calibration already requested"); + return; + } + this->calibration_pending_ = true; + if (this->warming_up_) { + // A request must never capture an unstable reading; the warm-up timeout starts it. + ESP_LOGI(TAG, "calibration requested - waiting for the warm-up window to end"); + return; + } + this->schedule_calibration_(0); + ESP_LOGI(TAG, "calibration requested - keep the sensor in clean air"); } void MiCS5524Component::loop() { @@ -131,7 +245,12 @@ void MiCS5524Component::loop() { this->process_calibration_(); } +void MiCS5524Component::schedule_calibration_(uint32_t delay) { + this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); +} + void MiCS5524Component::begin_calibration_() { + this->calibration_pending_ = false; this->calibrating_ = true; this->calibration_last_sample_ = App.get_loop_component_start_time(); this->calibration_count_ = 0; @@ -141,14 +260,15 @@ void MiCS5524Component::begin_calibration_() { this->calibration_max_ = -INFINITY; this->enable_loop(); - ESP_LOGI(TAG, "calibrating the air reference, %" PRIu32 " samples - keep the sensor in clean air", - this->calibration_samples_); + ESP_LOGI(TAG, "calibrating the %s, %" PRIu32 " samples - keep the sensor in clean air", + LOG_STR_ARG(model_strings(this->conversion_).reference), this->calibration_samples_); } void MiCS5524Component::process_calibration_() { this->calibration_attempts_++; - const float reference = this->read_air_value_(); + // The samples are averaged in the units of the active model. + const float reference = this->model_value_(this->sample_voltage_()); if (!std::isnan(reference)) { this->calibration_min_ = std::min(this->calibration_min_, reference); this->calibration_max_ = std::max(this->calibration_max_, reference); @@ -179,28 +299,59 @@ void MiCS5524Component::finish_calibration_() { // A sensor still settling, or not in clean air, varies too much to be the reference. if (!micsmath::calibration_is_stable(this->calibration_min_, this->calibration_max_, reference)) { - ESP_LOGW( - TAG, "clean-air samples spread %.4f V around %.4f V (%.4f - %.4f V)\n the sensor was not settled in clean air", - this->calibration_max_ - this->calibration_min_, reference, this->calibration_min_, this->calibration_max_); + ESP_LOGW(TAG, "clean-air samples spread %.4f around %.4f (%.4f - %.4f)\n the sensor was not settled in clean air", + this->calibration_max_ - this->calibration_min_, reference, this->calibration_min_, + this->calibration_max_); this->schedule_calibration_retry_(); return; } this->reference_ = reference; this->status_clear_warning(); + const bool stored = this->persist_ && this->save_reference_(); + const ModelStrings strings = model_strings(this->conversion_); ESP_LOGI(TAG, - "air reference = %.4f (%" PRIu32 "/%" PRIu32 " samples valid)\n" - " hard-code 'air_reference: %.4f' to skip the calibration at boot", - this->reference_, valid, attempts, this->reference_); + "%s = %.4f (%" PRIu32 "/%" PRIu32 " samples valid)%s\n" + " hard-code '%s: %.4f' to skip the calibration at boot", + LOG_STR_ARG(strings.reference), this->reference_, valid, attempts, + stored ? LOG_STR_LITERAL(", stored in flash") + : this->persist_ ? LOG_STR_LITERAL("") + : LOG_STR_LITERAL(", kept until reboot"), + LOG_STR_ARG(strings.key), this->reference_); } void MiCS5524Component::schedule_calibration_retry_() { this->status_set_warning(); + this->calibration_pending_ = true; // A zero delay would log the same failure about five times per second. const uint32_t delay = std::max(this->calibration_delay_, CALIBRATION_RETRY_MIN_MS); - this->set_timeout(CALIBRATION_START_ID, delay, [this]() { this->begin_calibration_(); }); + this->schedule_calibration_(delay); ESP_LOGW(TAG, "retrying the calibration in %" PRIu32 " s", delay / 1000); } +bool MiCS5524Component::save_reference_() { + if (!this->reference_pref_.save(&this->reference_)) { + ESP_LOGW(TAG, "could not store the %s in flash", LOG_STR_ARG(model_strings(this->conversion_).reference)); + return false; + } + // Once per calibration, so write it out now rather than at the next preferences sync + global_preferences->sync(); + ESP_LOGD(TAG, "%s %.4f saved", LOG_STR_ARG(model_strings(this->conversion_).reference), this->reference_); + return true; +} + +bool MiCS5524Component::load_reference_() { + float stored = 0.0f; + if (!this->reference_pref_.load(&stored)) + return false; + if (!std::isfinite(stored) || stored <= 0.0f) { + ESP_LOGW(TAG, "ignoring the stored %s %.4f, calibrating again", + LOG_STR_ARG(model_strings(this->conversion_).reference), stored); + return false; + } + this->reference_ = stored; + return true; +} + } // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_5524.h b/esphome/components/mics_5524/mics_5524.h index de7263e5d78..6fcdd5baa9f 100644 --- a/esphome/components/mics_5524/mics_5524.h +++ b/esphome/components/mics_5524/mics_5524.h @@ -6,31 +6,37 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/voltage_sampler/voltage_sampler.h" #include "esphome/core/component.h" +#include "esphome/core/gpio.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" #include "mics_math.h" namespace esphome::mics_5524 { -/// One gas of a MiCS-5524: converts the measured ratio with the vendor curve of that gas. +/// One gas of a MiCS-5524: converts the measured ratio with the curve of that gas. class MiCS5524GasSensor : public sensor::Sensor { public: - /// The vendor constants from coefficients.py. - MiCS5524GasSensor(float threshold, float gain, float min_ppm, float max_ppm) - : curve_{threshold, gain, min_ppm, max_ppm} {} + /// `p0`/`p1` are threshold and gain (dfrobot) or a and b (datasheet), from coefficients.py. + MiCS5524GasSensor(float p0, float p1, float min_ppm, float max_ppm) + : p0_(p0), p1_(p1), min_ppm_(min_ppm), max_ppm_(max_ppm) {} - /// Publish the concentration for `ratio`; NAN publishes `unknown`. - void publish_ratio(float ratio); + /// Publish the concentration for `ratio` with the formula of `model`; NAN publishes `unknown`. + void publish_ratio(float ratio, micsmath::ConversionModel model); - float get_min_ppm() const { return this->curve_.min_ppm; } - float get_max_ppm() const { return this->curve_.max_ppm; } + float get_min_ppm() const { return this->min_ppm_; } + float get_max_ppm() const { return this->max_ppm_; } protected: - micsmath::VendorCurve curve_; + float p0_; + float p1_; + float min_ppm_; + float max_ppm_; }; -/// The MiCS-5524 breakout: one analog output, converted for every configured gas against -/// the clean-air reference x_air from `air_reference:` or a calibration at boot. +/// The MiCS-5524 breakout: one analog output, converted for every configured gas against the +/// clean-air reference, x_air (vendor) or R0 (datasheet), from the configuration, flash or a +/// calibration at boot. class MiCS5524Component : public PollingComponent { public: /// `source` must outlive this component, which code generation guarantees. @@ -45,34 +51,57 @@ class MiCS5524Component : public PollingComponent { void register_gas_sensor(MiCS5524GasSensor *gas) { this->gases_.push_back(gas); } #endif + void set_conversion(micsmath::ConversionModel conversion) { this->conversion_ = conversion; } void set_vcc(float vcc) { this->vcc_ = vcc; } + void set_rl(float rl) { this->rl_ = rl; } void set_voltage_multiplier(float multiplier) { this->voltage_multiplier_ = multiplier; } void set_samples(uint8_t samples) { this->samples_ = samples; } void set_warmup_time(uint32_t warmup) { this->warmup_time_ = warmup; } - /// Calibrate at boot: start after `delay` ms, average `samples` readings `sample_interval` ms apart. - void set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval); - void set_air_reference(float air_reference) { this->reference_ = air_reference; } + /// Base of the flash key of the stored reference, a hash of the component ID and the front end. + void set_preference_key(uint32_t key) { this->preference_key_ = key; } + /// Driven active in setup(); EN of the breakout is active low, so `inverted: true` turns that on. + void set_enable_pin(GPIOPin *pin) { this->enable_pin_ = pin; } + /// Calibration parameters: start after `delay` ms, average `samples` readings `sample_interval` ms + /// apart; `persist` keeps the result in flash, `at_boot` runs one at boot. + void set_calibration(uint32_t delay, uint32_t samples, uint32_t sample_interval, bool persist, bool at_boot); + /// Clean-air reference from the configuration, x_air (dfrobot) or R0 (datasheet). + void set_reference(float reference) { this->reference_ = reference; } + /// Deferred until the warm-up window ends. + void request_calibration(); + SUB_SENSOR(voltage) + SUB_SENSOR(ratio) + // RS only exists in the datasheet model. + SUB_SENSOR(rs) protected: /// Averaged, scaled output voltage (V); the conversions run back to back. float sample_voltage_(); - /// x = vcc - V_AO, or NAN (and a status warning) when the voltage is unusable. - float read_air_value_(); + /// Value of the active model that the reference is taken from, x = VCC - V_AO (vendor) or + /// RS (datasheet), or NAN (and a status warning) when the voltage is unusable. + float model_value_(float voltage); void publish_ratio_(float ratio); + void publish_diagnostics_(float voltage, float ratio, float rs); + /// Start a calibration after `delay` ms; every start goes through here. + void schedule_calibration_(uint32_t delay); void begin_calibration_(); void process_calibration_(); void finish_calibration_(); void schedule_calibration_retry_(); + /// True when the reference reached the storage. + bool save_reference_(); + bool load_reference_(); + bool is_vendor_model_() const { return this->conversion_ == micsmath::CONVERSION_MODEL_DFROBOT; } bool has_reference_() const { return this->reference_ > 0.0f; } voltage_sampler::VoltageSampler *source_{nullptr}; float vcc_{5.0f}; + float rl_{10.0f}; float voltage_multiplier_{1.0f}; uint32_t warmup_time_{0}; + GPIOPin *enable_pin_{nullptr}; uint32_t calibration_delay_{0}; - /// 0 when calibration is disabled. uint32_t calibration_samples_{0}; /// Spacing of the clean-air calibration samples (ms); the ~16 ms loop tick is the floor. uint32_t sample_interval_{20}; @@ -89,16 +118,28 @@ class MiCS5524Component : public PollingComponent { float calibration_min_{0.0f}; float calibration_max_{0.0f}; - /// Clean-air reference x_air in volts, 0 while there is none. + /// Clean-air reference, x_air in volts or R0 in kOhm, 0 while there is none. float reference_{0.0f}; + uint32_t preference_key_{0}; + ESPPreferenceObject reference_pref_; + // Sized by code generation; the host unit tests run none, so the list compiles out there. #ifdef MICS_5524_GAS_COUNT StaticVector gases_; #endif + micsmath::ConversionModel conversion_{micsmath::CONVERSION_MODEL_DFROBOT}; uint8_t samples_{4}; + bool persist_{false}; + bool calibrate_at_boot_{false}; + /// Set from setup() until the warm-up timeout fires. + bool warming_up_{false}; bool calibrating_{false}; + /// A calibration waits for its start time; `calibrating_` takes over from there. + bool calibration_pending_{false}; + /// The out-of-range warning has been logged; the next usable reading clears it. + bool input_fault_{false}; }; } // namespace esphome::mics_5524 diff --git a/esphome/components/mics_5524/mics_math.h b/esphome/components/mics_5524/mics_math.h index e66def345db..3e56cc09be5 100644 --- a/esphome/components/mics_5524/mics_math.h +++ b/esphome/components/mics_5524/mics_math.h @@ -1,12 +1,21 @@ #pragma once -// Pure math of the MiCS-5524 vendor model; the per-gas constants live in coefficients.py. -// x = VCC - V_ao, ratio = x / x_air (sampled in clean air), ppm = (threshold - ratio) / gain. -// No ESPHome dependencies, so the host tests cover it. +// Pure math of the two MiCS-5524 conversion models; the per-gas constants live in coefficients.py. +// dfrobot: x = VCC - V_ao, ratio = x / x_air (sampled in clean air), ppm = (threshold - ratio) / gain. +// datasheet: RS = (VCC * RL) / V_ao - RL, ratio = RS / R0 (RS in clean air), ppm = a * ratio^b. +// Every helper returns NaN for input that cannot produce a reading. No ESPHome dependencies, so the +// host tests cover it. #include +#include namespace esphome::mics_5524::micsmath { +/// Generated through CONVERSION_MODELS in __init__.py. +enum ConversionModel : uint8_t { + CONVERSION_MODEL_DFROBOT = 0, + CONVERSION_MODEL_DATASHEET = 1, +}; + /// Per-gas constants of the vendor model. struct VendorCurve { float threshold; ///< ratio at or below which the gas counts as detected @@ -15,24 +24,21 @@ struct VendorCurve { float max_ppm; ///< upper end of the measuring range }; -/// x = VCC - V_ao: 0 at VCC (a saturated sensor, never clean air). NaN at or below 0 V (a -/// disconnected pin) and above VCC, which only a wrong vcc or voltage_multiplier produces. +// ------------------------------------------------------------------ vendor model + +/// Share of VCC at or below which the vendor model reads the output as disconnected: clean air +/// sits a volt or two below VCC, so a reading near 0 V is a wiring fault, not gas-free air. +inline constexpr float VENDOR_DEAD_INPUT_FRACTION = 0.05f; + +/// x = VCC - V_ao: 0 at VCC (a saturated sensor, never clean air). NaN at or below +/// VENDOR_DEAD_INPUT_FRACTION of VCC (a disconnected pin) and above VCC, which only a wrong vcc or +/// voltage_multiplier produces. inline float air_value_from_voltage(float voltage, float vcc) { - if (!std::isfinite(voltage) || !std::isfinite(vcc) || voltage <= 0.0f || voltage > vcc) + if (!std::isfinite(voltage) || !std::isfinite(vcc) || voltage <= VENDOR_DEAD_INPUT_FRACTION * vcc || voltage > vcc) return NAN; return vcc - voltage; } -/// Largest relative spread (max - min) / mean a clean-air calibration accepts. -inline constexpr float MAX_CALIBRATION_SPREAD = 0.25f; - -/// True when the clean-air samples spread at most MAX_CALIBRATION_SPREAD of their mean. -inline bool calibration_is_stable(float min_value, float max_value, float mean) { - if (!std::isfinite(min_value) || !std::isfinite(max_value) || !std::isfinite(mean) || mean <= 0.0f) - return false; - return max_value - min_value <= MAX_CALIBRATION_SPREAD * mean; -} - /// ratio = x / x_air (NaN when either value is unusable). inline float ratio_from_air_value(float x, float x_air) { if (!std::isfinite(x) || x < 0.0f || !std::isfinite(x_air) || x_air <= 0.0f) @@ -56,4 +62,67 @@ inline float ppm_from_vendor_curve(float ratio, const VendorCurve &curve) { return ppm; } +// --------------------------------------------------------------- datasheet model + +/// Largest RS the datasheet model reads, as a multiple of RL. The datasheet puts the clean-air +/// resistance of a module below about 1.5 MOhm, 150 x the usual 10k RL, so an output voltage +/// below that of this resistance is a disconnected input. +inline constexpr float MAX_RS_OVER_RL = 200.0f; + +/// Smallest V_ao the datasheet model reads: the output of RS = MAX_RS_OVER_RL * RL. +inline float datasheet_dead_input_voltage(float vcc) { return vcc / (MAX_RS_OVER_RL + 1.0f); } + +/// RS = (VCC * RL) / V - RL in kOhm: 0 at VCC (a saturated sensor). NaN above VCC, which only a +/// wrong vcc or voltage_multiplier produces, and at or below datasheet_dead_input_voltage(). +inline float rs_from_voltage(float voltage, float vcc, float rl) { + if (!std::isfinite(voltage) || !std::isfinite(vcc) || !std::isfinite(rl) || rl <= 0.0f || voltage > vcc || + voltage <= datasheet_dead_input_voltage(vcc)) + return NAN; + return vcc * rl / voltage - rl; +} + +/// ratio = RS / R0 (NaN when either value is unusable); 0 is the saturated end. +inline float ratio_from_rs(float rs, float r0) { + if (!std::isfinite(rs) || rs < 0.0f || !std::isfinite(r0) || r0 <= 0.0f) + return NAN; + return rs / r0; +} + +/// ppm = a * ratio^b; NaN for a ratio or coefficient that cannot produce a reading. Ratio 0, the +/// saturated end, gives infinity on a falling curve, which clamp_ppm turns into max_ppm. +inline float ppm_from_power_law(float a, float b, float ratio) { + if (!std::isfinite(a) || !std::isfinite(b) || !std::isfinite(ratio) || ratio < 0.0f || a <= 0.0f) + return NAN; + + const float ppm = a * powf(ratio, b); + return std::isnan(ppm) ? NAN : ppm; +} + +/// Clamp to the configured range: below min_ppm counts as not detected and reads 0, 0 itself +/// never changes, and a reading past a finite max_ppm (infinity included) is max_ppm. NaN stays +/// NaN. +inline float clamp_ppm(float ppm, float min_ppm, float max_ppm) { + if (std::isnan(ppm)) + return NAN; + if (ppm <= 0.0f) + return 0.0f; + if (std::isfinite(min_ppm) && ppm < min_ppm) + return 0.0f; + if (std::isfinite(max_ppm) && ppm > max_ppm) + return max_ppm; + return std::isfinite(ppm) ? ppm : NAN; +} + +// ----------------------------------------------------------------- calibration + +/// Largest relative spread (max - min) / mean a clean-air calibration accepts. +inline constexpr float MAX_CALIBRATION_SPREAD = 0.25f; + +/// True when the clean-air samples spread at most MAX_CALIBRATION_SPREAD of their mean. +inline bool calibration_is_stable(float min_value, float max_value, float mean) { + if (!std::isfinite(min_value) || !std::isfinite(max_value) || !std::isfinite(mean) || mean <= 0.0f) + return false; + return max_value - min_value <= MAX_CALIBRATION_SPREAD * mean; +} + } // namespace esphome::mics_5524::micsmath diff --git a/esphome/components/mics_5524/sensor.py b/esphome/components/mics_5524/sensor.py index f2b05c8da2e..7b80572a115 100644 --- a/esphome/components/mics_5524/sensor.py +++ b/esphome/components/mics_5524/sensor.py @@ -2,35 +2,85 @@ import logging +from esphome import automation, pins import esphome.codegen as cg from esphome.components import sensor, voltage_sampler +from esphome.components.adc import ATTENUATION_MODES +from esphome.components.esp32 import get_esp32_variant import esphome.config_validation as cv from esphome.const import ( + CONF_ATTENUATION, CONF_CALIBRATION, CONF_DELAY, + CONF_ENABLE_PIN, CONF_ID, + CONF_INTERNAL, + CONF_INVERTED, + CONF_NAME, + CONF_PIN, + CONF_RAW, + CONF_UPDATE_INTERVAL, CONF_VOLTAGE, + CONF_VOLTAGE_DIVIDER, CONF_WARMUP_TIME, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, + UNIT_VOLT, ) +from esphome.core import CORE +from esphome.helpers import fnv1_hash from esphome.types import ConfigType from . import ( + ADC_LIMIT_MARGIN_V, + ADC_LIMITS_V, + CONF_ADC_ATTENUATION, + CONF_ADC_INPUT_MAX, + CONF_ADC_PIN_MAX, CONF_AIR_REFERENCE, + CONF_COEFFICIENT_A, + CONF_COEFFICIENT_B, + CONF_CONVERSION, CONF_MAX_PPM, + CONF_MIN_PPM, + CONF_PERSIST, + CONF_R0, + CONF_R1, + CONF_R2, + CONF_RATIO_SENSOR, + CONF_RL, + CONF_RS_SENSOR, CONF_SAMPLE_COUNT, CONF_SAMPLE_INTERVAL, CONF_VCC, CONF_VOLTAGE_MULTIPLIER, + CONF_VOLTAGE_SENSOR, + CONVERSION_MODELS, MiCS5524Component, MiCS5524GasSensor, + esp32_attenuation_input_max, +) +from .coefficients import ( + CONVERSION_DATASHEET, + CONVERSION_DFROBOT, + GASES, + ResolvedGas, + resolve_gas, ) -from .coefficients import GASES _LOGGER = logging.getLogger(__name__) -AUTO_LOAD = ["sensor", "voltage_sampler"] + +def _auto_load(config: ConfigType | None) -> list[str]: + """Load adc only for a pin: source; it has no host build, so the unit tests cannot link it.""" + if config is not None and CONF_PIN in config: + return ["sensor", "voltage_sampler", "adc"] + return ["sensor", "voltage_sampler"] + + +AUTO_LOAD = _auto_load # The main loop runs every ~16 ms, so a shorter spacing cannot sample more often. MIN_SAMPLE_INTERVAL_MS = 16 @@ -40,10 +90,135 @@ MAX_SAMPLE_COUNT = 16 _request_gas_slot = cg.slot_counter("MICS_5524_GAS_COUNT") +# The generated adc entry lives in the validated config so the ID pass sees its ID. +_KEY_GENERATED_ADC = "_generated_adc" + +DIVIDER_SCHEMA = cv.Schema( + { + cv.Required(CONF_R1): cv.positive_not_null_float, + cv.Required(CONF_R2): cv.positive_not_null_float, + } +) + + +def _build_internal_adc(config: ConfigType) -> ConfigType: + """Validate the hidden ``adc`` entry of a ``pin:`` configuration.""" + from esphome.components.adc import sensor as adc_sensor + + adc_id = f"{config[CONF_ID].id}_adc" + adc_config: ConfigType = { + CONF_ID: adc_id, + CONF_PIN: config[CONF_PIN], + CONF_NAME: f"{adc_id} voltage", + CONF_INTERNAL: True, + CONF_RAW: False, + # sample_count of the component is the only averaging layer. + adc_sensor.CONF_SAMPLES: 1, + adc_sensor.CONF_SAMPLING_MODE: "avg", + CONF_UPDATE_INTERVAL: "never", + } + if (attenuation := config.get(CONF_ADC_ATTENUATION)) is not None: + adc_config[CONF_ATTENUATION] = attenuation + elif CORE.is_esp32: + # The 0 dB default (~1 V) would clip the 3.3 V the divider guard allows. + adc_config[CONF_ATTENUATION] = "12db" + + return adc_sensor.CONFIG_SCHEMA(adc_config) + + +def _voltage_multiplier(config: ConfigType) -> float: + """Output volts per sampler volt: from voltage_divider: when set, else voltage_multiplier:.""" + if (divider := config.get(CONF_VOLTAGE_DIVIDER)) is not None: + return (divider[CONF_R1] + divider[CONF_R2]) / divider[CONF_R2] + return float(config.get(CONF_VOLTAGE_MULTIPLIER, 1.0)) + + +def _preference_key(config: ConfigType) -> int: + """Flash key of the stored reference; a new vcc, scaling or rl must not load an old value.""" + parts = [ + config[CONF_ID].id, + f"{config[CONF_VCC]:g}", + f"{_voltage_multiplier(config):g}", + ] + if (rl := config.get(CONF_RL)) is not None: + parts.append(f"{rl:g}") + return fnv1_hash(":".join(parts)) + + +def _validate_adc_keys(config: ConfigType) -> ConfigType: + """Reject the ADC keys where they would be accepted and ignored.""" + if CONF_VOLTAGE_DIVIDER not in config and CONF_PIN not in config: + for key in (CONF_ADC_INPUT_MAX, CONF_ADC_PIN_MAX): + if key in config: + raise cv.Invalid( + f"'{key}:' only checks a 'pin:' source or a 'voltage_divider:', " + "so it has no effect here", + path=[key], + ) + if CONF_ADC_ATTENUATION in config and CONF_PIN not in config: + raise cv.Invalid( + "'adc_attenuation:' only applies to the ADC of a 'pin:' source - set " + "'attenuation:' on the adc sensor behind 'voltage:' instead", + path=[CONF_ADC_ATTENUATION], + ) + return config + + +def _validate_adc_range(config: ConfigType) -> ConfigType: + """Reject wiring that puts the supply, the largest possible reading, past the sampler limits. + + A ``pin:`` source is always checked; a ``voltage:`` source only through its ``voltage_divider:``, + since the scaling of an external sampler is not known here. + """ + divider = config.get(CONF_VOLTAGE_DIVIDER) + if divider is None and CONF_PIN not in config: + return config + + supply = config[CONF_VCC] + pin_voltage = supply / _voltage_multiplier(config) + if (limits := ADC_LIMITS_V.get(CORE.target_platform)) is None: + if CONF_ADC_INPUT_MAX not in config or CONF_ADC_PIN_MAX not in config: + raise cv.Invalid( + f"no built-in ADC limits for {CORE.target_platform}: declare " + "'adc_input_max:' and 'adc_pin_max:' so the wiring can be checked" + ) + limits = (config[CONF_ADC_INPUT_MAX], config[CONF_ADC_PIN_MAX]) + default_input_max, default_pin_max = limits + if CONF_PIN in config and CORE.is_esp32: + # The attenuation picks the measuring range of the internal ADC + default_input_max = esp32_attenuation_input_max( + get_esp32_variant(), config.get(CONF_ADC_ATTENUATION, "12db") + ) + pin_max = config.get(CONF_ADC_PIN_MAX, default_pin_max) + input_max = config.get(CONF_ADC_INPUT_MAX, default_input_max) + if divider is not None: + wiring = ( + f"the divider brings {supply:g} V down to {pin_voltage:.4g} V on the pin" + ) + remedy = "raise r1/r2" + elif CONF_VOLTAGE_MULTIPLIER in config: + wiring = f"{supply:g} V scaled by 1/{config[CONF_VOLTAGE_MULTIPLIER]:g} is {pin_voltage:.4g} V on the pin" + remedy = "raise 'voltage_multiplier:' to match the wiring" + else: + wiring = f"the {supply:g} V supply reaches the pin unscaled" + remedy = ( + "add a 'voltage_divider:' (or 'voltage_multiplier:' for one already wired)" + ) + if pin_voltage > pin_max + ADC_LIMIT_MARGIN_V: + raise cv.Invalid( + f"{wiring}, above the {pin_max:g} V the ADC tolerates - {remedy}, or " + "declare 'adc_pin_max:' for a sampler that takes more" + ) + if pin_voltage > input_max + ADC_LIMIT_MARGIN_V: + raise cv.Invalid( + f"{wiring}, above the {input_max:g} V measuring range of the sampler - {remedy}, " + "or declare 'adc_input_max:' for a sampler with a wider range" + ) + return config + def _gas_schema(gas_key: str) -> cv.Schema: """Schema of one gas sub-sensor.""" - curve = GASES[gas_key] return sensor.sensor_schema( MiCS5524GasSensor, unit_of_measurement=UNIT_PARTS_PER_MILLION, @@ -52,10 +227,13 @@ def _gas_schema(gas_key: str) -> cv.Schema: icon="mdi:molecule", ).extend( { - # A ceiling above the vendor range could never be reached. - cv.Optional(CONF_MAX_PPM, default=curve.max_ppm): cv.float_range( - min=0, min_included=False, max=curve.max_ppm - ) + # No default: each conversion model has its own ceiling, resolved in resolve_gas. + cv.Optional(CONF_MAX_PPM): cv.positive_not_null_float, + cv.Optional(CONF_MIN_PPM): cv.positive_float, + cv.Inclusive( + CONF_COEFFICIENT_A, "coefficients" + ): cv.positive_not_null_float, + cv.Inclusive(CONF_COEFFICIENT_B, "coefficients"): cv.float_, } ) @@ -70,13 +248,35 @@ CALIBRATION_SCHEMA = cv.Schema( cv.positive_time_period_milliseconds, cv.Range(min=cv.TimePeriod(milliseconds=MIN_SAMPLE_INTERVAL_MS)), ), + cv.Optional(CONF_PERSIST, default=True): cv.boolean, } ) +def _resolve_gas(gas_key: str, config: ConfigType) -> ResolvedGas: + """Resolve one configured gas against the conversion model of its hub.""" + gas = config[gas_key] + return resolve_gas( + gas_key, + config[CONF_CONVERSION], + a=gas.get(CONF_COEFFICIENT_A), + b=gas.get(CONF_COEFFICIENT_B), + min_ppm=gas.get(CONF_MIN_PPM), + max_ppm=gas.get(CONF_MAX_PPM), + ) + + def _validate_config(config: ConfigType) -> ConfigType: - """Reject an air reference at or above the supply; warn when there is none.""" + """Check the reference keys, the conversion and every gas against each other.""" + conversion = config[CONF_CONVERSION] + vendor_model = conversion == CONVERSION_DFROBOT + if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: + if not vendor_model: + raise cv.Invalid( + f"'air_reference:' belongs to 'conversion: {CONVERSION_DFROBOT}', use " + f"'r0:' with 'conversion: {CONVERSION_DATASHEET}'" + ) # The reference is vcc - V_AO in clean air, so a larger one reads 0 ppm forever. if air_reference >= config[CONF_VCC]: raise cv.Invalid( @@ -84,11 +284,73 @@ def _validate_config(config: ConfigType) -> ConfigType: f"({config[CONF_VCC]:g} V): it is how far the analog output sits " "below the supply in clean air" ) - elif CONF_CALIBRATION not in config: + elif CONF_CALIBRATION not in config and CONF_R0 not in config: _LOGGER.warning( - "no air reference configured - set 'air_reference:' or 'calibration:', " - "the PPM values stay unknown until then" + "no clean-air reference configured - set '%s' or 'calibration:', or every " + "PPM value stays unknown until the sensor has been calibrated in clean air", + CONF_AIR_REFERENCE if vendor_model else CONF_R0, ) + + if vendor_model: + for key, reason in ( + (CONF_R0, f"use 'air_reference:' with 'conversion: {CONVERSION_DFROBOT}'"), + ( + CONF_RL, + ( + f"'conversion: {CONVERSION_DFROBOT}' derives everything from the " + "clean-air reference" + ), + ), + ( + CONF_RS_SENSOR, + "the vendor model never computes RS, so the sensor would stay unknown", + ), + ): + if key in config: + raise cv.Invalid( + f"'{key}:' belongs to 'conversion: {CONVERSION_DATASHEET}' - {reason}" + ) + + for gas_key, definition in GASES.items(): + if gas_key not in config: + continue + try: + resolved = _resolve_gas(gas_key, config) + except ValueError as err: + raise cv.Invalid(str(err)) from err + if resolved.max_ppm <= resolved.min_ppm: + raise cv.Invalid( + f"max_ppm ({resolved.max_ppm:g}) must be greater than min_ppm " + f"({resolved.min_ppm:g})" + ) + # Only the vendor model has a documented range to check against. + if vendor_model and resolved.max_ppm > ( + vendor_max_ppm := definition.vendor.max_ppm + ): + raise cv.Invalid( + f"max_ppm ({resolved.max_ppm:g}) is above the {resolved.label} measuring " + f"range of {vendor_max_ppm:g} ppm, so the conversion can never reach it" + ) + return config + + +def _validate_enable_pin(config: ConfigType) -> ConfigType: + """Warn about an EN pin without inverted: true, which keeps the active-low module asleep.""" + if (enable := config.get(CONF_ENABLE_PIN)) is not None and not enable.get( + CONF_INVERTED, False + ): + _LOGGER.warning( + "'enable_pin:' is driven active high, but the EN input of the DFRobot " + "MiCS-5524 breakout is active low: without 'inverted: true' the module stays " + "asleep and every reading stays at 0 V" + ) + return config + + +def _build_pin_source(config: ConfigType) -> ConfigType: + """Build the hidden adc entry inside the validated config so the ID pass sees its ID.""" + if CONF_PIN in config: + config[_KEY_GENERATED_ADC] = _build_internal_adc(config) return config @@ -96,12 +358,24 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(MiCS5524Component), - cv.Required(CONF_VOLTAGE): cv.use_id(voltage_sampler.VoltageSampler), - cv.Optional( - CONF_VOLTAGE_MULTIPLIER, default=1.0 - ): cv.positive_not_null_float, + cv.Optional(CONF_VOLTAGE): cv.use_id(voltage_sampler.VoltageSampler), + # Validated by the hidden adc entry built below. + cv.Optional(CONF_PIN): cv.valid, + cv.Optional(CONF_VOLTAGE_DIVIDER): DIVIDER_SCHEMA, + cv.Optional(CONF_ADC_INPUT_MAX): cv.positive_not_null_float, + cv.Optional(CONF_ADC_PIN_MAX): cv.positive_not_null_float, + cv.Optional(CONF_ADC_ATTENUATION): cv.one_of( + *ATTENUATION_MODES, lower=True + ), + cv.Optional(CONF_CONVERSION, default=CONVERSION_DFROBOT): cv.enum( + CONVERSION_MODELS, lower=True + ), + cv.Optional(CONF_VOLTAGE_MULTIPLIER): cv.positive_not_null_float, cv.Optional(CONF_VCC, default=5.0): cv.positive_not_null_float, + cv.Optional(CONF_RL): cv.positive_not_null_float, cv.Optional(CONF_AIR_REFERENCE): cv.positive_not_null_float, + cv.Optional(CONF_R0): cv.positive_not_null_float, + cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_SAMPLE_COUNT, default=4): cv.int_range( min=1, max=MAX_SAMPLE_COUNT ), @@ -109,45 +383,111 @@ CONFIG_SCHEMA = cv.All( CONF_WARMUP_TIME, default="3min" ): cv.positive_time_period_milliseconds, cv.Optional(CONF_CALIBRATION): CALIBRATION_SCHEMA, + cv.Optional(CONF_VOLTAGE_SENSOR): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RATIO_SENSOR): sensor.sensor_schema( + accuracy_decimals=4, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RS_SENSOR): sensor.sensor_schema( + unit_of_measurement="kΩ", + accuracy_decimals=2, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), **{cv.Optional(gas_key): _gas_schema(gas_key) for gas_key in GASES}, } ).extend(cv.polling_component_schema("30s")), + cv.has_exactly_one_key(CONF_VOLTAGE, CONF_PIN), + cv.has_at_most_one_key(CONF_VOLTAGE_DIVIDER, CONF_VOLTAGE_MULTIPLIER), cv.has_at_least_one_key(*GASES), - cv.has_at_most_one_key(CONF_AIR_REFERENCE, CONF_CALIBRATION), + cv.has_at_most_one_key(CONF_AIR_REFERENCE, CONF_R0, CONF_CALIBRATION), + _validate_adc_keys, + _validate_adc_range, _validate_config, + _validate_enable_pin, + _build_pin_source, ) async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], await cg.get_variable(config[CONF_VOLTAGE])) + source = ( + await cg.get_variable(config[CONF_VOLTAGE]) + if CONF_VOLTAGE in config + else await _create_internal_adc(config) + ) + var = cg.new_Pvariable(config[CONF_ID], source) await cg.register_component(var, config) + cg.add(var.set_conversion(config[CONF_CONVERSION])) + if config[CONF_CONVERSION] == CONVERSION_DATASHEET: + # Only a build with a datasheet model links the power law and its libm + cg.add_define("USE_MICS_5524_DATASHEET") cg.add(var.set_vcc(config[CONF_VCC])) - cg.add(var.set_voltage_multiplier(config[CONF_VOLTAGE_MULTIPLIER])) + if (rl := config.get(CONF_RL)) is not None: + cg.add(var.set_rl(rl)) + cg.add(var.set_voltage_multiplier(_voltage_multiplier(config))) cg.add(var.set_samples(config[CONF_SAMPLE_COUNT])) cg.add(var.set_warmup_time(config[CONF_WARMUP_TIME])) + cg.add(var.set_preference_key(_preference_key(config))) - if (air_reference := config.get(CONF_AIR_REFERENCE)) is not None: - cg.add(var.set_air_reference(air_reference)) - elif (calibration := config.get(CONF_CALIBRATION)) is not None: - cg.add( - var.set_calibration( - calibration[CONF_DELAY], - calibration[CONF_SAMPLE_COUNT], - calibration[CONF_SAMPLE_INTERVAL], - ) + if (enable := config.get(CONF_ENABLE_PIN)) is not None: + cg.add(var.set_enable_pin(await cg.gpio_pin_expression(enable))) + if (reference := config.get(CONF_AIR_REFERENCE, config.get(CONF_R0))) is not None: + cg.add(var.set_reference(reference)) + # A request on a hub without `calibration:` uses the schema defaults, never persisted. + calibration = config.get(CONF_CALIBRATION) + at_boot = calibration is not None + if calibration is None: + calibration = CALIBRATION_SCHEMA({}) + cg.add( + var.set_calibration( + calibration[CONF_DELAY], + calibration[CONF_SAMPLE_COUNT], + calibration[CONF_SAMPLE_INTERVAL], + at_boot and calibration[CONF_PERSIST], + at_boot, ) + ) sensors = sensor.sub_sensors(config) - for gas_key, curve in GASES.items(): - if (gas_config := config.get(gas_key)) is None: + await sensors(CONF_VOLTAGE_SENSOR, var.set_voltage_sensor) + await sensors(CONF_RATIO_SENSOR, var.set_ratio_sensor) + await sensors(CONF_RS_SENSOR, var.set_rs_sensor) + + for gas_key in GASES: + if gas_key not in config: continue + resolved = _resolve_gas(gas_key, config) _request_gas_slot(str(var)) await sensors( gas_key, var.register_gas_sensor, - curve.threshold, - curve.gain, - curve.min_ppm, - gas_config[CONF_MAX_PPM], + resolved.p0, + resolved.p1, + resolved.min_ppm, + resolved.max_ppm, ) + + +async def _create_internal_adc(config: ConfigType) -> cg.MockObj: + """Create the ``adc`` sensor of a ``pin:`` configuration and return it.""" + from esphome.components.adc import sensor as adc_sensor + + adc_config = config[_KEY_GENERATED_ADC] + await adc_sensor.to_code(adc_config) + return await cg.get_variable(adc_config[CONF_ID]) + + +CALIBRATION_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.GenerateID(): cv.use_id(MiCS5524Component)} +) + +automation.register_apply_action( + "mics_5524.request_calibration", + CALIBRATION_ACTION_SCHEMA, + automation.ApplyCall("request_calibration()"), +) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 756485e0157..97677334828 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -167,6 +167,7 @@ #define LD2450_POLYGON_ZONE_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define MICS_5524_GAS_COUNT 5 +#define USE_MICS_5524_DATASHEET #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 #define NFC_TAG_LISTENER_COUNT 1 diff --git a/tests/component_tests/mics_5524/config/mics_5524.yaml b/tests/component_tests/mics_5524/config/mics_5524.yaml index 7538fb973e1..3a5f2459dcf 100644 --- a/tests/component_tests/mics_5524/config/mics_5524.yaml +++ b/tests/component_tests/mics_5524/config/mics_5524.yaml @@ -35,3 +35,29 @@ sensor: hydrogen: id: mics_5524_h2 name: MiCS-5524 H2 + + # The datasheet model on the ADC this component owns: the 10k/20k divider puts 3.33 V on + # the pin, so it needs adc_input_max: 3.34 and the 12 dB attenuation it gets by default. + - platform: mics_5524 + id: mics_5524_pin + pin: GPIO32 + voltage_divider: + r1: 10000 + r2: 20000 + adc_input_max: 3.34 + conversion: datasheet + r0: 10.0 + # Diagnostics of this reading, declared by the hub itself. + ratio_sensor: + id: mics_5524_ratio + name: MiCS-5524 ratio + rs_sensor: + id: mics_5524_rs + name: MiCS-5524 RS + voltage_sensor: + id: mics_5524_volts + name: MiCS-5524 volts + carbon_monoxide: + id: mics_5524_co_ds + name: MiCS-5524 CO (datasheet) + max_ppm: 2000.0 diff --git a/tests/component_tests/mics_5524/test_mics_5524.py b/tests/component_tests/mics_5524/test_mics_5524.py index 53dc2f49708..1ab5d9ae1a3 100644 --- a/tests/component_tests/mics_5524/test_mics_5524.py +++ b/tests/component_tests/mics_5524/test_mics_5524.py @@ -2,19 +2,49 @@ from collections.abc import Callable from pathlib import Path +import re import pytest from esphome import config_validation as cv -from esphome.components.mics_5524.coefficients import GASES, VendorCurve -from esphome.components.mics_5524.sensor import CONFIG_SCHEMA +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mics_5524.coefficients import ( + GASES, + DatasheetCurve, + GasDefinition, + VendorCurve, + datasheet_gases, +) +from esphome.components.mics_5524.sensor import CONFIG_SCHEMA, _voltage_multiplier from esphome.const import ( CONF_AMMONIA, CONF_CARBON_MONOXIDE, CONF_ETHANOL, CONF_HYDROGEN, CONF_METHANE, + PlatformFramework, ) +from esphome.helpers import fnv1_hash +from tests.component_tests.types import SetCoreConfigCallable + + +def _gas_arguments(main_cpp: str, variable: str) -> list[str]: + """Arguments of the constructor call that creates one gas sub-sensor.""" + match = re.search( + rf"new\({variable}\) mics_5524::MiCS5524GasSensor\((.*?)\);", + main_cpp, + ) + assert match is not None, f"{variable} was not constructed" + return [argument.strip() for argument in match.group(1).split(",")] + + +def _gas_constants(arguments: list[str]) -> list[float]: + """The numeric arguments of a constructor call, read as floats. + + Comparing numbers rather than their text keeps the test about the values and not + about how code generation happens to format them. + """ + return [float(argument.removesuffix("f")) for argument in arguments] def test_voltage_source_is_a_constructor_argument( @@ -37,21 +67,19 @@ def test_every_gas_becomes_a_sub_sensor_of_its_hub( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The curve is passed to the constructor and the sub-sensor to its own hub.""" + """The constants are passed to the constructor and the sub-sensor to its own hub.""" main_cpp = generate_main(component_config_path("mics_5524.yaml")) - # Threshold, gain, minimum and maximum ppm of the gas. - assert ( - "new(mics_5524_co) mics_5524::MiCS5524GasSensor(0.425f, " - "0.000405f, 1.0f, 500.0f);" in main_cpp - ) + # The vendor threshold and gain and the measuring range; the model lives on the hub. + co = _gas_arguments(main_cpp, "mics_5524_co") + assert _gas_constants(co) == [0.425, 0.000405, 1.0, 500.0] assert "mics_5524_boot->register_gas_sensor(mics_5524_co);" in main_cpp + # A gas without `max_ppm:` keeps the measuring range of the vendor. - assert ( - "new(mics_5524_ch4) mics_5524::MiCS5524GasSensor(0.786f, " - "2.3e-05f, 1000.0f, 25000.0f);" in main_cpp - ) + ch4 = _gas_arguments(main_cpp, "mics_5524_ch4") + assert _gas_constants(ch4) == [0.786, 0.000023, 1000.0, 25000.0] assert "mics_5524_boot->register_gas_sensor(mics_5524_ch4);" in main_cpp + # The gas belongs to the sensor it is configured under, not to the first one. assert "mics_5524_fixed->register_gas_sensor(mics_5524_h2);" in main_cpp assert "mics_5524_boot->register_gas_sensor(mics_5524_h2);" not in main_cpp @@ -64,106 +92,184 @@ def test_shared_options_reach_the_hub( """The analog front end is configured once per physical sensor.""" main_cpp = generate_main(component_config_path("mics_5524.yaml")) + assert ( + "mics_5524_boot->set_conversion(mics_5524::micsmath::CONVERSION_MODEL_DFROBOT);" + in main_cpp + ) assert "mics_5524_boot->set_vcc(5.0f);" in main_cpp + # `rl:` is not configured, so the C++ default stands. + assert "mics_5524_boot->set_rl(" not in main_cpp assert "mics_5524_boot->set_voltage_multiplier(1.5f);" in main_cpp assert "mics_5524_boot->set_samples(3);" in main_cpp assert "mics_5524_boot->set_warmup_time(180000);" in main_cpp - assert "mics_5524_boot->set_calibration(5000, 3, 20);" in main_cpp - assert "mics_5524_fixed->set_air_reference(3.6f);" in main_cpp + # The stored reference is keyed by the component ID and the analog front end, so a + # new vcc, scaling or rl never loads a value captured with the old one. + assert ( + f"mics_5524_boot->set_preference_key({fnv1_hash('mics_5524_boot:5:1.5')}UL);" + in main_cpp + ) + assert ( + f"mics_5524_pin->set_preference_key({fnv1_hash('mics_5524_pin:5:1.5')}UL);" + in main_cpp + ) + assert "mics_5524_boot->set_calibration(5000, 3, 20, true, true);" in main_cpp + assert "mics_5524_fixed->set_reference(3.6f);" in main_cpp # `air_reference:` and `calibration:` exclude each other, so the sensor with the - # hard-coded reference is never calibrated at boot. - assert "mics_5524_fixed->set_calibration(" not in main_cpp + # hard-coded reference only gets the defaults for a requested calibration. + assert "mics_5524_fixed->set_calibration(60000, 10, 20, false, false);" in main_cpp + + +def test_datasheet_conversion_uses_the_shipped_coefficients( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """One conversion model per sensor, and the coefficients of CO are the shipped ones.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + # a = 1.0, b = -1.2, the 1 ppm floor of the data sheet and the configured 2000 ppm ceiling. + co = _gas_arguments(main_cpp, "mics_5524_co_ds") + assert _gas_constants(co) == [1.0, -1.2, 1.0, 2000.0] + assert "mics_5524_pin->register_gas_sensor(mics_5524_co_ds);" in main_cpp + assert ( + "mics_5524_pin->set_conversion(mics_5524::micsmath::CONVERSION_MODEL_DATASHEET);" + in main_cpp + ) + assert "mics_5524_pin->set_reference(10.0f);" in main_cpp + + +def test_diagnostics_reach_the_hub( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The ratio, RS and voltage of every reading can be published to their own sensor.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert "mics_5524_pin->set_ratio_sensor(mics_5524_ratio);" in main_cpp + assert "mics_5524_pin->set_rs_sensor(mics_5524_rs);" in main_cpp + assert "mics_5524_pin->set_voltage_sensor(mics_5524_volts);" in main_cpp + + +def test_pin_creates_an_internal_adc( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A `pin:` configuration owns its ADC, and the divider is part of the reading.""" + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + + assert "new(mics_5524_pin_adc) adc::ADCSensor();" in main_cpp + # Without `adc_attenuation:` an ESP32 gets 12 dB, since the 0 dB default clips at about 1 V. + assert ( + "mics_5524_pin_adc->set_attenuation(adc::ADC_ATTEN_DB_12_COMPAT);" in main_cpp + ) + assert ( + "new(mics_5524_pin) mics_5524::MiCS5524Component(mics_5524_pin_adc);" + in main_cpp + ) + # 10k/20k under the 5 V of the breakout: 3.33 V on the pin, x1.5 in the reading. + assert "mics_5524_pin->set_voltage_multiplier(1.5f);" in main_cpp + + +def _hub(air_reference: float | None = 3.6, **overrides: object) -> dict: + """A minimal valid hub configuration with one hydrogen sub-sensor.""" + config: dict = { + "id": "mics_5524", + "voltage": "mics_5524_adc", + "vcc": 5.0, + "hydrogen": {"name": "MiCS-5524 H2"}, + } + if air_reference is not None: + config["air_reference"] = air_reference + for key, value in overrides.items(): + if value is None: + config.pop(key, None) + else: + config[key] = value + return config def test_air_reference_and_calibration_exclude_each_other() -> None: """Two references for one sensor would silently pick one, so it is an error.""" with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): - CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, - "air_reference": 3.6, - "calibration": {"delay": "5s"}, - } - ) + CONFIG_SCHEMA(_hub(calibration={"delay": "5s"})) @pytest.mark.parametrize("air_reference", [5.0, 6.0]) def test_air_reference_at_or_above_vcc_is_rejected(air_reference: float) -> None: - """A reference at or above the supply would report every gas as 0 ppm forever.""" with pytest.raises(cv.Invalid, match="must be below vcc"): - CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "vcc": 5.0, - "hydrogen": {"name": "MiCS-5524 H2 (reference above vcc)"}, - "air_reference": air_reference, - } - ) + CONFIG_SCHEMA(_hub(air_reference=air_reference)) def test_air_reference_below_vcc_is_accepted() -> None: - config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "vcc": 5.0, - "hydrogen": {"name": "MiCS-5524 H2 (reference)"}, - "air_reference": 3.6, - } - ) - - assert config["air_reference"] == 3.6 + assert CONFIG_SCHEMA(_hub())["air_reference"] == 3.6 def test_at_least_one_gas_is_required() -> None: """A sensor without a gas has nothing to publish.""" with pytest.raises(cv.Invalid, match="Must contain at least one of"): - CONFIG_SCHEMA({"id": "mics_5524", "voltage": "mics_5524_adc", "vcc": 5.0}) - - -def test_max_ppm_above_the_vendor_range_is_rejected() -> None: - """A ceiling the conversion can never reach is a configuration error.""" - with pytest.raises(cv.Invalid, match="value must be at most 1000.0"): CONFIG_SCHEMA( { "id": "mics_5524", "voltage": "mics_5524_adc", - "carbon_monoxide": { - "name": "MiCS-5524 CO (rejected)", - "max_ppm": 2000.0, - }, + "vcc": 5.0, "air_reference": 3.6, } ) +def test_the_sampler_is_given_or_owned() -> None: + """One source only: an existing sampler, or a pin the component reads itself.""" + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA( + {"id": "mics_5524", "vcc": 5.0, "hydrogen": {}, "air_reference": 3.6} + ) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_hub(pin=34)) + + +def test_max_ppm_above_the_vendor_range_is_rejected() -> None: + """A ceiling the conversion can never reach is a configuration error.""" + with pytest.raises(cv.Invalid, match="above the CO measuring range"): + CONFIG_SCHEMA( + _hub(carbon_monoxide={"name": "MiCS-5524 CO (rejected)", "max_ppm": 2000.0}) + ) + + def test_max_ppm_at_the_vendor_range_is_accepted() -> None: config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "carbon_monoxide": {"name": "MiCS-5524 CO (accepted)", "max_ppm": 1000.0}, - "air_reference": 3.6, - } + _hub(carbon_monoxide={"name": "MiCS-5524 CO (accepted)", "max_ppm": 1000.0}) ) assert config["carbon_monoxide"]["max_ppm"] == 1000.0 -def test_max_ppm_defaults_to_the_vendor_range_of_the_gas() -> None: - config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "carbon_monoxide": {"name": "MiCS-5524 CO (default)"}, - "air_reference": 3.6, - } - ) +def test_max_ppm_defaults_to_the_vendor_range_of_the_gas( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """An omitted ceiling still reports the vendor range: it is resolved per model.""" + from esphome.components.mics_5524.coefficients import resolve_gas - assert config["carbon_monoxide"]["max_ppm"] == 1000.0 + resolved = resolve_gas("carbon_monoxide", "dfrobot", max_ppm=None) + assert resolved.max_ppm == 1000.0 + + # End to end: CH4 of the boot sensor has no max_ppm: and keeps 25000 ppm. + main_cpp = generate_main(component_config_path("mics_5524.yaml")) + ch4 = _gas_arguments(main_cpp, "mics_5524_ch4") + assert _gas_constants(ch4)[-1] == 25000.0 + + +def test_max_ppm_must_stay_above_min_ppm() -> None: + with pytest.raises(cv.Invalid, match="must be greater than min_ppm"): + CONFIG_SCHEMA( + _hub( + carbon_monoxide={ + "name": "MiCS-5524 CO (narrow)", + "min_ppm": 100.0, + "max_ppm": 50.0, + } + ) + ) def test_calibration_cannot_sample_faster_than_the_loop() -> None: @@ -173,6 +279,7 @@ def test_calibration_cannot_sample_faster_than_the_loop() -> None: { "id": "mics_5524", "voltage": "mics_5524_adc", + "vcc": 5.0, "hydrogen": {"name": "MiCS-5524 H2 (tick)"}, "calibration": {"sample_interval": "10ms"}, } @@ -180,26 +287,348 @@ def test_calibration_cannot_sample_faster_than_the_loop() -> None: def test_voltage_multiplier_defaults_to_one() -> None: + config = CONFIG_SCHEMA(_hub()) + + assert _voltage_multiplier(config) == 1.0 + + +def test_voltage_divider_derives_the_multiplier( + set_core_config: SetCoreConfigCallable, +) -> None: + """The divider the user wired is turned into the multiplier of every reading.""" + set_core_config(PlatformFramework.ESP32_IDF) config = CONFIG_SCHEMA( - { - "id": "mics_5524", - "voltage": "mics_5524_adc", - "hydrogen": {"name": "MiCS-5524 H2 (default)"}, - "air_reference": 3.6, - } + _hub(voltage_divider={"r1": 10000.0, "r2": 20000.0}, adc_input_max=3.34) ) - assert config["voltage_multiplier"] == 1.0 + assert _voltage_multiplier(config) == 1.5 + + +def test_voltage_divider_rejects_an_explicit_multiplier( + set_core_config: SetCoreConfigCallable, +) -> None: + """The divider already says what the multiplier is, so the two would contradict.""" + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): + CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + voltage_multiplier=1.5, + adc_input_max=3.34, + ) + ) + + +def test_voltage_divider_above_the_adc_limit_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """A 10k/20k divider on a 5 V sensor puts 3.33 V on a classic ESP32 ADC.""" + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="above the 3.3 V measuring range"): + CONFIG_SCHEMA(_hub(voltage_divider={"r1": 10000.0, "r2": 20000.0})) + + +def test_pin_source_must_be_scaled_into_the_adc_range( + set_core_config: SetCoreConfigCallable, +) -> None: + """A 5 V sensor straight on an ESP32 pin clips; a divider or multiplier that fits passes.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + with pytest.raises(cv.Invalid, match="5 V supply reaches the pin unscaled"): + CONFIG_SCHEMA(_hub(voltage=None, pin=34)) + # Each hub needs its own entity names + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_multiplier=1.5, + adc_input_max=3.34, + hydrogen={"name": "H2 behind a multiplier"}, + ) + ) + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + hydrogen={"name": "H2 behind a divider"}, + ) + ) + + +def test_pin_source_range_follows_the_attenuation( + set_core_config: SetCoreConfigCallable, +) -> None: + """At 6 dB a classic ESP32 measures up to 1.75 V, so 3.33 V on the pin is rejected.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + with pytest.raises(cv.Invalid, match="above the 1.75 V measuring range"): + CONFIG_SCHEMA( + _hub(voltage=None, pin=34, voltage_multiplier=1.5, adc_attenuation="6db") + ) + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_multiplier=3.0, + adc_attenuation="6db", + hydrogen={"name": "H2 at 6 dB"}, + ) + ) + # auto picks the widest range, 2.45 V on a classic ESP32 + with pytest.raises(cv.Invalid, match="above the 2.45 V measuring range"): + CONFIG_SCHEMA( + _hub( + voltage=None, + pin=34, + voltage_multiplier=1.5, + adc_attenuation="auto", + hydrogen={"name": "H2 auto"}, + ) + ) + + +def test_min_ppm_zero_disables_the_floor() -> None: + config = CONFIG_SCHEMA(_hub(hydrogen={"name": "MiCS-5524 H2", "min_ppm": 0.0})) + assert config["hydrogen"]["min_ppm"] == 0.0 + + +def test_voltage_divider_limits_follow_the_esp8266_adc( + set_core_config: SetCoreConfigCallable, +) -> None: + """A bare ESP8266 A0 reads 0 - 1 V, so the ESP32 limits must not apply there.""" + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="above the 1 V"): + CONFIG_SCHEMA(_hub(voltage_divider={"r1": 10000.0, "r2": 20000.0})) + config = CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + adc_pin_max=3.6, + hydrogen={"name": "MiCS-5524 H2 (declared limits)"}, + ) + ) + assert config["voltage_divider"]["r1"] == 10000.0 + + +def test_voltage_divider_below_the_declared_limit_is_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + """A sampler with a wider range declares it, and the same divider then passes.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + adc_pin_max=3.4, + ) + ) + + assert config["voltage_divider"]["r1"] == 10000.0 + + +def test_voltage_divider_on_an_unknown_platform_needs_declared_limits( + set_core_config: SetCoreConfigCallable, +) -> None: + """No built-in ADC limits exist for LibreTiny, so the guard cannot run without them.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + with pytest.raises(cv.Invalid, match="no built-in ADC limits for bk72xx"): + CONFIG_SCHEMA(_hub(voltage_divider={"r1": 10000.0, "r2": 20000.0})) + config = CONFIG_SCHEMA( + _hub( + voltage_divider={"r1": 10000.0, "r2": 20000.0}, + adc_input_max=3.34, + adc_pin_max=3.6, + hydrogen={"name": "MiCS-5524 H2 (libretiny)"}, + ) + ) + assert config["voltage_divider"]["r1"] == 10000.0 + + +@pytest.mark.parametrize("key", ["adc_input_max", "adc_pin_max"]) +def test_adc_limits_without_a_divider_are_rejected(key: str) -> None: + """The limits only check a divider, so without one they would be silently ignored.""" + with pytest.raises( + cv.Invalid, + match=f"'{key}:' only checks a 'pin:' source or a 'voltage_divider:'", + ): + CONFIG_SCHEMA(_hub(**{key: 3.34})) + + +def test_adc_attenuation_needs_an_owned_pin() -> None: + """The attenuation configures the hidden ADC of pin:, not an external sampler.""" + with pytest.raises( + cv.Invalid, match="'adc_attenuation:' only applies to the ADC of" + ): + CONFIG_SCHEMA(_hub(adc_attenuation="12db")) + + +def test_datasheet_conversion_is_not_capped_by_the_vendor_range() -> None: + """The datasheet fit has no published range, so a wider one may be configured.""" + config = CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen=None, + carbon_monoxide={"name": "MiCS-5524 CO (datasheet)", "max_ppm": 2000.0}, + ) + ) + + assert config["carbon_monoxide"]["max_ppm"] == 2000.0 + + +def test_datasheet_needs_coefficients_for_gases_without_a_shipped_fit() -> None: + with pytest.raises( + cv.Invalid, match="no built-in 'conversion: datasheet' coefficients" + ): + CONFIG_SCHEMA(_hub(air_reference=None, conversion="datasheet", r0=10.0)) + + +def test_datasheet_accepts_explicit_coefficients() -> None: + config = CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen={ + "name": "MiCS-5524 H2 (fit)", + "coefficient_a": 12.0, + "coefficient_b": -0.8, + }, + ) + ) + + assert config["hydrogen"]["coefficient_a"] == 12.0 + + +def test_coefficients_are_rejected_by_the_vendor_model() -> None: + # A pair under the vendor model is rejected by the model check ... + with pytest.raises(cv.Invalid, match="remove them or switch the conversion"): + CONFIG_SCHEMA( + _hub( + hydrogen={ + "name": "MiCS-5524 H2 (stray)", + "coefficient_a": 12.0, + "coefficient_b": -0.8, + } + ) + ) + # ... while half a pair is already rejected by the schema itself. + with pytest.raises(cv.Invalid, match="coefficients"): + CONFIG_SCHEMA( + _hub(hydrogen={"name": "MiCS-5524 H2 (half)", "coefficient_a": 12.0}) + ) + + +def test_datasheet_rejects_half_a_fit() -> None: + """One coefficient with the shipped value of the other mixes two fits.""" + with pytest.raises(cv.Invalid, match="coefficients"): + CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen=None, + carbon_monoxide={ + "name": "MiCS-5524 CO (half)", + "coefficient_a": 12.0, + }, + ) + ) + + +def test_datasheet_rejects_a_flat_or_rising_fit() -> None: + """A fit whose reading does not rise as the resistance falls reads saturation as no gas.""" + for b in (0.0, 0.8): + with pytest.raises(cv.Invalid, match="'coefficient_b:' must be negative"): + CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen={ + "name": f"MiCS-5524 H2 (fit b={b})", + "coefficient_a": 12.0, + "coefficient_b": b, + }, + ) + ) + + +def test_datasheet_min_ppm_defaults_to_the_shipped_floor_only() -> None: + """The shipped CO fit reads 0 below the 1 ppm of the data sheet; a user fit has no floor.""" + from esphome.components.mics_5524.coefficients import resolve_gas + + assert resolve_gas("carbon_monoxide", "datasheet").min_ppm == 1.0 + assert resolve_gas("carbon_monoxide", "datasheet", a=2.0, b=-1.0).min_ppm == 0.0 + assert resolve_gas("carbon_monoxide", "datasheet", min_ppm=5.0).min_ppm == 5.0 + + +def test_datasheet_rejects_a_non_positive_coefficient() -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA( + _hub( + air_reference=None, + conversion="datasheet", + r0=10.0, + hydrogen={ + "name": "MiCS-5524 H2 (bad fit)", + "coefficient_a": 0.0, + "coefficient_b": -0.8, + }, + ) + ) + + +def test_rs_sensor_is_rejected_by_the_vendor_model() -> None: + """RS only exists in the datasheet model, so the vendor one cannot publish it.""" + with pytest.raises(cv.Invalid, match="'rs_sensor:' belongs to"): + CONFIG_SCHEMA(_hub(rs_sensor={"name": "MiCS-5524 RS"})) + + +def test_reference_keys_belong_to_one_model() -> None: + with pytest.raises(cv.Invalid, match="'r0:' belongs to 'conversion: datasheet'"): + CONFIG_SCHEMA(_hub(air_reference=None, r0=10.0)) + + with pytest.raises( + cv.Invalid, match="'air_reference:' belongs to 'conversion: dfrobot'" + ): + CONFIG_SCHEMA( + _hub( + air_reference=3.6, + conversion="datasheet", + hydrogen={"name": "MiCS-5524 H2 (wrong model)"}, + ) + ) def test_vendor_table_is_the_datasheet_one() -> None: """The whole table is pinned, not only the gases a config file happens to use.""" expected = { - CONF_CARBON_MONOXIDE: VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), - CONF_HYDROGEN: VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), - CONF_ETHANOL: VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), - CONF_AMMONIA: VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), - CONF_METHANE: VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), + CONF_CARBON_MONOXIDE: GasDefinition( + vendor=VendorCurve("CO", 0.425, 0.000405, 1.0, 1000.0), + datasheet=DatasheetCurve(1.0, -1.2, 1.0, 1000.0), + ), + CONF_HYDROGEN: GasDefinition( + VendorCurve("H2", 0.279, 0.00026, 1.0, 1000.0), None + ), + CONF_ETHANOL: GasDefinition( + VendorCurve("C2H5OH", 0.306, 0.00057, 10.0, 500.0), None + ), + CONF_AMMONIA: GasDefinition(VendorCurve("NH3", 0.8, 0.0015, 10.0, 500.0), None), + CONF_METHANE: GasDefinition( + VendorCurve("CH4", 0.786, 0.000023, 1000.0, 25000.0), None + ), } assert expected == GASES + + +def test_only_co_ships_with_datasheet_coefficients() -> None: + assert datasheet_gases() == ["CO"] diff --git a/tests/components/mics_5524/common.h b/tests/components/mics_5524/common.h index 7f46ba9bea3..d80ba354b4d 100644 --- a/tests/components/mics_5524/common.h +++ b/tests/components/mics_5524/common.h @@ -10,8 +10,12 @@ namespace esphome::mics_5524::testing { using micsmath::air_value_from_voltage; using micsmath::calibration_is_stable; +using micsmath::clamp_ppm; +using micsmath::ppm_from_power_law; using micsmath::ppm_from_vendor_curve; using micsmath::ratio_from_air_value; +using micsmath::ratio_from_rs; +using micsmath::rs_from_voltage; // Independent copy of the DFRobot table; the Python tests pin coefficients.py itself. constexpr micsmath::VendorCurve CO{0.425f, 0.000405f, 1.0f, 1000.0f}; diff --git a/tests/components/mics_5524/common.yaml b/tests/components/mics_5524/common.yaml index eb5f69b14ee..86a9c764203 100644 --- a/tests/components/mics_5524/common.yaml +++ b/tests/components/mics_5524/common.yaml @@ -6,7 +6,7 @@ sensor: - platform: mics_5524 id: mics_5524_module - # 10k/20k divider: the 5 V breakout's 3.33 V stays under the ADC limit. + # A 10k/20k divider brings the 5 V output down to 3.33 V on the pin; x1.5 scales it back. voltage: mics_5524_adc voltage_multiplier: 1.5 sample_count: 3 @@ -15,6 +15,11 @@ sensor: delay: 5s sample_count: 3 sample_interval: 20ms + # rs_sensor: only exists in the datasheet model. + voltage_sensor: + name: MiCS-5524 volts + ratio_sensor: + name: MiCS-5524 ratio carbon_monoxide: name: MiCS-5524 CO max_ppm: 500.0 diff --git a/tests/components/mics_5524/sensor/mics_math_test.cpp b/tests/components/mics_5524/sensor/mics_math_test.cpp index 946b4fa2e2e..2f58170774d 100644 --- a/tests/components/mics_5524/sensor/mics_math_test.cpp +++ b/tests/components/mics_5524/sensor/mics_math_test.cpp @@ -51,6 +51,9 @@ TEST(MicsMath, AirValueIsTheVoltageBelowVcc) { // A pin at or below 0 V is disconnected, not a reading of VCC. EXPECT_TRUE(std::isnan(air_value_from_voltage(0.0f, 5.0f))); EXPECT_TRUE(std::isnan(air_value_from_voltage(-0.1f, 5.0f))); + // So is one at or below 5 % of VCC: clean air sits volts above that. + EXPECT_TRUE(std::isnan(air_value_from_voltage(0.25f, 5.0f))); + EXPECT_FLOAT_EQ(4.74f, air_value_from_voltage(0.26f, 5.0f)); // The two ends of the vendor range, and the unusable case, one by one. EXPECT_FLOAT_EQ(1.0f, ratio_from_air_value(air_value_from_voltage(4.0f, 5.0f), 1.0f)); EXPECT_FLOAT_EQ(0.0f, ratio_from_air_value(air_value_from_voltage(5.0f, 5.0f), 1.0f)); @@ -90,4 +93,88 @@ TEST(MicsMath, SaturatedSensorEndToEnd) { EXPECT_FLOAT_EQ(CO.max_ppm, ppm_from_vendor_curve(ratio, CO)); } +TEST(MicsMath, DatasheetResistanceComesFromTheDivider) { + // RS = (VCC * RL) / V_AO - RL: half of the supply across a 10k load resistor. + EXPECT_FLOAT_EQ(10.0f, rs_from_voltage(2.5f, 5.0f, 10.0f)); + EXPECT_FLOAT_EQ(90.0f, rs_from_voltage(0.5f, 5.0f, 10.0f)); + // A pin at the supply is RS = 0, the saturated end; above it would be a negative + // resistance, which only a wrong `vcc:` produces. + EXPECT_FLOAT_EQ(0.0f, rs_from_voltage(5.0f, 5.0f, 10.0f)); + EXPECT_TRUE(std::isnan(rs_from_voltage(5.5f, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(NAN, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(2.5f, 5.0f, 0.0f))); +} + +TEST(MicsMath, DatasheetDeadInputReadsUnknown) { + // The floor is the output of RS = 200 x RL, 5 V / 201 = 24.9 mV: a pin at or below it is + // disconnected, not a module with a huge clean-air resistance. + EXPECT_NEAR(0.02488f, micsmath::datasheet_dead_input_voltage(5.0f), 0.00001f); + EXPECT_TRUE(std::isnan(rs_from_voltage(0.0f, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(0.02f, 5.0f, 10.0f))); + EXPECT_TRUE(std::isnan(rs_from_voltage(0.0248f, 5.0f, 10.0f))); + // Just above the floor a 1.5 MOhm module, the top of the datasheet range, still reads. + EXPECT_NEAR(1656.7f, rs_from_voltage(0.03f, 5.0f, 10.0f), 0.1f); + // The floor scales with RL, so a 1k load resistor reads up to 200 kOhm. + EXPECT_NEAR(199.0f, rs_from_voltage(0.025f, 5.0f, 1.0f), 0.1f); +} + +TEST(MicsMath, SaturatedDatasheetSensorReadsTheTopOfTheRange) { + // V_AO at VCC -> RS = 0 -> ratio 0 -> infinity on the falling curve -> max_ppm, like the + // vendor model, rather than unknown with a wiring warning. + const float ratio = ratio_from_rs(rs_from_voltage(5.0f, 5.0f, 10.0f), 10.0f); + EXPECT_FLOAT_EQ(0.0f, ratio); + EXPECT_TRUE(std::isinf(ppm_from_power_law(1.0f, -1.2f, ratio))); + EXPECT_FLOAT_EQ(1000.0f, clamp_ppm(ppm_from_power_law(1.0f, -1.2f, ratio), 1.0f, 1000.0f)); + // Without a ceiling there is nothing to report. + EXPECT_TRUE(std::isnan(clamp_ppm(INFINITY, 0.0f, INFINITY))); +} + +TEST(MicsMath, DatasheetRatioNeedsAReference) { + EXPECT_FLOAT_EQ(1.0f, ratio_from_rs(10.0f, 10.0f)); + EXPECT_FLOAT_EQ(0.5f, ratio_from_rs(5.0f, 10.0f)); + // Without a calibrated R0 there is no ratio to report. + EXPECT_TRUE(std::isnan(ratio_from_rs(10.0f, 0.0f))); + EXPECT_TRUE(std::isnan(ratio_from_rs(-1.0f, 10.0f))); + EXPECT_TRUE(std::isnan(ratio_from_rs(NAN, 10.0f))); +} + +TEST(MicsMath, DatasheetCurveFollowsTheShippedCoefficients) { + // The CO fit shipped in `coefficients.py`, a = 1.0 and b = -1.2, follows the data sheet + // graph with R0 at clean air: 1 ppm at RS/R0 = 1, 60 ppm at about 0.033 (a sensitivity + // factor of 30, inside the 1.2 - 50 the data sheet gives) and 1000 ppm at about 0.0032. + EXPECT_FLOAT_EQ(1.0f, ppm_from_power_law(1.0f, -1.2f, 1.0f)); + EXPECT_NEAR(60.0f, ppm_from_power_law(1.0f, -1.2f, 0.0329f), 0.5f); + EXPECT_NEAR(1000.0f, ppm_from_power_law(1.0f, -1.2f, 0.00316f), 5.0f); + // A ratio outside what the fit can express is unusable, not a huge number. + EXPECT_TRUE(std::isnan(ppm_from_power_law(1.0f, -1.2f, -0.1f))); + EXPECT_TRUE(std::isnan(ppm_from_power_law(1.0f, -1.2f, NAN))); + // A non-positive fit cannot produce a reading either. + EXPECT_TRUE(std::isnan(ppm_from_power_law(0.0f, -1.2f, 0.5f))); + EXPECT_TRUE(std::isnan(ppm_from_power_law(-1.0f, -1.2f, 0.5f))); +} + +TEST(MicsMath, ClampingKeepsZeroAndCapsTheRange) { + // 0 ppm means "no gas detected" in both models and is never changed. A reading + // below `min_ppm` counts as not detected and reports 0, the vendor behaviour. + EXPECT_FLOAT_EQ(0.0f, clamp_ppm(0.0f, 5.0f, 100.0f)); + EXPECT_FLOAT_EQ(0.0f, clamp_ppm(2.0f, 5.0f, 100.0f)); + EXPECT_FLOAT_EQ(100.0f, clamp_ppm(200.0f, 5.0f, 100.0f)); + EXPECT_FLOAT_EQ(6.3f, clamp_ppm(6.3f, 5.0f, 100.0f)); + EXPECT_TRUE(std::isnan(clamp_ppm(NAN, 5.0f, 100.0f))); +} + +TEST(MicsMath, DatasheetModelEndToEnd) { + // 2.5 V across a 10k load resistor is RS = 10 kOhm, which is also R0 when the sensor + // is in clean air: the reading is then the 1 ppm floor of the data sheet. + const float rs = rs_from_voltage(2.5f, 5.0f, 10.0f); + const float ratio = ratio_from_rs(rs, 10.0f); + + EXPECT_FLOAT_EQ(1.0f, ratio); + EXPECT_FLOAT_EQ(1.0f, clamp_ppm(ppm_from_power_law(1.0f, -1.2f, ratio), 1.0f, 1000.0f)); + // Twice the resistance is cleaner than the reference air, below the floor, so no gas. + EXPECT_FLOAT_EQ(0.0f, clamp_ppm(ppm_from_power_law(1.0f, -1.2f, ratio_from_rs(20.0f, 10.0f)), 1.0f, 1000.0f)); + // Half the resistance is ratio 0.5: 2^1.2 = 2.3 ppm. + EXPECT_NEAR(2.30f, ppm_from_power_law(1.0f, -1.2f, ratio_from_rs(5.0f, 10.0f)), 0.01f); +} + } // namespace esphome::mics_5524::testing diff --git a/tests/components/mics_5524/validate-datasheet.esp32-idf.yaml b/tests/components/mics_5524/validate-datasheet.esp32-idf.yaml new file mode 100644 index 00000000000..4aad3b6ee50 --- /dev/null +++ b/tests/components/mics_5524/validate-datasheet.esp32-idf.yaml @@ -0,0 +1,31 @@ +# Config-only test: the datasheet model with the wiring the component owns (pin:, voltage_divider:). +sensor: + - platform: mics_5524 + id: mics_5524_dat + pin: GPIO34 + voltage_divider: + r1: 10000 + r2: 20000 + adc_input_max: 3.34 + adc_attenuation: 12db + conversion: datasheet + r0: 10.0 + rl: 10.0 + # The EN input of the breakout is active low, so the pin is inverted. + enable_pin: + number: GPIO25 + inverted: true + ratio_sensor: + name: MiCS-5524 ratio + rs_sensor: + name: MiCS-5524 RS + voltage_sensor: + name: MiCS-5524 volts + carbon_monoxide: + id: mics_5524_dat_co + name: MiCS-5524 CO (datasheet) + max_ppm: 2000.0 + hydrogen: + name: MiCS-5524 H2 (fit) + coefficient_a: 6.3 + coefficient_b: -1.1 From f8d31c3e541e209fb739354449afd4f7bafd1f67 Mon Sep 17 00:00:00 2001 From: Tom Zaspel <40226087+tzabbi@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:47:50 +0200 Subject: [PATCH 025/179] [dfrobot_sen0465] Add DFRobot SEN0465-SEN0476 electrochemical gas sensors (#20880) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + .../dfrobot_sen0465_base/__init__.py | 244 ++++++++++++++++ .../dfrobot_sen0465_base.cpp | 276 ++++++++++++++++++ .../dfrobot_sen0465_base.h | 110 +++++++ .../dfrobot_sen0465_i2c/__init__.py | 2 + .../dfrobot_sen0465_i2c.cpp | 37 +++ .../dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h | 19 ++ .../components/dfrobot_sen0465_i2c/sensor.py | 26 ++ .../dfrobot_sen0465_uart/__init__.py | 2 + .../dfrobot_sen0465_uart.cpp | 62 ++++ .../dfrobot_sen0465_uart.h | 24 ++ .../components/dfrobot_sen0465_uart/sensor.py | 38 +++ esphome/core/defines.h | 2 + .../dfrobot_sen0465_i2c/common.yaml | 24 ++ .../dfrobot_sen0465_i2c/test.esp32-idf.yaml | 3 + .../dfrobot_sen0465_i2c/test.esp8266-ard.yaml | 3 + .../dfrobot_sen0465_uart/common.yaml | 14 + .../dfrobot_sen0465_uart/test.esp32-idf.yaml | 3 + .../test.esp8266-ard.yaml | 3 + .../fixtures/uart_mock_dfrobot_sen0465.yaml | 94 ++++++ .../test_uart_mock_dfrobot_sen0465.py | 106 +++++++ 21 files changed, 1095 insertions(+) create mode 100644 esphome/components/dfrobot_sen0465_base/__init__.py create mode 100644 esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp create mode 100644 esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h create mode 100644 esphome/components/dfrobot_sen0465_i2c/__init__.py create mode 100644 esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp create mode 100644 esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h create mode 100644 esphome/components/dfrobot_sen0465_i2c/sensor.py create mode 100644 esphome/components/dfrobot_sen0465_uart/__init__.py create mode 100644 esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp create mode 100644 esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h create mode 100644 esphome/components/dfrobot_sen0465_uart/sensor.py create mode 100644 tests/components/dfrobot_sen0465_i2c/common.yaml create mode 100644 tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml create mode 100644 tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml create mode 100644 tests/components/dfrobot_sen0465_uart/common.yaml create mode 100644 tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml create mode 100644 tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml create mode 100644 tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml create mode 100644 tests/integration/test_uart_mock_dfrobot_sen0465.py diff --git a/CODEOWNERS b/CODEOWNERS index 55f58dbe696..c9ff7c6bb32 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -157,6 +157,9 @@ esphome/components/delonghi/* @grob6000 esphome/components/dew_point/* @CFlix esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber +esphome/components/dfrobot_sen0465_base/* @tzabbi +esphome/components/dfrobot_sen0465_i2c/* @tzabbi +esphome/components/dfrobot_sen0465_uart/* @tzabbi esphome/components/dfrobot_sen0609/* @remcom esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 diff --git a/esphome/components/dfrobot_sen0465_base/__init__.py b/esphome/components/dfrobot_sen0465_base/__init__.py new file mode 100644 index 00000000000..cb82e894e79 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_base/__init__.py @@ -0,0 +1,244 @@ +from collections.abc import Callable +from dataclasses import dataclass + +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_MODEL, + CONF_TEMPERATURE, + CONF_TEMPERATURE_COMPENSATION, + CONF_THRESHOLD, + CONF_TYPE, + DEVICE_CLASS_CARBON_MONOXIDE, + DEVICE_CLASS_TEMPERATURE, + ICON_GAS_CYLINDER, + ICON_MOLECULE_CO, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, +) +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType + +CODEOWNERS = ["@tzabbi"] +DOMAIN = "dfrobot_sen0465_base" + +CONF_ALARM = "alarm" +CONF_CONCENTRATION = "concentration" + +dfrobot_sen0465_base_ns = cg.esphome_ns.namespace("dfrobot_sen0465_base") +DFRobotSEN0465Component = dfrobot_sen0465_base_ns.class_( + "DFRobotSEN0465Component", cg.PollingComponent +) +AlarmType = dfrobot_sen0465_base_ns.enum("AlarmType", is_class=True) +ALARM_TYPES = { + "LOW": AlarmType.ALARM_TYPE_LOW, + "HIGH": AlarmType.ALARM_TYPE_HIGH, +} + +# One piece of the temperature compensation from the DFRobot_MultiGasSensor library: +# for low < T <= high, compensated = raw / (a * T + b) - (c * T + d) +CompensationRange = tuple[int, int, float, float, float, float] + + +@dataclass(frozen=True) +class Model: + """One gas probe of the series.""" + + gas: str + gas_type: int # Gas type code the sensor reports + unit: str + # Also the resolution of the alarm threshold the sensor expects + accuracy_decimals: int + compensation: tuple[CompensationRange, ...] = () + device_class: str = cv.UNDEFINED + icon: str = ICON_GAS_CYLINDER + + +MODELS: dict[str, Model] = { + "SEN0465": Model("O2", 0x05, UNIT_PERCENT, 1), + "SEN0466": Model( + "CO", + 0x04, + UNIT_PARTS_PER_MILLION, + 0, + ( + (-20, 20, 0.005, 0.9, 0, 0), + (20, 40, 0.005, 0.9, 0.3, -6), + ), + device_class=DEVICE_CLASS_CARBON_MONOXIDE, + icon=ICON_MOLECULE_CO, + ), + "SEN0467": Model( + "H2S", + 0x03, + UNIT_PARTS_PER_MILLION, + 0, + ( + (-20, 20, 0.005, 0.92, 0, 0), + (20, 60, 0.015, -0.3, 0, 0), + ), + ), + "SEN0468": Model( + "Cl2", + 0x31, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0.015, 1.1, -0.0025, 0), + (0, 20, 0, 1.1, 0.005, 0), + (20, 40, 0, 1.1, -0.005, 0.3), + ), + ), + "SEN0469": Model( + "NH3", + 0x02, + UNIT_PARTS_PER_MILLION, + 0, + ( + (-20, 0, 0.006, 0.95, -0.006, 0.25), + (0, 20, 0.006, 0.95, -0.012, 0.25), + (20, 40, 0.005, 1.08, -0.1, 2), + ), + ), + "SEN0470": Model( + "SO2", + 0x2B, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-40, 40, 0.006, 0.95, 0, 0), + (40, 60, 0.006, 0.95, 0.05, -2), + ), + ), + "SEN0471": Model( + "NO2", + 0x2C, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0.005, 0.9, -0.0025, 0.005), + (0, 20, 0.005, 0.9, 0.005, 0.005), + (20, 40, 0.005, 0.9, 0.0025, 0.1), + ), + ), + "SEN0472": Model( + "O3", + 0x2A, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0.015, 1.1, 0, 0.05), + (0, 20, 0, 1.1, 0.01, 0), + (20, 40, 0, 1.1, -0.005, 0.3), + ), + ), + # The library's H2 compensation divides by (0.74 * T + 0.007) below 20 °C, which gives + # nonsense results, so it is not offered. + "SEN0473": Model("H2", 0x06, UNIT_PARTS_PER_MILLION, 0), + "SEN0474": Model( + "HCl", + 0x2E, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0, 1, -0.0075, -0.1), + (0, 20, 0, 1, 0, -0.1), + (20, 50, 0, 1, -0.01, 0.1), + ), + ), + "SEN0475": Model( + "HF", + 0x33, + UNIT_PARTS_PER_MILLION, + 1, + ( + (-20, 0, 0, 1, -0.0025, 0), + (0, 20, 0, 1, 0, -0.1), + (20, 40, 0, 1, 0.0375, -0.85), + ), + ), + "SEN0476": Model( + "PH3", + 0x45, + UNIT_PARTS_PER_MILLION, + 1, + ((-20, 40, 0.005, 0.9, 0, 0),), + ), +} + +# Sizes the C++ range list; the largest table entry sets it +COMPENSATION_RANGES = max(len(model.compensation) for model in MODELS.values()) + + +def _model_schema( + model: Model, component_class: MockObjClass, bus_schema: cv.Schema +) -> cv.Schema: + schema = { + cv.GenerateID(): cv.declare_id(component_class), + cv.Optional(CONF_CONCENTRATION): sensor.sensor_schema( + unit_of_measurement=model.unit, + icon=model.icon, + accuracy_decimals=model.accuracy_decimals, + device_class=model.device_class, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + if model.compensation: + schema[cv.Optional(CONF_TEMPERATURE_COMPENSATION, default=False)] = cv.boolean + # The sensor sends the threshold as a 16-bit count of its last decimal place + max_threshold = 0xFFFF / 10**model.accuracy_decimals + schema[cv.Optional(CONF_ALARM)] = cv.Schema( + { + cv.Required(CONF_THRESHOLD): cv.float_range(min=0, max=max_threshold), + cv.Optional(CONF_TYPE, default="HIGH"): cv.enum(ALARM_TYPES, upper=True), + } + ) + return ( + cv.Schema(schema).extend(cv.polling_component_schema("60s")).extend(bus_schema) + ) + + +def dfrobot_sen0465_schema( + component_class: MockObjClass, bus_schema: cv.Schema +) -> Callable[[ConfigType], ConfigType]: + """Return the config schema for one bus variant, with one schema per model.""" + return cv.typed_schema( + { + name: _model_schema(model, component_class, bus_schema) + for name, model in MODELS.items() + }, + key=CONF_MODEL, + upper=True, + ) + + +async def to_code_base(config: ConfigType) -> MockObj: + model = MODELS[config[CONF_MODEL]] + var = cg.new_Pvariable(config[CONF_ID], model.gas_type, model.gas) + await cg.register_component(var, config) + + if config.get(CONF_TEMPERATURE_COMPENSATION): + cg.add_define("USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION") + cg.add_define("DFROBOT_SEN0465_COMPENSATION_RANGES", COMPENSATION_RANGES) + for low, high, a, b, c, d in model.compensation: + cg.add(var.add_compensation_range(low, high, a, b, c, d)) + + if (alarm := config.get(CONF_ALARM)) is not None: + decimals = model.accuracy_decimals + threshold = round(alarm[CONF_THRESHOLD] * 10**decimals) + cg.add(var.set_alarm(alarm[CONF_TYPE], threshold, decimals)) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_CONCENTRATION, var.set_concentration_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + return var diff --git a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp new file mode 100644 index 00000000000..78267d5ff9e --- /dev/null +++ b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp @@ -0,0 +1,276 @@ +#include "dfrobot_sen0465_base.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::dfrobot_sen0465_base { + +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0465"); + +// Protocol from https://github.com/DFRobot/DFRobot_MultiGasSensor +// Request: FF 01 command argument 00 00 00 00 checksum +// Response: FF command conc_high conc_low gas_type decimals temp_high temp_low checksum +static constexpr uint8_t FRAME_ADDRESS = 0x01; + +static constexpr uint8_t NO_COMMAND = 0x00; +static constexpr uint8_t CMD_CHANGE_ACQUIRE_MODE = 0x78; +static constexpr uint8_t CMD_GET_ALL_DATA = 0x88; +static constexpr uint8_t CMD_SET_THRESHOLD_ALARM = 0x89; +static constexpr uint8_t ACQUIRE_MODE_PASSIVE = 0x04; +static constexpr uint8_t ALARM_SWITCH_OFF = 0x00; +static constexpr uint8_t ALARM_SWITCH_ON = 0x01; +static constexpr uint8_t ALARM_LOW_THRESHOLD = 0x00; +static constexpr uint8_t ALARM_HIGH_THRESHOLD = 0x01; +// Byte 2 of the answer to a setting command is 1 when the sensor accepted it +static constexpr uint8_t RESULT_OK = 0x01; + +// The DFRobot examples wait this long after changing the acquire mode +static constexpr uint32_t MODE_CHANGE_DELAY_MS = 1000; +static constexpr uint32_t RETRY_DELAY_MS = 1000; +static constexpr uint8_t MAX_ATTEMPTS = 3; + +// Like FucCheckSum() in the DFRobot library, the checksum covers bytes 1 to 6 only, not byte 7 +static uint8_t frame_checksum(const uint8_t *frame) { + // Two's complement of the byte sum between the head and the checksum + return static_cast(~sum16_bytes(frame + 1, FRAME_SIZE - 3) + 1); +} + +static const LogString *step_name(SetupStep step) { + return step == SetupStep::SETUP_STEP_ACQUIRE_MODE ? LOG_STR("Passive mode") : LOG_STR("Alarm setting"); +} + +void DFRobotSEN0465Component::setup() { + // Answer only when asked, instead of sending a reading every second + this->step_ = SetupStep::SETUP_STEP_ACQUIRE_MODE; + if (!this->send_step_request_()) + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +void DFRobotSEN0465Component::update() { + // The first reading is requested when the setup steps are done + if (this->step_ != SetupStep::SETUP_STEP_DONE) + return; + // A new request would replace the wait for the answer to the previous one + if (this->pending_command_ != NO_COMMAND) { + ESP_LOGV(TAG, "Previous request still waiting for an answer, skipping update"); + return; + } + if (!this->send_command_(CMD_GET_ALL_DATA)) { + ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(); + } +} + +void DFRobotSEN0465Component::dump_config() { + ESP_LOGCONFIG(TAG, + "DFRobot SEN0465 series gas sensor:\n" + " Gas: %s (type 0x%02X)", + this->gas_name_, this->gas_type_); +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + ESP_LOGCONFIG(TAG, " Temperature compensation: %s", YESNO(!this->compensation_.empty())); +#endif + if (this->alarm_type_ == AlarmType::ALARM_TYPE_OFF) { + ESP_LOGCONFIG(TAG, " Alarm: off"); + } else { + float scale = 1.0f; + for (uint8_t i = 0; i < this->alarm_decimals_; i++) + scale *= 10.0f; + const float threshold = this->alarm_threshold_ / scale; + ESP_LOGCONFIG(TAG, " Alarm: %s %.*f", + this->alarm_type_ == AlarmType::ALARM_TYPE_HIGH ? LOG_STR_LITERAL("above") : LOG_STR_LITERAL("below"), + this->alarm_decimals_, threshold); + } + if (this->setup_incomplete_) { + ESP_LOGCONFIG(TAG, " Setup incomplete: yes"); + } + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Concentration", this->concentration_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); +} + +bool DFRobotSEN0465Component::send_command_(uint8_t command, uint8_t arg0, uint8_t arg1, uint8_t arg2, uint8_t arg3) { + uint8_t frame[FRAME_SIZE] = {FRAME_HEAD, FRAME_ADDRESS, command, arg0, arg1, arg2, arg3}; + frame[FRAME_SIZE - 1] = frame_checksum(frame); + if (!this->write_frame(frame)) + return false; + this->pending_command_ = command; + this->read_response(); + return true; +} + +bool DFRobotSEN0465Component::send_step_request_() { + if (this->step_ == SetupStep::SETUP_STEP_ACQUIRE_MODE) + return this->send_command_(CMD_CHANGE_ACQUIRE_MODE, ACQUIRE_MODE_PASSIVE); + if (this->alarm_type_ == AlarmType::ALARM_TYPE_OFF) { + // Switch the alarm off explicitly, the sensor may still have one from an earlier configuration + return this->send_command_(CMD_SET_THRESHOLD_ALARM, ALARM_SWITCH_OFF); + } + return this->send_command_( + CMD_SET_THRESHOLD_ALARM, ALARM_SWITCH_ON, static_cast(this->alarm_threshold_ >> 8), + static_cast(this->alarm_threshold_), + this->alarm_type_ == AlarmType::ALARM_TYPE_HIGH ? ALARM_HIGH_THRESHOLD : ALARM_LOW_THRESHOLD); +} + +void DFRobotSEN0465Component::retry_step_request_() { + if (!this->send_step_request_()) + this->step_failed_(LOG_STR("request not sent")); +} + +void DFRobotSEN0465Component::step_failed_(const LogString *reason) { + this->attempts_++; + ESP_LOGW(TAG, "%s failed: %s (attempt %u of %u)", LOG_STR_ARG(step_name(this->step_)), LOG_STR_ARG(reason), + this->attempts_, MAX_ATTEMPTS); + if (this->attempts_ < MAX_ATTEMPTS) { + this->set_timeout(STEP_TIMEOUT_ID, RETRY_DELAY_MS, [this]() { this->retry_step_request_(); }); + return; + } + // Carry on: readings may still work, but the warning stays because the sensor is not set up as configured + this->setup_incomplete_ = true; + this->status_set_warning(); + this->next_step_(); +} + +void DFRobotSEN0465Component::next_step_() { + this->attempts_ = 0; + if (this->step_ == SetupStep::SETUP_STEP_ACQUIRE_MODE) { + this->step_ = SetupStep::SETUP_STEP_ALARM; + this->set_timeout(STEP_TIMEOUT_ID, MODE_CHANGE_DELAY_MS, [this]() { this->retry_step_request_(); }); + return; + } + this->step_ = SetupStep::SETUP_STEP_DONE; + this->update(); +} + +void DFRobotSEN0465Component::handle_no_response_() { + this->pending_command_ = NO_COMMAND; + if (this->step_ != SetupStep::SETUP_STEP_DONE) { + this->step_failed_(LOG_STR("no response")); + return; + } + ESP_LOGW(TAG, "No response from the sensor"); + this->fail_reading_(); +} + +void DFRobotSEN0465Component::fail_reading_() { + // A frozen gas reading would hide a rising concentration, so the entities go unknown + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); + this->status_set_warning(); +} + +void DFRobotSEN0465Component::handle_response_(const uint8_t *frame) { + // Reject a late answer to an earlier request or a reading sent before passive mode was set + const bool expected = frame[0] == FRAME_HEAD && frame[1] == this->pending_command_; + this->pending_command_ = NO_COMMAND; + + if (this->step_ == SetupStep::SETUP_STEP_DONE) { + if (!expected) { + char hex[format_hex_pretty_size(FRAME_SIZE)]; + ESP_LOGW(TAG, "Unexpected response: %s", format_hex_pretty_to(hex, frame, FRAME_SIZE)); + this->fail_reading_(); + return; + } + this->handle_data_(frame); + return; + } + + ESP_LOGD_HEX(TAG, frame, FRAME_SIZE, FRAME_SIZE, "%s response: %s", LOG_STR_ARG(step_name(this->step_))); + if (!expected) { + this->step_failed_(LOG_STR("unexpected response")); + return; + } + // Like the DFRobot library, the answer to the mode change is accepted without a checksum check + if (this->step_ == SetupStep::SETUP_STEP_ALARM && frame[FRAME_SIZE - 1] != frame_checksum(frame)) { + this->step_failed_(LOG_STR("invalid checksum")); + return; + } + if (frame[2] != RESULT_OK) { + this->step_failed_(LOG_STR("not confirmed")); + return; + } + ESP_LOGD(TAG, "%s confirmed", LOG_STR_ARG(step_name(this->step_))); + this->next_step_(); +} + +void DFRobotSEN0465Component::handle_data_(const uint8_t *frame) { + if (frame[FRAME_SIZE - 1] != frame_checksum(frame)) { + char hex[format_hex_pretty_size(FRAME_SIZE)]; + ESP_LOGW(TAG, "Invalid checksum: %s", format_hex_pretty_to(hex, frame, FRAME_SIZE)); + this->fail_reading_(); + return; + } + + // On-board 10 kOhm NTC (B = 3380) read by a 10-bit ADC at 3 V, as converted by the DFRobot library + float temperature = NAN; + const uint16_t temperature_raw = encode_uint16(frame[6], frame[7]); + if (temperature_raw > 0 && temperature_raw < 1024) { + const float voltage = 3.0f * temperature_raw / 1024.0f; + const float resistance = voltage * 10000.0f / (3.0f - voltage); + temperature = 1.0f / (1.0f / 298.15f + std::log(resistance / 10000.0f) / 3380.13f) - 273.15f; + } else { + // 0 is a disconnected thermistor, 1024 and above a bad frame + ESP_LOGW(TAG, "Temperature reading %u is out of range", temperature_raw); + } + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + + if (frame[4] != this->gas_type_) { + ESP_LOGW(TAG, "Probe reports gas type 0x%02X, expected %s (0x%02X); check the configured model", frame[4], + this->gas_name_, this->gas_type_); + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(NAN); + this->status_set_warning(); + return; + } + + float concentration = encode_uint16(frame[2], frame[3]); + switch (frame[5]) { + case 0: + break; + case 1: + concentration *= 0.1f; + break; + case 2: + concentration *= 0.01f; + break; + default: + ESP_LOGW(TAG, "Invalid number of decimal places: %u", frame[5]); + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(NAN); + this->status_set_warning(); + return; + } + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + if (!this->compensation_.empty()) + concentration = this->compensate_(concentration, temperature); +#endif + if (concentration < 0.0f) + concentration = 0.0f; + + if (this->concentration_sensor_ != nullptr) + this->concentration_sensor_->publish_state(concentration); + if (std::isnan(concentration) || std::isnan(temperature) || this->setup_incomplete_) { + this->status_set_warning(); + } else { + this->status_clear_warning(); + } +} + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION +float DFRobotSEN0465Component::compensate_(float concentration, float temperature) const { + for (const auto &range : this->compensation_) { + if (temperature > range.low && temperature <= range.high) + return concentration / (range.a * temperature + range.b) - (range.c * temperature + range.d); + } + ESP_LOGW(TAG, "Temperature %.1f C is outside the compensation range", temperature); + return NAN; +} +#endif + +} // namespace esphome::dfrobot_sen0465_base diff --git a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h new file mode 100644 index 00000000000..cc589b7f134 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h @@ -0,0 +1,110 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +namespace esphome::dfrobot_sen0465_base { + +static constexpr uint8_t FRAME_SIZE = 9; +static constexpr uint8_t FRAME_HEAD = 0xFF; + +// Scheduler ids, shared with the bus variants +static constexpr uint32_t STEP_TIMEOUT_ID = 0; +static constexpr uint32_t RESPONSE_TIMEOUT_ID = 1; + +enum class AlarmType : uint8_t { + ALARM_TYPE_OFF, + ALARM_TYPE_LOW, + ALARM_TYPE_HIGH, +}; + +/// Settings sent once after boot, before the first reading +enum class SetupStep : uint8_t { + SETUP_STEP_ACQUIRE_MODE, + SETUP_STEP_ALARM, + SETUP_STEP_DONE, +}; + +/// DFRobot "Factory Calibrated Electrochemical Gas Sensor" series (SEN0465 to SEN0476). +/// The bus variants only send request frames and deliver the answers. +class DFRobotSEN0465Component : public PollingComponent { + SUB_SENSOR(concentration) + SUB_SENSOR(temperature) + + public: + /// @param gas_type gas type code the configured probe reports + /// @param gas_name name of that gas, used in log messages + DFRobotSEN0465Component(uint8_t gas_type, const char *gas_name) : gas_name_(gas_name), gas_type_(gas_type) {} + + void setup() override; + void update() override; + void dump_config() override; + + /// @param threshold alarm threshold as sent to the sensor, in units of the last decimal place + /// @param decimals decimal places of the threshold, used for logging + void set_alarm(AlarmType type, uint16_t threshold, uint8_t decimals) { + this->alarm_type_ = type; + this->alarm_threshold_ = threshold; + this->alarm_decimals_ = decimals; + } + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + /// For low < T <= high: compensated = raw / (a * T + b) - (c * T + d) + void add_compensation_range(int8_t low, int8_t high, float a, float b, float c, float d) { + this->compensation_.push_back({low, high, a, b, c, d}); + } +#endif + + protected: + /// Sends one complete request frame. + virtual bool write_frame(const uint8_t *frame) = 0; + /// Waits for the answer to a request, then calls either handle_response_() or handle_no_response_(), once. + virtual void read_response() = 0; + + void handle_response_(const uint8_t *frame); + void handle_no_response_(); + /// Publish unknown on both sensors and set the warning when a reading cannot be used + void fail_reading_(); + + /// Sends the request of the current step. + bool send_step_request_(); + /// Sends the request again from a scheduled callback; a failed write counts as a failed attempt. + void retry_step_request_(); + void step_failed_(const LogString *reason); + void next_step_(); + void handle_data_(const uint8_t *frame); + /// Sends a request and waits for its answer; returns false when the request could not be sent. + bool send_command_(uint8_t command, uint8_t arg0 = 0, uint8_t arg1 = 0, uint8_t arg2 = 0, uint8_t arg3 = 0); + + const char *gas_name_; + uint16_t alarm_threshold_{0}; + uint8_t gas_type_; + AlarmType alarm_type_{AlarmType::ALARM_TYPE_OFF}; + uint8_t alarm_decimals_{0}; + SetupStep step_{SetupStep::SETUP_STEP_ACQUIRE_MODE}; + uint8_t attempts_{0}; + /// Command whose answer is awaited, 0 when no request is outstanding + uint8_t pending_command_{0}; + /// A setup step gave up, so the alarm or the mode may not be what was configured + bool setup_incomplete_{false}; + +#ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION + struct CompensationRange { + int8_t low; + int8_t high; + float a; + float b; + float c; + float d; + }; + + /// Returns NAN when the temperature is outside every range + float compensate_(float concentration, float temperature) const; + + StaticVector compensation_; +#endif +}; + +} // namespace esphome::dfrobot_sen0465_base diff --git a/esphome/components/dfrobot_sen0465_i2c/__init__.py b/esphome/components/dfrobot_sen0465_i2c/__init__.py new file mode 100644 index 00000000000..898f8f2d5f0 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@tzabbi"] +DOMAIN = "dfrobot_sen0465_i2c" diff --git a/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp new file mode 100644 index 00000000000..aa6312a7ebb --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.cpp @@ -0,0 +1,37 @@ +#include "dfrobot_sen0465_i2c.h" +#include "esphome/core/log.h" + +namespace esphome::dfrobot_sen0465_i2c { + +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0465_i2c"); + +using dfrobot_sen0465_base::FRAME_SIZE; +using dfrobot_sen0465_base::RESPONSE_TIMEOUT_ID; + +static constexpr uint8_t DATA_REGISTER = 0x00; +// The DFRobot library waits 10 ms, but on an ESP32-C6 reading the answer to the alarm command +// 10 ms after sending it timed out +static constexpr uint32_t RESPONSE_DELAY_MS = 100; + +void DFRobotSEN0465I2CComponent::dump_config() { + DFRobotSEN0465Component::dump_config(); + LOG_I2C_DEVICE(this); +} + +bool DFRobotSEN0465I2CComponent::write_frame(const uint8_t *frame) { + return this->write_register(DATA_REGISTER, frame, FRAME_SIZE) == i2c::ERROR_OK; +} + +void DFRobotSEN0465I2CComponent::read_response() { + this->set_timeout(RESPONSE_TIMEOUT_ID, RESPONSE_DELAY_MS, [this]() { + uint8_t frame[FRAME_SIZE]; + // Like the DFRobot library, end the register write with a STOP before reading + if (this->write(&DATA_REGISTER, 1) != i2c::ERROR_OK || this->read(frame, FRAME_SIZE) != i2c::ERROR_OK) { + this->handle_no_response_(); + return; + } + this->handle_response_(frame); + }); +} + +} // namespace esphome::dfrobot_sen0465_i2c diff --git a/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h new file mode 100644 index 00000000000..505cc1aef41 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/dfrobot_sen0465_i2c.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::dfrobot_sen0465_i2c { + +class DFRobotSEN0465I2CComponent final : public dfrobot_sen0465_base::DFRobotSEN0465Component, public i2c::I2CDevice { + public: + using DFRobotSEN0465Component::DFRobotSEN0465Component; + + void dump_config() override; + + protected: + bool write_frame(const uint8_t *frame) override; + void read_response() override; +}; + +} // namespace esphome::dfrobot_sen0465_i2c diff --git a/esphome/components/dfrobot_sen0465_i2c/sensor.py b/esphome/components/dfrobot_sen0465_i2c/sensor.py new file mode 100644 index 00000000000..85ec983b479 --- /dev/null +++ b/esphome/components/dfrobot_sen0465_i2c/sensor.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.dfrobot_sen0465_base import ( + DFRobotSEN0465Component, + dfrobot_sen0465_schema, + to_code_base, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["dfrobot_sen0465_base"] +DEPENDENCIES = ["i2c"] + +dfrobot_sen0465_i2c_ns = cg.esphome_ns.namespace("dfrobot_sen0465_i2c") +DFRobotSEN0465I2CComponent = dfrobot_sen0465_i2c_ns.class_( + "DFRobotSEN0465I2CComponent", DFRobotSEN0465Component, i2c.I2CDevice +) + +# 0x77 is the factory setting of the A0/A1 switches according to the DFRobot examples +CONFIG_SCHEMA = dfrobot_sen0465_schema( + DFRobotSEN0465I2CComponent, i2c.i2c_device_schema(0x77) +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await i2c.register_i2c_device(var, config) diff --git a/esphome/components/dfrobot_sen0465_uart/__init__.py b/esphome/components/dfrobot_sen0465_uart/__init__.py new file mode 100644 index 00000000000..e0598c87c8e --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@tzabbi"] +DOMAIN = "dfrobot_sen0465_uart" diff --git a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp new file mode 100644 index 00000000000..ed1c6733abb --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp @@ -0,0 +1,62 @@ +#include "dfrobot_sen0465_uart.h" + +#include + +namespace esphome::dfrobot_sen0465_uart { + +using dfrobot_sen0465_base::FRAME_HEAD; +using dfrobot_sen0465_base::FRAME_SIZE; +using dfrobot_sen0465_base::RESPONSE_TIMEOUT_ID; + +// The DFRobot library waits up to this long for an answer +static constexpr uint32_t RESPONSE_TIMEOUT_MS = 3000; + +void DFRobotSEN0465UARTComponent::setup() { + // loop() only runs while an answer is expected + this->disable_loop(); + DFRobotSEN0465Component::setup(); +} + +void DFRobotSEN0465UARTComponent::loop() { + // The tail of a reading the probe sent on its own may precede the answer; start at the next frame head. + uint8_t byte; + while (this->rx_count_ == 0 && this->peek_byte(&byte) && byte != FRAME_HEAD) + this->read_byte(&byte); + const size_t available = this->available(); + if (available == 0) + return; + const size_t len = std::min(available, FRAME_SIZE - this->rx_count_); + if (!this->read_array(this->rx_buffer_ + this->rx_count_, len)) + return; + this->rx_count_ += len; + if (this->rx_count_ < FRAME_SIZE) + return; + + this->rx_count_ = 0; + this->cancel_timeout(RESPONSE_TIMEOUT_ID); + this->disable_loop(); + this->handle_response_(this->rx_buffer_); +} + +bool DFRobotSEN0465UARTComponent::write_frame(const uint8_t *frame) { + // Drop anything left over, such as the answer to the mode change or readings sent before passive mode was set + uint8_t byte; + while (this->available() > 0) { + if (!this->read_byte(&byte)) + break; + } + this->rx_count_ = 0; + this->write_array(frame, FRAME_SIZE); + return true; +} + +void DFRobotSEN0465UARTComponent::read_response() { + this->enable_loop(); + this->set_timeout(RESPONSE_TIMEOUT_ID, RESPONSE_TIMEOUT_MS, [this]() { + this->disable_loop(); + this->rx_count_ = 0; + this->handle_no_response_(); + }); +} + +} // namespace esphome::dfrobot_sen0465_uart diff --git a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h new file mode 100644 index 00000000000..e30f1c3353c --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::dfrobot_sen0465_uart { + +class DFRobotSEN0465UARTComponent final : public dfrobot_sen0465_base::DFRobotSEN0465Component, + public uart::UARTDevice { + public: + using DFRobotSEN0465Component::DFRobotSEN0465Component; + + void setup() override; + void loop() override; + + protected: + bool write_frame(const uint8_t *frame) override; + void read_response() override; + + uint8_t rx_buffer_[dfrobot_sen0465_base::FRAME_SIZE]; + uint8_t rx_count_{0}; +}; + +} // namespace esphome::dfrobot_sen0465_uart diff --git a/esphome/components/dfrobot_sen0465_uart/sensor.py b/esphome/components/dfrobot_sen0465_uart/sensor.py new file mode 100644 index 00000000000..36eae742f7d --- /dev/null +++ b/esphome/components/dfrobot_sen0465_uart/sensor.py @@ -0,0 +1,38 @@ +import esphome.codegen as cg +from esphome.components import uart +from esphome.components.dfrobot_sen0465_base import ( + DFRobotSEN0465Component, + dfrobot_sen0465_schema, + to_code_base, +) +from esphome.types import ConfigType + +from . import DOMAIN + +AUTO_LOAD = ["dfrobot_sen0465_base"] +DEPENDENCIES = ["uart"] + +dfrobot_sen0465_uart_ns = cg.esphome_ns.namespace("dfrobot_sen0465_uart") +DFRobotSEN0465UARTComponent = dfrobot_sen0465_uart_ns.class_( + "DFRobotSEN0465UARTComponent", DFRobotSEN0465Component, uart.UARTDevice +) + +CONFIG_SCHEMA = dfrobot_sen0465_schema( + DFRobotSEN0465UARTComponent, uart.UART_DEVICE_SCHEMA +) + +# The sensor's baud rate is fixed +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + baud_rate=9600, + require_tx=True, + require_rx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await uart.register_uart_device(var, config) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 97677334828..e1e30d1b783 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -70,6 +70,8 @@ #define USE_DEEP_SLEEP #define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES +#define USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION +#define DFROBOT_SEN0465_COMPENSATION_RANGES 3 #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_EMONTX_VERSION diff --git a/tests/components/dfrobot_sen0465_i2c/common.yaml b/tests/components/dfrobot_sen0465_i2c/common.yaml new file mode 100644 index 00000000000..1106210a67f --- /dev/null +++ b/tests/components/dfrobot_sen0465_i2c/common.yaml @@ -0,0 +1,24 @@ +sensor: + - platform: dfrobot_sen0465_i2c + id: dfrobot_sen0466_i2c_co + i2c_id: i2c_bus + model: SEN0466 + address: 0x77 + temperature_compensation: true + alarm: + threshold: 50 + update_interval: 30s + concentration: + name: CO Concentration + temperature: + name: CO Sensor Temperature + - platform: dfrobot_sen0465_i2c + id: dfrobot_sen0465_i2c_o2 + i2c_id: i2c_bus + model: SEN0465 + address: 0x76 + alarm: + threshold: 19.5 + type: low + concentration: + name: O2 Concentration diff --git a/tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml b/tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml new file mode 100644 index 00000000000..e02720c246d --- /dev/null +++ b/tests/components/dfrobot_sen0465_i2c/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + dfrobot_sen0465_i2c: !include common.yaml diff --git a/tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml b/tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml new file mode 100644 index 00000000000..bfb82a5c696 --- /dev/null +++ b/tests/components/dfrobot_sen0465_i2c/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + dfrobot_sen0465_i2c: !include common.yaml diff --git a/tests/components/dfrobot_sen0465_uart/common.yaml b/tests/components/dfrobot_sen0465_uart/common.yaml new file mode 100644 index 00000000000..fdd12be8f96 --- /dev/null +++ b/tests/components/dfrobot_sen0465_uart/common.yaml @@ -0,0 +1,14 @@ +sensor: + - platform: dfrobot_sen0465_uart + id: dfrobot_sen0466_uart_co + uart_id: uart_bus + model: SEN0466 + temperature_compensation: true + alarm: + threshold: 35 + type: high + update_interval: 30s + concentration: + name: CO Concentration + temperature: + name: CO Sensor Temperature diff --git a/tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml b/tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml new file mode 100644 index 00000000000..98ff3920a96 --- /dev/null +++ b/tests/components/dfrobot_sen0465_uart/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + dfrobot_sen0465_uart: !include common.yaml diff --git a/tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml b/tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml new file mode 100644 index 00000000000..863aa9e7455 --- /dev/null +++ b/tests/components/dfrobot_sen0465_uart/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + dfrobot_sen0465_uart: !include common.yaml diff --git a/tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml b/tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml new file mode 100644 index 00000000000..3e40b1c017f --- /dev/null +++ b/tests/integration/fixtures/uart_mock_dfrobot_sen0465.yaml @@ -0,0 +1,94 @@ +esphome: + name: uart-mock-dfrobot-sen0465 + +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 DEPENDENCIES = ["uart"] +uart: + baud_rate: 9600 + port: /dev/null + +# A model of a SEN0466 (CO) probe. The first mode change is refused, the first alarm request gets a late +# answer to the mode change instead, and the first data request gets no answer, so the component has to +# retry, reject the wrong answer and wait out the timeout without sending a new request +uart_mock: + id: mock_uart + baud_rate: 9600 + on_tx: + - then: + - lambda: |- + if (data.size() != 9 || data[0] != 0xFF || data[1] != 0x01) + return; + static int mode_requests = 0; + static int alarm_requests = 0; + static int data_requests = 0; + static uint32_t mode_confirmed_ms = 0; + static bool alarm_set = false; + std::vector reply(9, 0); + reply[0] = 0xFF; + reply[1] = data[2]; + switch (data[2]) { + case 0x78: + if (data[3] == 0x04 && ++mode_requests >= 2) { + reply[2] = 0x01; + mode_confirmed_ms = millis(); + } + break; + case 0x89: + ESP_LOGI("sim", "Alarm switch %u threshold %u type %u, %u ms after the mode change", data[3], + (data[4] << 8) | data[5], data[6], millis() - mode_confirmed_ms); + reply[2] = 0x01; + if (++alarm_requests == 1) { + reply[1] = 0x78; // FF 78 01 00 00 00 00 00 87: valid checksum and result, wrong command + break; + } + alarm_set = true; + break; + case 0x88: + if (!alarm_set) + ESP_LOGE("sim", "Data requested before the setup was done"); + if (++data_requests == 1) { + ESP_LOGI("sim", "Data request left unanswered"); + return; + } + if (data_requests == 2) + ESP_LOGI("sim", "Data request answered"); + reply[2] = 0x00; // 20 ppm + reply[3] = 0x14; + reply[4] = 0x04; // CO + reply[5] = 0x00; // no decimal places + reply[6] = 0x01; // NTC ADC 511, a non-zero byte 7 that the checksum must leave out + reply[7] = 0xFF; + break; + default: + return; + } + // The checksum covers bytes 1 to 6 only, as in the DFRobot library + uint8_t sum = 0; + for (int i = 1; i < 7; i++) + sum += reply[i]; + reply[8] = static_cast(~sum + 1); + id(mock_uart).inject_to_rx_buffer(reply); + +sensor: + - platform: dfrobot_sen0465_uart + uart_id: mock_uart + model: SEN0466 + temperature_compensation: true + update_interval: 2s + alarm: + threshold: 35 + type: high + concentration: + name: CO + temperature: + name: Probe Temperature diff --git a/tests/integration/test_uart_mock_dfrobot_sen0465.py b/tests/integration/test_uart_mock_dfrobot_sen0465.py new file mode 100644 index 00000000000..12fd139fad5 --- /dev/null +++ b/tests/integration/test_uart_mock_dfrobot_sen0465.py @@ -0,0 +1,106 @@ +"""Integration test for the DFRobot SEN0465 series over UART against a model of a SEN0466 probe. + +Checks the boot sequence (passive mode with one retry, the wait after it, the alarm setting with a +late answer to the mode change that must be rejected), that no new request is sent while one is +unanswered, and that a reading is decoded and temperature compensated. +""" + +from __future__ import annotations + +import asyncio +import math +from pathlib import Path +import re + +from aioesphomeapi import EntityState, SensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +ALARM_RE = re.compile( + r"Alarm switch (\d+) threshold (\d+) type (\d+), (\d+) ms after the mode change" +) + + +@pytest.mark.asyncio +async def test_uart_mock_dfrobot_sen0465( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Boot sets passive mode and the alarm before the first reading.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + alarm_set: asyncio.Future[re.Match[str]] = loop.create_future() + lines: list[str] = [] + + def line_callback(line: str) -> None: + lines.append(line) + if (match := ALARM_RE.search(line)) and not alarm_set.done(): + alarm_set.set_result(match) + + states: dict[int, EntityState] = {} + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + concentration = require_entity(entities, "co", SensorInfo) + temperature = require_entity(entities, "probe_temperature", SensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity: SensorInfo) -> float | None: + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + try: + async with asyncio.timeout(15.0): + # The unanswered request publishes NAN first; wait for the decoded reading + while (value := state_of(concentration)) is None or math.isnan(value): + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"No reading; log: {lines[-20:]}") + + match = await asyncio.wait_for(alarm_set, timeout=1.0) + switch, threshold, alarm_type, delay_ms = (int(x) for x in match.groups()) + assert (switch, threshold, alarm_type) == (1, 35, 1) + assert delay_ms >= 1000, ( + "the alarm was set before the wait after the mode change" + ) + + assert any( + "Passive mode failed: not confirmed (attempt 1 of 3)" in x for x in lines + ) + assert any("Passive mode confirmed" in x for x in lines) + assert any( + "Alarm setting failed: unexpected response (attempt 1 of 3)" in x + for x in lines + ) + assert any("Alarm setting confirmed" in x for x in lines) + assert not any("Data requested before the setup was done" in x for x in lines) + + # The unanswered request blocks new ones until its timeout, which is longer than the interval + unanswered = next( + i for i, x in enumerate(lines) if "Data request left unanswered" in x + ) + answered = next(i for i, x in enumerate(lines) if "Data request answered" in x) + between = lines[unanswered:answered] + assert any("Previous request still waiting for an answer" in x for x in between) + assert any("No response from the sensor" in x for x in between) + + # NTC ADC 511 converted like the DFRobot library, then compensated with the CO formula + voltage = 3 * 511 / 1024 + resistance = voltage * 10000 / (3 - voltage) + temp = 1 / (1 / 298.15 + math.log(resistance / 10000) / 3380.13) - 273.15 + assert state_of(temperature) == pytest.approx(temp, abs=0.05) + expected = 20 / (0.005 * temp + 0.9) - (0.3 * temp - 6) + assert state_of(concentration) == pytest.approx(expected, abs=0.05) From 1b4721d4451c7d62dca91e571d4f494b440a66e6 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:23:12 +1100 Subject: [PATCH 026/179] [core] Script improvements including `choose` (#20547) --- esphome/automation.py | 129 +++++++++- esphome/components/script/__init__.py | 105 +++++++-- esphome/components/script/script.h | 37 ++- esphome/core/defines.h | 1 + tests/components/script/__init__.py | 9 + tests/components/script/common.yaml | 33 +++ .../components/script/test_script_on_stop.cpp | 77 ++++++ .../fixtures/script_on_stop_and_choose.yaml | 145 ++++++++++++ .../test_script_on_stop_and_choose.py | 117 +++++++++ tests/unit_tests/components/test_script.py | 160 +++++++++++++ tests/unit_tests/test_automation.py | 222 ++++++++++++++++++ 11 files changed, 1013 insertions(+), 22 deletions(-) create mode 100644 tests/components/script/__init__.py create mode 100644 tests/components/script/test_script_on_stop.cpp create mode 100644 tests/integration/fixtures/script_on_stop_and_choose.yaml create mode 100644 tests/integration/test_script_on_stop_and_choose.py create mode 100644 tests/unit_tests/components/test_script.py diff --git a/esphome/automation.py b/esphome/automation.py index 32f423d2a34..91cbf0daab1 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_COUNT, CONF_ELSE, CONF_ID, + CONF_IF, CONF_THEN, CONF_TIME, CONF_TIMEOUT, @@ -34,6 +35,9 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, SafeExpType from esphome.util import Registry +CONF_CHOOSE = "choose" +CONF_CHOICE_ID = "choice_id" + def progmem_bytes(name: str, data: bytes | list[int]) -> MockObj: """Shared PROGMEM table for constant bytes; equal payloads share one, empty is nullptr.""" @@ -840,10 +844,39 @@ async def delay_action_to_code( return var +def _wrap_bare_if_condition(value: Any) -> Any: + """Allow a single condition directly under `if:` in place of `condition: {...}`.""" + if not isinstance(value, dict): + return value + # `any` and `all` are also registered conditions, but here they are the if action's own keys + bare = [ + key + for key in value + if key in CONDITION_REGISTRY and key not in (CONF_ANY, CONF_ALL) + ] + if not bare: + return value + if len(bare) > 1: + raise cv.Invalid( + f"Only one condition may be given directly under 'if', found {', '.join(bare)}. " + f"Use '{CONF_CONDITION}', '{CONF_ALL}' or '{CONF_ANY}' to combine conditions" + ) + if other := [k for k in (CONF_CONDITION, CONF_ANY, CONF_ALL) if k in value]: + raise cv.Invalid( + f"'{bare[0]}' given directly under 'if' cannot be used with '{other[0]}'. " + f"Move it into '{other[0]}'" + ) + key = bare[0] + result = {k: v for k, v in value.items() if k != key} + result[CONF_CONDITION] = {key: value[key]} + return result + + @register_action( "if", IfAction, cv.All( + _wrap_bare_if_condition, { cv.Exclusive( CONF_CONDITION, CONF_CONDITION @@ -870,14 +903,104 @@ async def if_action_to_code( cond_conf = next(el for el in config if el in (CONF_ANY, CONF_ALL, CONF_CONDITION)) condition = await build_condition(config[cond_conf], template_arg, args) var = cg.new_Pvariable(action_id, if_template_arg, condition) + then_actions = else_actions = None if CONF_THEN in config: - for action in await build_action_list(config[CONF_THEN], template_arg, args): + then_actions = await build_action_list(config[CONF_THEN], template_arg, args) + if has_else: + else_actions = await build_action_list(config[CONF_ELSE], template_arg, args) + _add_if_branches(var, then_actions, else_actions) + return var + + +def _add_if_branches( + var: MockObj, + then_actions: list[MockObj] | None, + else_actions: list[MockObj] | None, +) -> None: + """Add the branches of an IfAction one action at a time; None leaves a branch out.""" + if then_actions is not None: + for action in then_actions: cg.add(var.add_then_action(action)) cg.add(var.finish_then()) - if has_else: - for action in await build_action_list(config[CONF_ELSE], template_arg, args): + if else_actions is not None: + for action in else_actions: cg.add(var.add_else_action(action)) cg.add(var.finish_else()) + + +def _validate_choice(choice: ConfigType) -> ConfigType: + """A choice is either `if` with `then`, or `else` on its own.""" + if CONF_ELSE in choice: + for key in (CONF_IF, CONF_THEN): + if key in choice: + raise cv.Invalid(f"'{key}' cannot be used with 'else'", path=[key]) + return choice + for key in (CONF_IF, CONF_THEN): + if key not in choice: + raise cv.Invalid(f"'{key}' is required", path=[key]) + return choice + + +def _validate_choose_else(choices: list[ConfigType]) -> list[ConfigType]: + """`else` may only be the last choice, and not the only one.""" + for index, choice in enumerate(choices[:-1]): + if CONF_ELSE in choice: + raise cv.Invalid( + "Only the last choice may be 'else'", path=[index, CONF_ELSE] + ) + if len(choices) == 1 and CONF_ELSE in choices[0]: + raise cv.Invalid( + "At least one choice must have 'if' and 'then'", path=[0, CONF_ELSE] + ) + return choices + + +@register_action( + CONF_CHOOSE, + IfAction, + cv.All( + cv.ensure_list( + cv.All( + cv.Schema( + { + cv.GenerateID(CONF_CHOICE_ID): cv.declare_id(IfAction), + cv.Optional(CONF_IF): validate_potentially_and_condition, + cv.Optional(CONF_THEN): validate_action_list, + cv.Optional(CONF_ELSE): validate_action_list, + } + ), + _validate_choice, + ) + ), + cv.Length(min=1), + _validate_choose_else, + ), + synchronous=True, +) +async def choose_action_to_code( + config: list[ConfigType], + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + # Built as a chain of if/else actions: each choice's else branch holds the next choice, + # and the last choice's else branch holds the `else` actions, if any. + else_actions: list[MockObj] | None = None + if CONF_ELSE in config[-1]: + else_actions = await build_action_list( + config[-1][CONF_ELSE], template_arg, args + ) + config = config[:-1] + var: MockObj | None = None + for index in reversed(range(len(config))): + choice = config[index] + if_template_arg = cg.TemplateArguments(else_actions is not None, *template_arg) + condition = await build_condition(choice[CONF_IF], template_arg, args) + choice_id = action_id if index == 0 else choice[CONF_CHOICE_ID] + var = cg.new_Pvariable(choice_id, if_template_arg, condition) + then_actions = await build_action_list(choice[CONF_THEN], template_arg, args) + _add_if_branches(var, then_actions, else_actions) + else_actions = [var] return var diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index 48a208b7f7f..da149944f21 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -1,9 +1,13 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg +from esphome.components.const import CONF_ON_STOP import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODE, CONF_PARAMETERS, CONF_RESTART from esphome.core import CORE, EsphomeError +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DOMAIN = "script" @@ -20,6 +24,7 @@ CONF_SINGLE = "single" CONF_QUEUED = "queued" CONF_PARALLEL = "parallel" CONF_MAX_RUNS = "max_runs" +CONF_SCRIPT_STOP = "script.stop" SCRIPT_MODES = { CONF_SINGLE: SingleScript, @@ -109,22 +114,72 @@ def validate_parameter_type(value): raise cv.Invalid("Parameter type contains invalid characters") -CONFIG_SCHEMA = automation.validate_automation( - { - # Don't declare id as cv.declare_id yet, because the ID type - # depends on the mode. Will be checked later with assign_declare_id - cv.Required(CONF_ID): cv.string_strict, - cv.Optional(CONF_MODE, default=CONF_SINGLE): cv.one_of( - *SCRIPT_MODES, lower=True - ), - cv.Optional(CONF_MAX_RUNS): cv.int_range(min=0, max=100), - cv.Optional(CONF_PARAMETERS, default={}): cv.Schema( - { - validate_parameter_name: validate_parameter_type, - } - ), - }, - extra_validators=cv.All(check_max_runs, assign_declare_id), +def _fill_script_stop_ids(value: Any, script_id: str) -> Any: + """Give `script.stop` actions without an id in a raw action tree the id `script_id`. + + Containers are copied only along changed paths so untouched YAML nodes keep their source locations. + """ + if isinstance(value, list): + result = [ + {CONF_SCRIPT_STOP: {CONF_ID: script_id}} + if item == CONF_SCRIPT_STOP + else _fill_script_stop_ids(item, script_id) + for item in value + ] + if all(new is old for new, old in zip(result, value, strict=True)): + return value + return result + if not isinstance(value, dict): + return value + result = value + for key, item in value.items(): + if key == CONF_SCRIPT_STOP and ( + item is None or (isinstance(item, dict) and CONF_ID not in item) + ): + new_item = {**(item or {}), CONF_ID: script_id} + else: + new_item = _fill_script_stop_ids(item, script_id) + if new_item is not item: + if result is value: + result = value.copy() + result[key] = new_item + return result + + +def _fill_script_stop_ids_in_script(item: Any) -> Any: + if isinstance(item, dict) and isinstance(script_id := item.get(CONF_ID), str): + return _fill_script_stop_ids(item, script_id) + return item + + +def default_script_stop_ids(value: Any) -> Any: + """Make `script.stop` with no id, used inside a script, stop that script.""" + if isinstance(value, list): + return [_fill_script_stop_ids_in_script(item) for item in value] + # A single script may be given as a mapping instead of a list + return _fill_script_stop_ids_in_script(value) + + +CONFIG_SCHEMA = cv.All( + default_script_stop_ids, + automation.validate_automation( + { + # Don't declare id as cv.declare_id yet, because the ID type + # depends on the mode. Will be checked later with assign_declare_id + cv.Required(CONF_ID): cv.string_strict, + cv.Optional(CONF_MODE, default=CONF_SINGLE): cv.one_of( + *SCRIPT_MODES, lower=True + ), + cv.Optional(CONF_MAX_RUNS): cv.int_range(min=0, max=100), + cv.Optional(CONF_PARAMETERS, default={}): cv.Schema( + { + validate_parameter_name: validate_parameter_type, + } + ), + cv.Optional(CONF_ON_STOP): automation.validate_automation(single=True), + }, + extra_validators=cv.All(check_max_runs, assign_declare_id), + ), ) @@ -145,8 +200,12 @@ async def to_code(config): triggers.append((trigger, func_args, conf)) + if any(CONF_ON_STOP in conf for conf in config): + cg.add_define("USE_SCRIPT_ON_STOP") for trigger, func_args, conf in triggers: await automation.build_automation(trigger, func_args, conf) + if (stop_conf := conf.get(CONF_ON_STOP)) is not None: + await automation.build_automation(trigger.get_stop_trigger(), [], stop_conf) @automation.register_action( @@ -205,9 +264,19 @@ async def script_execute_action_to_code(config, action_id, template_arg, args): return var +def _require_script_stop_id(config: ConfigType) -> ConfigType: + # Inside a script the id has already been filled in by default_script_stop_ids + if CONF_ID not in config: + raise cv.Invalid( + "'script.stop' without an id can only be used inside a script", + path=[CONF_ID], + ) + return config + + automation.register_apply_action( - "script.stop", - maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), + CONF_SCRIPT_STOP, + maybe_simple_id({cv.Optional(CONF_ID): cv.use_id(Script)}, _require_script_stop_id), automation.ApplyCall("stop()"), ) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index efd75d27e0e..c84077025a3 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -33,8 +33,18 @@ template class Script : public ScriptLogger, public Triggeris_action_running(); } +#ifdef USE_SCRIPT_ON_STOP + /// Stop all instances of this script. Fires the on_stop trigger once if any instance was running. + virtual void stop() { + if (this->is_action_running()) + this->stop_running_(); + } + + Trigger<> *get_stop_trigger() { return &this->stop_trigger_; } +#else /// Stop all instances of this script. virtual void stop() { this->stop_action(); } +#endif // execute this script using a tuple that contains the arguments void execute_tuple(const std::tuple &tuple) { @@ -57,7 +67,18 @@ template class Script : public ScriptLogger, public Triggertrigger(x...); } + // Stop the running actions early and fire the on_stop trigger. + void stop_running_() { + this->stop_action(); +#ifdef USE_SCRIPT_ON_STOP + this->stop_trigger_.trigger(); +#endif + } + const LogString *name_{nullptr}; +#ifdef USE_SCRIPT_ON_STOP + Trigger<> stop_trigger_; +#endif }; /** A script type for which only a single instance at a time is allowed. @@ -88,7 +109,12 @@ template class RestartScript : public Script { void execute(Ts... x) override { if (this->is_action_running()) { this->esp_logd_(__LINE__, ESPHOME_LOG_FORMAT("Script '%s' restarting (mode: restart)"), LOG_STR_ARG(this->name_)); - this->stop_action(); + this->stop_running_(); +#ifdef USE_SCRIPT_ON_STOP + // on_stop may have executed this script again; only the newest run may continue + if (this->is_action_running()) + this->stop_action(); +#endif } this->run_actions_(x...); @@ -145,12 +171,21 @@ template class QueueingScript : public Script, public Com } void stop() override { +#ifdef USE_SCRIPT_ON_STOP + // Queued runs are unfinished work too, even between two runs when nothing is running + const bool stopping = this->is_action_running() || this->num_queued_ != 0; +#endif // Clear all queued items to free memory immediately // Resetting the array automatically destroys all unique_ptrs and their contents this->var_queue_.reset(); this->num_queued_ = 0; this->queue_front_ = 0; +#ifdef USE_SCRIPT_ON_STOP + if (stopping) + this->stop_running_(); +#else Script::stop(); +#endif } void loop() override { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index e1e30d1b783..3555b3cd1c2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -266,6 +266,7 @@ #define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 +#define USE_SCRIPT_ON_STOP #define USE_SELECT #define USE_SELEC_METER_EM4M #define USE_SENSOR diff --git a/tests/components/script/__init__.py b/tests/components/script/__init__.py new file mode 100644 index 00000000000..ef5a1a33501 --- /dev/null +++ b/tests/components/script/__init__.py @@ -0,0 +1,9 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config): + cg.add_define("USE_SCRIPT_ON_STOP") + + manifest.to_code = to_code_testing diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index f8396e9648f..6a71c2cdef4 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -22,6 +22,25 @@ esphome: - script.is_running: my_script then: - logger.log: my_script is running + - if: + any: + - script.is_running: my_script + - script.is_running: my_script_restart + then: + - logger.log: a script is running + - script.execute: my_script_stops_itself + - choose: + - if: + script.is_running: my_script + then: + - logger.log: first choice + - if: + - lambda: return true; + - script.is_running: my_script_restart + then: + - logger.log: second choice + - else: + - logger.log: default choice globals: - id: nested_total @@ -44,8 +63,22 @@ script: - lambda: 'ESP_LOGD("main", "Hello World!");' - id: my_script_restart mode: restart + on_stop: + - logger.log: my_script_restart stopped then: - lambda: 'ESP_LOGD("main", "Hello World!");' + - id: my_script_stops_itself + on_stop: + then: + - logger.log: my_script_stops_itself stopped + then: + - delay: 1s + - if: + lambda: return true; + then: + - script.stop + - script.stop: + - logger.log: never reached - id: my_script_with_array_params parameters: ints: int[] diff --git a/tests/components/script/test_script_on_stop.cpp b/tests/components/script/test_script_on_stop.cpp new file mode 100644 index 00000000000..5fdda64bc15 --- /dev/null +++ b/tests/components/script/test_script_on_stop.cpp @@ -0,0 +1,77 @@ +#include + +#include "esphome/components/script/script.h" + +namespace esphome::script::testing { + +class CountAction : public Action<> { + public: + explicit CountAction(int *count) : count_(count) {} + + protected: + void play() override { (*this->count_)++; } + int *count_; +}; + +// Stays running until release() is called, like a delay that never expires on its own. +class HoldAction : public Action<> { + public: + void play_complex() override { + this->num_running_++; + this->plays++; + } + void release() { this->play_next_(); } + int plays{0}; + + protected: + void play() override {} +}; + +class QueueingScriptOnStopTest : public ::testing::Test { + protected: + void SetUp() override { + this->script_.set_max_runs(3); + this->automation_.add_action(&this->hold_); + this->stop_automation_.add_action(&this->count_); + } + + QueueingScript<> script_; + Automation<> automation_{&this->script_}; + HoldAction hold_; + int on_stop_calls_{0}; + Automation<> stop_automation_{this->script_.get_stop_trigger()}; + CountAction count_{&this->on_stop_calls_}; +}; + +TEST_F(QueueingScriptOnStopTest, StopWhileRunningFiresOnStopOnceAndDropsQueuedRun) { + this->script_.execute(); + this->script_.execute(); + ASSERT_EQ(this->hold_.plays, 1); + + this->script_.stop(); + EXPECT_EQ(this->on_stop_calls_, 1); + + this->script_.loop(); + EXPECT_EQ(this->hold_.plays, 1); +} + +// After one run ends, the next queued run only starts on the following loop() call +TEST_F(QueueingScriptOnStopTest, StopBetweenQueuedRunsFiresOnStop) { + this->script_.execute(); + this->script_.execute(); + this->hold_.release(); + ASSERT_FALSE(this->script_.is_running()); + + this->script_.stop(); + EXPECT_EQ(this->on_stop_calls_, 1); + + this->script_.loop(); + EXPECT_EQ(this->hold_.plays, 1); +} + +TEST_F(QueueingScriptOnStopTest, StopWhenIdleDoesNotFireOnStop) { + this->script_.stop(); + EXPECT_EQ(this->on_stop_calls_, 0); +} + +} // namespace esphome::script::testing diff --git a/tests/integration/fixtures/script_on_stop_and_choose.yaml b/tests/integration/fixtures/script_on_stop_and_choose.yaml new file mode 100644 index 00000000000..d7edbd90d3f --- /dev/null +++ b/tests/integration/fixtures/script_on_stop_and_choose.yaml @@ -0,0 +1,145 @@ +esphome: + name: test-script-on-stop-choose + +host: + +api: + actions: + - action: run_choose + variables: + value: int + then: + - choose: + - if: + lambda: return value < 10; + then: + - logger.log: + format: "CHOOSE small %d" + args: [value] + - if: + lambda: return value < 100; + then: + - delay: 20ms + - logger.log: + format: "CHOOSE medium %d" + args: [value] + # Also true for smaller values, but only the first true choice runs + - if: + lambda: return value < 1000; + then: + - logger.log: + format: "CHOOSE large %d" + args: [value] + - else: + - logger.log: + format: "CHOOSE default %d" + args: [value] + - logger.log: + format: "CHOOSE done %d" + args: [value] + - action: run_self_stop + then: + - script.execute: self_stop + - action: run_external_stop + then: + - script.execute: long_script + - delay: 50ms + - script.stop: long_script + - action: run_idle_stop + then: + - script.stop: long_script + - logger.log: "IDLE stop done" + - action: run_restart + then: + - script.execute: restart_script + - script.execute: restart_script + - action: run_normal + then: + - script.execute: normal_script + - action: run_reenter + then: + - script.execute: reenter_script + - delay: 20ms + - script.execute: reenter_script + - action: run_parallel_stop + then: + - script.execute: parallel_script + - script.execute: parallel_script + - delay: 20ms + - script.stop: parallel_script + - logger.log: "PARALLEL stop done" + - action: run_queued_stop + then: + - script.execute: queued_script + - script.execute: queued_script + - delay: 20ms + - script.stop: queued_script + - delay: 300ms + - logger.log: "QUEUED stop done" + +logger: + level: DEBUG + +script: + - id: self_stop + on_stop: + - logger.log: "SELF on_stop" + then: + - logger.log: "SELF start" + - delay: 20ms + - if: + condition: + lambda: return true; + then: + - script.stop + - logger.log: "SELF not reached" + + - id: long_script + on_stop: + - logger.log: "LONG on_stop" + then: + - delay: 10s + - logger.log: "LONG finished" + + - id: restart_script + mode: restart + on_stop: + - logger.log: "RESTART on_stop" + then: + - logger.log: "RESTART start" + - delay: 50ms + - logger.log: "RESTART finished" + + - id: normal_script + on_stop: + - logger.log: "NORMAL on_stop" + then: + - logger.log: "NORMAL finished" + + # on_stop executes the same restart script again; only one run may continue + - id: reenter_script + mode: restart + on_stop: + - logger.log: "REENTER on_stop" + - script.execute: reenter_script + then: + - logger.log: "REENTER start" + - delay: 100ms + - logger.log: "REENTER finished" + + - id: parallel_script + mode: parallel + on_stop: + - logger.log: "PARALLEL on_stop" + then: + - delay: 10s + - logger.log: "PARALLEL finished" + + - id: queued_script + mode: queued + on_stop: + - logger.log: "QUEUED on_stop" + then: + - logger.log: "QUEUED start" + - delay: 100ms + - logger.log: "QUEUED finished" diff --git a/tests/integration/test_script_on_stop_and_choose.py b/tests/integration/test_script_on_stop_and_choose.py new file mode 100644 index 00000000000..07631450df3 --- /dev/null +++ b/tests/integration/test_script_on_stop_and_choose.py @@ -0,0 +1,117 @@ +"""Integration test for the script on_stop trigger, script.stop without an id, and the choose action.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import UserService +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_on_stop_and_choose( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test on_stop, self-stopping scripts and choose.""" + lines: list[str] = [] + waiters: list[tuple[str, asyncio.Future[None]]] = [] + + def check_output(line: str) -> None: + lines.append(line) + for text, future in waiters: + if text in line and not future.done(): + future.set_result(None) + + def expect(text: str) -> asyncio.Future[None]: + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + waiters.append((text, future)) + return future + + def count(text: str) -> int: + return sum(text in line for line in lines) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service: dict[str, UserService] = {s.name: s for s in services} + + # choose runs only the first choice whose condition is true, or the else + # choice if none is, then continues + for value, label in ( + (5, "small"), + (50, "medium"), + (500, "large"), + (5000, "default"), + ): + done = expect(f"CHOOSE done {value}") + await client.execute_service(service["run_choose"], {"value": value}) + await asyncio.wait_for(done, timeout=5.0) + assert count(f"CHOOSE {label} {value}") == 1 + assert count("CHOOSE small") == 1 + assert count("CHOOSE medium") == 1 + assert count("CHOOSE large") == 1 + assert count("CHOOSE default") == 1 + + # A bare script.stop inside a script stops that script and fires on_stop + stopped = expect("SELF on_stop") + await client.execute_service(service["run_self_stop"], {}) + await asyncio.wait_for(stopped, timeout=5.0) + + # Stopping a running script from outside fires on_stop + stopped = expect("LONG on_stop") + await client.execute_service(service["run_external_stop"], {}) + await asyncio.wait_for(stopped, timeout=5.0) + + # Stopping a script that is not running does not fire on_stop + idle_done = expect("IDLE stop done") + await client.execute_service(service["run_idle_stop"], {}) + await asyncio.wait_for(idle_done, timeout=5.0) + + # Restarting a running script stops the old run early + finished = expect("RESTART finished") + await client.execute_service(service["run_restart"], {}) + await asyncio.wait_for(finished, timeout=5.0) + + # A script that finishes normally does not fire on_stop + finished = expect("NORMAL finished") + await client.execute_service(service["run_normal"], {}) + await asyncio.wait_for(finished, timeout=5.0) + + # A restart script whose on_stop executes it again ends up with one run + finished = expect("REENTER finished") + await client.execute_service(service["run_reenter"], {}) + await asyncio.wait_for(finished, timeout=5.0) + + # Stopping several parallel runs fires on_stop once + done = expect("PARALLEL stop done") + await client.execute_service(service["run_parallel_stop"], {}) + await asyncio.wait_for(done, timeout=5.0) + + # Stopping a queued script fires on_stop once and drops the queued run + done = expect("QUEUED stop done") + await client.execute_service(service["run_queued_stop"], {}) + await asyncio.wait_for(done, timeout=5.0) + await asyncio.sleep(0.2) + + assert count("SELF not reached") == 0 + assert count("SELF on_stop") == 1 + assert count("LONG finished") == 0 + assert count("LONG on_stop") == 1 + assert count("RESTART start") == 2 + assert count("RESTART on_stop") == 1 + assert count("RESTART finished") == 1 + assert count("NORMAL on_stop") == 0 + assert count("REENTER on_stop") == 1 + assert count("REENTER start") == 3 + assert count("REENTER finished") == 1 + assert count("PARALLEL on_stop") == 1 + assert count("PARALLEL finished") == 0 + assert count("QUEUED on_stop") == 1 + assert count("QUEUED start") == 1 + assert count("QUEUED finished") == 0 diff --git a/tests/unit_tests/components/test_script.py b/tests/unit_tests/components/test_script.py new file mode 100644 index 00000000000..10c6d0a7a38 --- /dev/null +++ b/tests/unit_tests/components/test_script.py @@ -0,0 +1,160 @@ +"""Tests for the script component's config validation.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from esphome import automation +from esphome.components.const import CONF_ON_STOP +from esphome.components.script import ( + CONF_SCRIPT_STOP, + SingleScript, + default_script_stop_ids, + to_code, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MODE, CONF_PARAMETERS, CONF_THEN +from esphome.core import ID +from esphome.cpp_generator import MockObj + + +def test_bare_script_stop_defaults_to_enclosing_script() -> None: + config = [ + { + CONF_ID: "my_script", + CONF_THEN: [ + CONF_SCRIPT_STOP, + {CONF_SCRIPT_STOP: None}, + {CONF_SCRIPT_STOP: {}}, + {CONF_SCRIPT_STOP: "other_script"}, + {CONF_SCRIPT_STOP: {CONF_ID: "other_script"}}, + {"if": {"condition": "x", CONF_THEN: [CONF_SCRIPT_STOP]}}, + ], + "on_stop": [{CONF_SCRIPT_STOP: None}], + }, + {CONF_ID: "second", CONF_THEN: [CONF_SCRIPT_STOP]}, + ] + + result = default_script_stop_ids(config) + + assert result[0][CONF_THEN] == [ + {CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}, + {CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}, + {CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}, + {CONF_SCRIPT_STOP: "other_script"}, + {CONF_SCRIPT_STOP: {CONF_ID: "other_script"}}, + { + "if": { + "condition": "x", + CONF_THEN: [{CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}], + } + }, + ] + assert result[0]["on_stop"] == [{CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}] + assert result[1][CONF_THEN] == [{CONF_SCRIPT_STOP: {CONF_ID: "second"}}] + # The input is not modified + assert config[1][CONF_THEN] == [CONF_SCRIPT_STOP] + + +def test_unchanged_script_config_is_not_copied() -> None: + then = [{"logger.log": "hello"}] + config = [{CONF_ID: "my_script", CONF_THEN: then}] + + result = default_script_stop_ids(config) + + assert result[0] is config[0] + assert result[0][CONF_THEN] is then + + +def test_script_stop_without_id_outside_script_is_invalid() -> None: + with pytest.raises(cv.Invalid, match="can only be used inside a script"): + automation.validate_action(CONF_SCRIPT_STOP) + + +def test_script_stop_with_id_is_valid() -> None: + result = automation.validate_action({CONF_SCRIPT_STOP: "my_script"}) + + assert result[CONF_SCRIPT_STOP][CONF_ID].id == "my_script" + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("my_script", id="not_a_list_or_mapping"), + pytest.param({CONF_THEN: [CONF_SCRIPT_STOP]}, id="mapping_without_id"), + pytest.param(["my_script", {CONF_THEN: [CONF_SCRIPT_STOP]}], id="no_id"), + ], +) +def test_script_stop_ids_left_alone_without_script_id(value: Any) -> None: + assert default_script_stop_ids(value) == value + + +def test_bare_script_stop_in_single_script_mapping() -> None: + config = {CONF_ID: "my_script", CONF_THEN: [CONF_SCRIPT_STOP]} + + result = default_script_stop_ids(config) + + assert result[CONF_THEN] == [{CONF_SCRIPT_STOP: {CONF_ID: "my_script"}}] + + +@pytest.mark.asyncio +async def test_on_stop_builds_stop_trigger_automation() -> None: + stop_conf = {CONF_THEN: []} + config = [ + { + CONF_ID: ID("my_script", is_declaration=True, type=SingleScript), + CONF_MODE: "single", + CONF_PARAMETERS: {}, + CONF_THEN: [], + CONF_ON_STOP: stop_conf, + }, + { + CONF_ID: ID("other_script", is_declaration=True, type=SingleScript), + CONF_MODE: "single", + CONF_PARAMETERS: {}, + CONF_THEN: [], + }, + ] + with ( + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add"), + patch("esphome.codegen.add_define") as add_define, + patch( + "esphome.automation.build_automation", new_callable=AsyncMock + ) as build_automation, + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + await to_code(config) + + add_define.assert_called_once_with("USE_SCRIPT_ON_STOP") + + assert [ + (str(c.args[0]), *c.args[1:]) for c in build_automation.await_args_list + ] == [ + ("my_script", [], config[0]), + ("my_script->get_stop_trigger()", [], stop_conf), + ("other_script", [], config[1]), + ] + + +@pytest.mark.asyncio +async def test_no_on_stop_define_without_on_stop() -> None: + config = [ + { + CONF_ID: ID("my_script", is_declaration=True, type=SingleScript), + CONF_MODE: "single", + CONF_PARAMETERS: {}, + CONF_THEN: [], + } + ] + with ( + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add"), + patch("esphome.codegen.add_define") as add_define, + patch("esphome.automation.build_automation", new_callable=AsyncMock), + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + await to_code(config) + + add_define.assert_not_called() diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index b56d0a2920e..6f4a1ba83a8 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -8,6 +8,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from esphome.automation import ( + CONF_CHOICE_ID, + CONF_CHOOSE, ApplyAction, ApplyCall, ApplyCondition, @@ -25,7 +27,9 @@ from esphome.automation import ( build_parent_callback_automation, build_trigger_automations, build_trigger_callback, + choose_action_to_code, has_non_synchronous_actions, + if_action_to_code, literal_with_length, maybe_simple_id, parent_ref, @@ -40,14 +44,19 @@ from esphome.automation import ( register_simple_condition, string_ref_literal, templatable_bytes, + validate_action, validate_no_deferring_actions, validate_synchronous_automation, ) import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( + CONF_ALL, + CONF_ANY, CONF_AUTOMATION_ID, + CONF_CONDITION, CONF_COUNT, + CONF_ELSE, CONF_ID, CONF_THEN, CONF_TYPE_ID, @@ -1557,6 +1566,219 @@ async def test_build_callback_automation_forwarder(mock_cg: MockCodegen) -> None ) +def test_if_accepts_bare_condition() -> None: + """A single condition directly under `if:` is the same as `condition: {...}`.""" + bare = validate_action({"if": {"or": [], CONF_THEN: [{"delay": "1s"}]}}) + wrapped = validate_action( + {"if": {CONF_CONDITION: {"or": []}, CONF_THEN: [{"delay": "1s"}]}} + ) + assert bare["if"][CONF_CONDITION] == wrapped["if"][CONF_CONDITION] + assert "or" not in bare["if"] + + +@pytest.mark.parametrize("key", [CONF_ANY, CONF_ALL]) +def test_if_any_all_are_not_bare_conditions(key: str) -> None: + """`any:` and `all:` under `if:` are the if action's own keys, not a bare condition.""" + result = validate_action({"if": {key: [{"or": []}], CONF_THEN: [{"delay": "1s"}]}}) + assert key in result["if"] + assert CONF_CONDITION not in result["if"] + + +@pytest.mark.parametrize( + ("value", "match"), + [ + pytest.param( + {"or": [], "and": [], CONF_THEN: []}, + "Only one condition", + id="two_bare", + ), + pytest.param( + {"or": [], CONF_CONDITION: {"and": []}, CONF_THEN: []}, + "cannot be used with 'condition'", + id="bare_and_wrapped", + ), + pytest.param( + {"or": [], CONF_ANY: [{"and": []}], CONF_THEN: []}, + "cannot be used with 'any'", + id="bare_and_any", + ), + ], +) +def test_if_rejects_ambiguous_bare_condition( + value: dict[str, object], match: str +) -> None: + with pytest.raises(cv.Invalid, match=match): + validate_action({"if": value}) + + +def test_if_with_non_dict_value_is_invalid() -> None: + with pytest.raises(cv.Invalid, match="expected a dictionary"): + validate_action({"if": "not a dict"}) + + +CHOICE = {"if": {"and": []}, CONF_THEN: [{"delay": "1s"}]} + + +@pytest.mark.parametrize( + ("value", "match"), + [ + pytest.param([], "at least 1", id="empty"), + pytest.param([{CONF_THEN: [{"delay": "1s"}]}], "'if' is required", id="no_if"), + pytest.param([{"if": {"and": []}}], "'then' is required", id="no_then"), + pytest.param( + [{CONF_ELSE: [{"delay": "1s"}]}], + "At least one choice must have 'if' and 'then'", + id="only_else", + ), + pytest.param( + [{CONF_ELSE: [{"delay": "1s"}]}, CHOICE], + "Only the last choice may be 'else'", + id="else_not_last", + ), + pytest.param( + [CHOICE, {"if": {"and": []}, CONF_ELSE: []}], + "'if' cannot be used with 'else'", + id="else_with_if", + ), + pytest.param( + [CHOICE, {CONF_THEN: [], CONF_ELSE: []}], + "'then' cannot be used with 'else'", + id="else_with_then", + ), + ], +) +def test_choose_invalid(value: list[ConfigType], match: str) -> None: + with pytest.raises(cv.Invalid, match=match): + validate_action({CONF_CHOOSE: value}) + + +def test_choose_valid() -> None: + result = validate_action( + { + CONF_CHOOSE: [ + CHOICE, + {"if": [{"or": []}, {"and": []}], CONF_THEN: [{"delay": "2s"}]}, + {CONF_ELSE: [{"delay": "3s"}]}, + ] + } + ) + + choices = result[CONF_CHOOSE] + assert len(choices) == 3 + assert "and" in choices[1]["if"] + assert CONF_ELSE in choices[2] + assert all(CONF_CHOICE_ID in choice for choice in choices) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("has_else", [True, False]) +async def test_choose_builds_if_else_chain(has_else: bool) -> None: + """Each choice is an IfAction whose else branch holds the next choice, then the else actions.""" + config: list[ConfigType] = [ + {"if": "cond_a", CONF_THEN: "then_a", CONF_CHOICE_ID: ID("choice_a")}, + {"if": "cond_b", CONF_THEN: "then_b", CONF_CHOICE_ID: ID("choice_b")}, + ] + if has_else: + config.append({CONF_ELSE: "else_c", CONF_CHOICE_ID: ID("choice_c")}) + action_id = ID("action") + with ( + patch( + "esphome.automation.build_condition", + new_callable=AsyncMock, + side_effect=lambda conf, *_: f"built_{conf}", + ), + patch( + "esphome.automation.build_action_list", + new_callable=AsyncMock, + side_effect=lambda conf, *_: [MockObj(f"built_{conf}")], + ), + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add") as add, + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + result = await choose_action_to_code(config, action_id, TEMPLATE_ARG, []) + + # Built from the last choice back; the first choice takes the action's own id + created = [ + (c.args[0].id, str(c.args[1]), c.args[2]) for c in new_pvariable.call_args_list + ] + assert created == [ + ("choice_b", f"<{str(has_else).lower()}>", "built_cond_b"), + ("action", "", "built_cond_a"), + ] + assert str(result) == "action" + statements = [str(c.args[0]) for c in add.call_args_list] + expected_b_else = ( + ["choice_b->add_else_action(built_else_c)", "choice_b->finish_else()"] + if has_else + else [] + ) + assert statements == [ + "choice_b->add_then_action(built_then_b)", + "choice_b->finish_then()", + *expected_b_else, + "action->add_then_action(built_then_a)", + "action->finish_then()", + "action->add_else_action(choice_b)", + "action->finish_else()", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("config", "template_arg", "expected"), + [ + pytest.param( + {CONF_CONDITION: "cond", CONF_THEN: "then", CONF_ELSE: "else"}, + "", + [ + "action->add_then_action(built_then)", + "action->finish_then()", + "action->add_else_action(built_else)", + "action->finish_else()", + ], + id="then_and_else", + ), + pytest.param( + {CONF_ANY: "cond", CONF_ELSE: "else"}, + "", + ["action->add_else_action(built_else)", "action->finish_else()"], + id="only_else", + ), + pytest.param( + {CONF_ALL: "cond", CONF_THEN: "then"}, + "", + ["action->add_then_action(built_then)", "action->finish_then()"], + id="only_then", + ), + ], +) +async def test_if_adds_branches_one_action_at_a_time( + config: ConfigType, template_arg: str, expected: list[str] +) -> None: + with ( + patch( + "esphome.automation.build_condition", + new_callable=AsyncMock, + side_effect=lambda conf, *_: f"built_{conf}", + ), + patch( + "esphome.automation.build_action_list", + new_callable=AsyncMock, + side_effect=lambda conf, *_: [MockObj(f"built_{conf}")], + ), + patch("esphome.codegen.new_Pvariable") as new_pvariable, + patch("esphome.codegen.add") as add, + ): + new_pvariable.side_effect = lambda id_, *args: MockObj(id_.id, "->") + await if_action_to_code(config, ID("action"), TEMPLATE_ARG, []) + + new_pvariable.assert_called_once() + assert str(new_pvariable.call_args.args[1]) == template_arg + assert new_pvariable.call_args.args[2] == "built_cond" + assert [str(c.args[0]) for c in add.call_args_list] == expected + + def test_shadow_iteration_leaves_other_names() -> None: args = [(cg.std_string, "prefix"), (cg.uint32, "iteration_1")] assert _shadow_iteration(args) == args From c0a693dfce2253565aaef43786aaa5277b85726e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 03:18:24 -1000 Subject: [PATCH 027/179] [components] Use xor_bytes and sum16_bytes for checksums (#20922) --- esphome/components/apc1/apc1.cpp | 13 ++---------- esphome/components/dfplayer/dfplayer.cpp | 5 +---- esphome/components/ld2420/ld2420.cpp | 9 ++------- esphome/components/ld6002b/ld6002b.cpp | 8 ++------ esphome/components/pmsa003i/pmsa003i.cpp | 5 +---- esphome/components/pmsx003/pmsx003.cpp | 10 ++-------- esphome/components/rdm6300/rdm6300.cpp | 4 +--- .../seeed_mr60fda2/seeed_mr60fda2.cpp | 20 ++----------------- .../components/shelly_dimmer/stm32flash.cpp | 14 ++++--------- esphome/components/toshiba/toshiba.cpp | 9 ++------- .../components/zwave_proxy/zwave_proxy.cpp | 10 ++-------- 11 files changed, 21 insertions(+), 86 deletions(-) diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp index beca9d85815..dee4bd8d73b 100644 --- a/esphome/components/apc1/apc1.cpp +++ b/esphome/components/apc1/apc1.cpp @@ -28,15 +28,6 @@ static constexpr uint32_t APC1_NO_DATA_TIMEOUT_MS = 15000; // Drop a partial frame when the bytes stop mid-way static constexpr uint32_t APC1_RX_IDLE_TIMEOUT_MS = 200; -// Frames and commands end with the 16-bit sum of every byte before it -static uint16_t apc1_checksum(const uint8_t *data, size_t len) { - uint16_t sum = 0; - for (size_t i = 0; i < len; i++) { - sum += data[i]; - } - return sum; -} - void APC1Component::setup() { if (this->set_pin_ != nullptr) { this->set_pin_->setup(); @@ -157,7 +148,7 @@ void APC1Component::loop() { void APC1Component::parse_frame_(uint16_t total_len) { uint16_t expected_checksum = encode_uint16(this->rx_buffer_[total_len - 2], this->rx_buffer_[total_len - 1]); - uint16_t calculated_checksum = apc1_checksum(this->rx_buffer_.data(), total_len - 2); + uint16_t calculated_checksum = sum16_bytes(this->rx_buffer_.data(), total_len - 2); if (expected_checksum != calculated_checksum) { ESP_LOGW(TAG, "APC1 checksum mismatch: calculated 0x%04X != expected 0x%04X", calculated_checksum, expected_checksum); @@ -343,7 +334,7 @@ void APC1Component::send_command_(APC1Command cmd, uint16_t data) { 0, 0, }; - uint16_t checksum = apc1_checksum(buf.data(), 5); + uint16_t checksum = sum16_bytes(buf.data(), 5); buf[5] = static_cast((checksum >> 8) & 0xFF); buf[6] = static_cast(checksum & 0xFF); this->write_array(buf); diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index c45e895b385..4b031274d26 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -124,10 +124,7 @@ void DFPlayer::play_folder(uint16_t folder, uint16_t file) { void DFPlayer::send_cmd_(uint8_t cmd, uint16_t argument) { uint8_t buffer[10]{0x7e, 0xff, 0x06, cmd, 0x01, (uint8_t) (argument >> 8), (uint8_t) argument, 0x00, 0x00, 0xef}; - uint16_t checksum = 0; - for (uint8_t i = 1; i < 7; i++) - checksum += buffer[i]; - checksum = -checksum; + const uint16_t checksum = static_cast(-sum16_bytes(buffer + 1, 6)); buffer[7] = checksum >> 8; buffer[8] = (uint8_t) checksum; diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index dec7e42de47..e81f117a1b3 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -161,13 +161,8 @@ template uint8_t find_uint8(const StringToUint8 (&arr)[N], const std:: return 0xFF; // Not found } -static uint8_t calc_checksum(void *data, size_t size) { - uint8_t checksum = 0; - uint8_t *data_bytes = (uint8_t *) data; - for (size_t i = 0; i < size; i++) { - checksum ^= data_bytes[i]; // XOR operation - } - return checksum; +static uint8_t calc_checksum(const void *data, size_t size) { + return xor_bytes(static_cast(data), size); } static int32_t get_firmware_int(const char *version_string) { diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 93a2ea341f4..42461228cef 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -1256,12 +1256,8 @@ void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t this->write_byte(static_cast(~header_xor)); if (len > 0 && data != nullptr) { - uint8_t data_xor = 0; - for (uint8_t i = 0; i < len; i++) { - this->write_byte(data[i]); - data_xor ^= data[i]; - } - this->write_byte(static_cast(~data_xor)); + this->write_array(data, len); + this->write_byte(static_cast(~xor_bytes(data, len))); } const uint32_t now = millis(); if (track) { diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 6885795e8a7..5ab0516785d 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -124,10 +124,7 @@ bool PMSA003IComponent::read_data_(PM25AQIData *data) { } // Calculate checksum - uint16_t checksum = 0; - for (uint8_t i = 0; i < CHECKSUM_START_INDEX; i++) { - checksum += buffer[i]; - } + const uint16_t checksum = sum16_bytes(buffer, CHECKSUM_START_INDEX); const uint16_t check = encode_uint16(buffer[CHECKSUM_START_INDEX], buffer[CHECKSUM_START_INDEX + 1]); if (checksum != check) { diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index f99e5adaa2c..efc80e8c650 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -159,10 +159,7 @@ optional PMSX003Component::check_byte_() { } // checksum is without checksum bytes - uint16_t checksum = 0; - for (uint16_t i = 0; i < total_size - 2; i++) { - checksum += this->data_[i]; - } + const uint16_t checksum = sum16_bytes(this->data_, total_size - 2); const uint16_t check = this->get_16_bit_uint_(total_size - 2); if (checksum != check) { @@ -207,10 +204,7 @@ void PMSX003Component::send_command_(Command cmd, uint16_t data) { }; // Calculate checksum - uint16_t checksum = 0; - for (uint8_t i = 0; i < 5; i++) { - checksum += send_data[i]; - } + const uint16_t checksum = sum16_bytes(send_data, 5); send_data[5] = (checksum >> 8) & 0xFF; // Verify Byte 1 send_data[6] = (checksum >> 0) & 0xFF; // Verify Byte 2 diff --git a/esphome/components/rdm6300/rdm6300.cpp b/esphome/components/rdm6300/rdm6300.cpp index 2bdbde91748..12fc9204cf6 100644 --- a/esphome/components/rdm6300/rdm6300.cpp +++ b/esphome/components/rdm6300/rdm6300.cpp @@ -39,9 +39,7 @@ void rdm6300::RDM6300Component::loop() { ESP_LOGW(TAG, "Invalid end byte from RDM6300!"); this->read_state_ = RDM6300_STATE_WAITING_FOR_START; } else { - uint8_t checksum = 0; - for (uint8_t i = 0; i < 5; i++) - checksum ^= this->buffer_[i]; + const uint8_t checksum = xor_bytes(this->buffer_, 5); this->read_state_ = RDM6300_STATE_WAITING_FOR_START; if (checksum != this->buffer_[5]) { ESP_LOGW(TAG, "Checksum from RDM6300 doesn't match! (0x%02X!=0x%02X)", checksum, this->buffer_[5]); diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index 6b5fb087171..d0e4c635eb2 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -62,24 +62,8 @@ void MR60FDA2Component::loop() { } } -/** - * @brief Calculate the checksum for a byte array. - * - * This function calculates the checksum for the provided byte array using an - * XOR-based checksum algorithm. - * - * @param data The byte array to calculate the checksum for. - * @param len The length of the byte array. - * @return The calculated checksum. - */ -static uint8_t calculate_checksum(const uint8_t *data, size_t len) { - uint8_t checksum = 0; - for (size_t i = 0; i < len; i++) { - checksum ^= data[i]; - } - checksum = ~checksum; - return checksum; -} +// The checksum is the inverted XOR of the bytes +static uint8_t calculate_checksum(const uint8_t *data, size_t len) { return ~xor_bytes(data, len); } /** * @brief Validate the checksum of a byte array. diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index 5996929ab84..9d3bd9f1e11 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -27,6 +27,7 @@ #include "debug.h" #include "dev_table.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -725,20 +726,13 @@ stm32_err_t stm32_write_memory(const stm32_unique_ptr &stm, uint32_t address, co return STM32_ERR_UNKNOWN; const unsigned int aligned_len = (len + 3) & ~3; - uint8_t cs = aligned_len - 1; uint8_t buf[256 + 2]; buf[0] = aligned_len - 1; - for (unsigned int i = 0; i < len; i++) { - cs ^= data[i]; - buf[i + 1] = data[i]; - } + memcpy(&buf[1], data, len); /* padding data */ - for (auto i = len; i < aligned_len; i++) { - cs ^= 0xFF; - buf[i + 1] = 0xFF; - } - buf[aligned_len + 1] = cs; + memset(&buf[1 + len], 0xFF, aligned_len - len); + buf[aligned_len + 1] = xor_bytes(buf, aligned_len + 1); stream->write_array(buf, aligned_len + 2); stream->flush(); diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index eae24641c40..2cf9419ef8e 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -544,9 +544,7 @@ void ToshibaClimate::transmit_generic_() { } // The last byte is the xor of all bytes from [4] - for (uint8_t i = 4; i < message_length - 1; i++) { - message[message_length - 1] ^= message[i]; - } + message[message_length - 1] = xor_bytes(message + 4, message_length - 5); // Transmit auto transmit = this->transmitter_->transmit(); @@ -1335,13 +1333,10 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { break; // Generic (old) Toshiba packet default: - uint8_t checksum = 0; // Add back the length of the header (we pruned it above) message_length += TOSHIBA_HEADER_LENGTH; // Validate the second checksum before trusting any more of the message - for (uint8_t i = TOSHIBA_HEADER_LENGTH; i < message_length - 1; i++) { - checksum ^= message[i]; - } + const uint8_t checksum = xor_bytes(message + TOSHIBA_HEADER_LENGTH, message_length - 1 - TOSHIBA_HEADER_LENGTH); // Did our computed checksum and the provided checksum match? if (checksum != message[message_length - 1]) { return false; diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index bfa16f857a0..a7b1fa0ced0 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -34,15 +34,9 @@ static constexpr bool is_bootloader_menu_byte(uint8_t byte) { return byte == 0 || byte == '\r' || byte == '\n' || (byte >= 0x20 && byte <= 0x7E); } -static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { - // Calculate Z-Wave frame checksum - // XOR all bytes between SOF and checksum position (exclusive) +static uint8_t calculate_frame_checksum(const uint8_t *data, size_t length) { // Initial value is 0xFF per Z-Wave protocol specification - uint8_t checksum = 0xFF; - for (uint8_t i = 1; i < length - 1; i++) { - checksum ^= data[i]; - } - return checksum; + return xor_bytes(data + 1, length - 2, 0xFF); } ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; } From cef9418a0f77a47410ee1e9a43a9b26026ee0e5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 03:20:28 -1000 Subject: [PATCH 028/179] [i2c][uart][spi] Add new_i2c_component, new_uart_component and new_spi_component (#20908) --- esphome/codegen.py | 1 + esphome/components/i2c/__init__.py | 11 ++++ esphome/components/spi/__init__.py | 12 ++++ esphome/components/uart/__init__.py | 12 ++++ esphome/cpp_helpers.py | 22 ++++++- .../components/test_new_bus_component.py | 54 +++++++++++++++++ tests/unit_tests/test_cpp_helpers.py | 58 +++++++++++++++++++ 7 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/components/test_new_bus_component.py diff --git a/esphome/codegen.py b/esphome/codegen.py index 9ed36de0d55..64f2ca9f04d 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -58,6 +58,7 @@ from esphome.cpp_helpers import ( # noqa: F401 extract_registry_entry_config, get_slot_count, gpio_pin_expression, + new_bus_component, past_safe_mode, register_component, register_parented, diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 5bf64609f53..84f6d0c2e66 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Coroutine import logging import re import sys @@ -394,6 +395,16 @@ async def register_i2c_device(var: MockObj, config: ConfigType) -> None: cg.add(var.set_i2c_address(config[CONF_ADDRESS])) +def new_i2c_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Create a component on an i2c bus: construct, register, attach to the bus. + + Extra args go to the constructor. + """ + return cg.new_bus_component(register_i2c_device, config, *args) + + def final_validate_device_schema( name: str, *, diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index d6949cc5909..92d5baccf76 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Coroutine import re from typing import Any @@ -504,6 +505,17 @@ async def register_spi_device( cg.add(var.set_psram_dma(psram_dma)) +def new_spi_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Create a component on an SPI bus: construct, register, attach to the bus. + + Extra args go to the constructor. Write-only devices call register_spi_device + themselves with write_only=True. + """ + return cg.new_bus_component(register_spi_device, config, *args) + + def final_validate_device_schema( name: str, *, require_mosi: bool, require_miso: bool ) -> cv.Schema: diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index fc811e385f4..dd6ae47bbfa 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,7 +1,9 @@ +from collections.abc import Coroutine from dataclasses import dataclass, field from logging import getLogger import math import re +from typing import Any from esphome import automation, pins import esphome.codegen as cg @@ -591,6 +593,16 @@ async def register_uart_device(var, config): cg.add(var.set_uart_parent(parent)) +def new_uart_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Create a component on a UART bus: construct, register, attach to the bus. + + Extra args go to the constructor. + """ + return cg.new_bus_component(register_uart_device, config, *args) + + @automation.register_action( "uart.write", UARTWriteAction, diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index fc44d27f472..c744b09728e 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,8 +1,10 @@ -from collections.abc import Callable, Hashable +from collections.abc import Awaitable, Callable, Hashable from dataclasses import dataclass, field import logging +from typing import Any from esphome.const import ( + CONF_ID, CONF_SAFE_MODE, CONF_SETUP_PRIORITY, CONF_TYPE_ID, @@ -12,11 +14,13 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, coroutine, coroutine_with_priority from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import ( + MockObj, RawStatement, add, add_define, add_global, get_variable, + new_Pvariable, ) from esphome.cpp_types import App from esphome.helpers import cpp_string_escape @@ -294,6 +298,22 @@ async def register_component(var, config): return var +async def new_bus_component( + register_device: Callable[[MockObj, ConfigType], Awaitable[None]], + config: ConfigType, + *args: Any, +) -> MockObj: + """Create a component, register it and attach it to its bus. + + Extra args go to the constructor. Bus wrappers must be plain functions + returning this coroutine, so the component source stays their caller. + """ + var = new_Pvariable(config[CONF_ID], *args) + await register_component(var, config) + await register_device(var, config) + return var + + async def register_parented(var, value): if isinstance(value, ID): paren = await get_variable(value) diff --git a/tests/unit_tests/components/test_new_bus_component.py b/tests/unit_tests/components/test_new_bus_component.py new file mode 100644 index 00000000000..54f947d6257 --- /dev/null +++ b/tests/unit_tests/components/test_new_bus_component.py @@ -0,0 +1,54 @@ +"""Tests for the new__component helpers.""" + +from collections.abc import Callable +import inspect +from typing import Any +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from esphome import cpp_helpers +from esphome.components import i2c, spi, uart +from esphome.const import CONF_ID + + +@pytest.mark.parametrize( + ("helper", "module", "register_name"), + [ + (i2c.new_i2c_component, i2c, "register_i2c_device"), + (uart.new_uart_component, uart, "register_uart_device"), + (spi.new_spi_component, spi, "register_spi_device"), + ], +) +@pytest.mark.asyncio +async def test_creates_registers_and_attaches_in_order( + helper: Callable[..., Any], module: Any, register_name: str +) -> None: + config = {CONF_ID: "my_id"} + var = MagicMock(name="var") + order = MagicMock() + order.new_Pvariable.return_value = var + with ( + patch.object(cpp_helpers, "new_Pvariable", order.new_Pvariable), + patch.object( + cpp_helpers, "register_component", AsyncMock(side_effect=order.register) + ), + patch.object(module, register_name, AsyncMock(side_effect=order.bus)), + ): + result = await helper(config, 1, "two") + + assert result is var + assert order.mock_calls == [ + call.new_Pvariable("my_id", 1, "two"), + call.register(var, config), + call.bus(var, config), + ] + + +@pytest.mark.parametrize( + "helper", + [i2c.new_i2c_component, uart.new_uart_component, spi.new_spi_component], +) +def test_wrappers_are_plain_functions(helper: Callable[..., Any]) -> None: + # An async wrapper would stay on the stack and become the component source. + assert not inspect.iscoroutinefunction(helper) diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 725c1daebb4..7f56faddfbc 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -1,4 +1,8 @@ +import importlib.util import logging +from pathlib import Path +import sys +from types import ModuleType from unittest.mock import Mock import pytest @@ -243,3 +247,57 @@ def test_slot_counter_request_after_emit_raises() -> None: assert _define_value("TEST_SLOT_COUNT_TOO_LATE") == "1" with pytest.raises(ValueError, match="TEST_SLOT_COUNT_TOO_LATE"): request() + + +def _load_module( + monkeypatch: pytest.MonkeyPatch, name: str, path: Path, source: str +) -> ModuleType: + path.write_text(source, encoding="utf-8") + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, name, module) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize( + ("wrapper", "expected"), + [ + # A plain wrapper returns the coroutine, so its frame is gone when + # register_component looks for the source; an async one is still on the stack. + ("def new_component(config):\n return ", "fake_dev"), + ("async def new_component(config):\n return await ", "fake_bus"), + ], +) +@pytest.mark.asyncio +async def test_new_bus_component_source_is_the_caller( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, wrapper: str, expected: str +) -> None: + bus = _load_module( + monkeypatch, + "esphome.components.fake_bus", + tmp_path / "fake_bus.py", + "from esphome import cpp_helpers as ch\n" + "async def register_device(var, config):\n" + " pass\n" + f"{wrapper}ch.new_bus_component(register_device, config)\n", + ) + dev = _load_module( + monkeypatch, + "esphome.components.fake_dev", + tmp_path / "fake_dev.py", + "async def to_code(new_component, config):\n" + " return await new_component(config)\n", + ) + base = Mock() + base.__str__ = lambda self: "foo.bar" + var = Mock(base=base) + monkeypatch.setattr(ch, "new_Pvariable", Mock(return_value=var)) + monkeypatch.setattr(ch, "CORE", Mock(component_ids=["foo.bar"], data={})) + monkeypatch.setattr(ch, "add", Mock()) + monkeypatch.setattr(ch, "App", Mock()) + source = Mock(return_value=1) + monkeypatch.setattr(ch, "register_component_source", source) + result = await dev.to_code(bus.new_component, {const.CONF_ID: "foo.bar"}) + assert result is var + source.assert_called_once_with(expected) From 91dfb92c60c30748ce0eaae8dd668c5380a9a29c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 03:22:29 -1000 Subject: [PATCH 029/179] [esp32_ble] Test ble.enabled, ble.enable and ble.disable (#20838) --- tests/components/esp32_ble/common.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/components/esp32_ble/common.yaml b/tests/components/esp32_ble/common.yaml index 76b35fc8f8b..b2114f5dc41 100644 --- a/tests/components/esp32_ble/common.yaml +++ b/tests/components/esp32_ble/common.yaml @@ -1,2 +1,13 @@ esp32_ble: io_capability: keyboard_only + +button: + - platform: template + name: Toggle BLE + on_press: + - if: + condition: ble.enabled + then: + - ble.disable: + else: + - ble.enable: From d08c845c65cef5fbb7e1367a22df917fab2f8c0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:36:49 -1000 Subject: [PATCH 030/179] [components] Create I2C devices with new_i2c_component (#20909) --- esphome/components/ade7880/sensor.py | 4 +--- esphome/components/ads1115/__init__.py | 5 +---- esphome/components/ags10/sensor.py | 5 +---- esphome/components/aht10/sensor.py | 5 +---- esphome/components/aic3204/audio_dac.py | 6 ++---- esphome/components/am2315c/sensor.py | 5 +---- esphome/components/am2320/sensor.py | 5 +---- esphome/components/apds9930/sensor.py | 5 +---- esphome/components/apds9960/__init__.py | 5 +---- esphome/components/as5600/__init__.py | 5 +---- esphome/components/as7341/sensor.py | 5 +---- esphome/components/as734x/__init__.py | 5 +---- esphome/components/at581x/__init__.py | 4 +--- esphome/components/bh1745/__init__.py | 5 +---- esphome/components/bm8563/time.py | 6 ++---- esphome/components/bme680/sensor.py | 5 +---- esphome/components/bme680_bsec/__init__.py | 4 +--- esphome/components/bmi160/sensor.py | 5 +---- esphome/components/bmp085/sensor.py | 5 +---- esphome/components/bq25186/__init__.py | 4 +--- esphome/components/bq27220/sensor.py | 5 +---- esphome/components/bq76972/__init__.py | 5 +---- esphome/components/ccs811/sensor.py | 5 +---- esphome/components/dht12/sensor.py | 5 +---- esphome/components/dps310/sensor.py | 5 +---- esphome/components/ds248x/__init__.py | 6 ++---- esphome/components/ds3231/__init__.py | 5 +---- esphome/components/ee895/sensor.py | 5 +---- esphome/components/emc2101/__init__.py | 6 ++---- esphome/components/ens210/sensor.py | 5 +---- esphome/components/es7210/audio_adc.py | 6 ++---- esphome/components/es7243e/audio_adc.py | 6 ++---- esphome/components/es8156/audio_dac.py | 4 +--- esphome/components/es8311/audio_dac.py | 6 ++---- esphome/components/es8388/audio_dac.py | 5 +---- esphome/components/es9038q2m_katana/audio_dac.py | 6 ++---- esphome/components/ezo_pmp/__init__.py | 4 +--- esphome/components/fusb302b/__init__.py | 5 +---- esphome/components/gdk101/__init__.py | 5 +---- esphome/components/gp8403/__init__.py | 6 ++---- esphome/components/grove_gas_mc_v2/sensor.py | 5 +---- esphome/components/grove_tb6612fng/__init__.py | 4 +--- esphome/components/hdc1080/sensor.py | 5 +---- esphome/components/hdc2010/sensor.py | 5 +---- esphome/components/hdc2080/sensor.py | 5 +---- esphome/components/hdc302x/sensor.py | 5 +---- esphome/components/hm3301/sensor.py | 5 +---- esphome/components/hmc5883l/sensor.py | 5 +---- esphome/components/honeywell_hih_i2c/sensor.py | 5 +---- esphome/components/honeywellabp2_i2c/sensor.py | 5 +---- esphome/components/hte501/sensor.py | 5 +---- esphome/components/htu21d/sensor.py | 5 +---- esphome/components/htu31d/sensor.py | 5 +---- esphome/components/husb238/__init__.py | 5 +---- esphome/components/hyt271/sensor.py | 5 +---- esphome/components/i2c_device/__init__.py | 4 +--- esphome/components/ina219/sensor.py | 5 +---- esphome/components/ina226/sensor.py | 5 +---- esphome/components/ina3221/sensor.py | 5 +---- esphome/components/kmeteriso/sensor.py | 5 +---- esphome/components/lc709203f/sensor.py | 5 +---- esphome/components/lp5562/__init__.py | 5 +---- esphome/components/lps22/sensor.py | 5 +---- esphome/components/ltr390/sensor.py | 5 +---- esphome/components/ltr501/sensor.py | 5 +---- esphome/components/ltr_als_ps/sensor.py | 5 +---- esphome/components/m5stack_8angle/__init__.py | 5 +---- esphome/components/max17043/sensor.py | 4 +--- esphome/components/max17048/sensor.py | 5 +---- esphome/components/max44009/sensor.py | 5 +---- esphome/components/max6956/__init__.py | 4 +--- esphome/components/max9611/sensor.py | 5 +---- esphome/components/mcp23016/__init__.py | 4 +--- esphome/components/mcp4461/__init__.py | 7 ++----- esphome/components/mcp4725/output.py | 4 +--- esphome/components/mcp4728/__init__.py | 5 +---- esphome/components/mcp47a1/output.py | 4 +--- esphome/components/mics_4514/sensor.py | 5 +---- esphome/components/mlx90393/sensor.py | 5 +---- esphome/components/mlx90614/sensor.py | 5 +---- esphome/components/mmc5603/sensor.py | 5 +---- esphome/components/mmc5983/sensor.py | 5 +---- esphome/components/mpl3115a2/sensor.py | 5 +---- esphome/components/mpu6050/sensor.py | 5 +---- esphome/components/mpu6886/sensor.py | 5 +---- esphome/components/ms5611/sensor.py | 5 +---- esphome/components/ms8607/sensor.py | 5 +---- esphome/components/msa3xx/__init__.py | 5 +---- esphome/components/nau7802/sensor.py | 4 +--- esphome/components/npi19/sensor.py | 5 +---- esphome/components/pca6416a/__init__.py | 4 +--- esphome/components/pca9505/__init__.py | 4 +--- esphome/components/pcf8574/__init__.py | 4 +--- esphome/components/pcm5122/audio_dac.py | 4 +--- esphome/components/pi4ioe5v6408/__init__.py | 4 +--- esphome/components/pm2005/sensor.py | 5 +---- esphome/components/pmsa003i/sensor.py | 5 +---- esphome/components/pmwcs3/sensor.py | 5 +---- esphome/components/qmc5883l/sensor.py | 5 +---- esphome/components/qmp6988/sensor.py | 5 +---- esphome/components/rx8111/time.py | 5 +---- esphome/components/rx8130/time.py | 5 +---- esphome/components/scd30/sensor.py | 5 +---- esphome/components/scd4x/sensor.py | 4 +--- esphome/components/sen5x/sensor.py | 4 +--- esphome/components/sen6x/sensor.py | 4 +--- esphome/components/sfa30/sensor.py | 5 +---- esphome/components/sfa40/sensor.py | 4 +--- esphome/components/sgp30/sensor.py | 5 +---- esphome/components/sgp4x/sensor.py | 5 +---- esphome/components/sht3xd/sensor.py | 5 +---- esphome/components/sht4x/sensor.py | 5 +---- esphome/components/shtcx/sensor.py | 5 +---- esphome/components/sps30/sensor.py | 4 +--- esphome/components/stcc4/sensor.py | 5 +---- esphome/components/sx1509/__init__.py | 4 +--- esphome/components/sy6970/__init__.py | 7 ++----- esphome/components/tas2780/audio_dac.py | 6 ++---- esphome/components/tas58xx/audio_dac.py | 4 +--- esphome/components/tca8418/__init__.py | 6 ++---- esphome/components/tca9548a/__init__.py | 4 +--- esphome/components/tca9555/__init__.py | 4 +--- esphome/components/tcs34725/sensor.py | 5 +---- esphome/components/tem3200/sensor.py | 5 +---- esphome/components/tfluna/__init__.py | 5 +---- esphome/components/tlc59208f/__init__.py | 5 +---- esphome/components/tsl2591/sensor.py | 5 +---- esphome/components/ttp229_lsf/__init__.py | 5 +---- esphome/components/veml7700/sensor.py | 5 +---- esphome/components/waveshare_io_ch32v003/__init__.py | 4 +--- esphome/components/xdb401/sensor.py | 5 +---- esphome/components/xgzp68xx/sensor.py | 5 +---- esphome/components/xl9535/__init__.py | 4 +--- tests/unit_tests/components/test_pcf8574.py | 4 +--- 134 files changed, 147 insertions(+), 507 deletions(-) diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index 93c279e2359..541d37603e1 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -282,9 +282,7 @@ async def power_channel(config: ConfigType) -> MockObj: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if irq0_pin := config.get(CONF_IRQ0_PIN): pin = await cg.gpio_pin_expression(irq0_pin) diff --git a/esphome/components/ads1115/__init__.py b/esphome/components/ads1115/__init__.py index 1b8405322fa..7358c88db7c 100644 --- a/esphome/components/ads1115/__init__.py +++ b/esphome/components/ads1115/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -27,8 +26,6 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_continuous_mode(config[CONF_CONTINUOUS_MODE])) diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 8fd18671f81..407245407a2 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -4,7 +4,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_ID, CONF_MODE, CONF_TVOC, CONF_VALUE, @@ -58,9 +57,7 @@ FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency= async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TVOC, var.set_tvoc) diff --git a/esphome/components/aht10/sensor.py b/esphome/components/aht10/sensor.py index 974e7d4df4c..6065d702bde 100644 --- a/esphome/components/aht10/sensor.py +++ b/esphome/components/aht10/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, CONF_VARIANT, DEVICE_CLASS_HUMIDITY, @@ -49,9 +48,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_variant(config[CONF_VARIANT])) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index b4942fe3908..52cd24b2021 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -3,7 +3,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MODE +from esphome.const import CONF_MODE from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -41,6 +41,4 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/am2315c/sensor.py b/esphome/components/am2315c/sensor.py index 39ded956c3f..9e9da3a34ce 100644 --- a/esphome/components/am2315c/sensor.py +++ b/esphome/components/am2315c/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -42,9 +41,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/am2320/sensor.py b/esphome/components/am2320/sensor.py index 30a1b1d8a34..b70ac70e6b2 100644 --- a/esphome/components/am2320/sensor.py +++ b/esphome/components/am2320/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/apds9930/sensor.py b/esphome/components/apds9930/sensor.py index 2b169271c68..9895985438d 100644 --- a/esphome/components/apds9930/sensor.py +++ b/esphome/components/apds9930/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor from esphome.components.const import UNIT_COUNTS import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_ILLUMINANCE, DEVICE_CLASS_ILLUMINANCE, STATE_CLASS_MEASUREMENT, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_led_drive(config[CONF_LED_DRIVE])) cg.add(var.set_proximity_gain(config[CONF_PROXIMITY_GAIN])) cg.add(var.set_ambient_gain(config[CONF_AMBIENT_LIGHT_GAIN])) diff --git a/esphome/components/apds9960/__init__.py b/esphome/components/apds9960/__init__.py index 8c19eec5ca1..e32984970f5 100644 --- a/esphome/components/apds9960/__init__.py +++ b/esphome/components/apds9960/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -60,9 +59,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_led_drive(config[CONF_LED_DRIVE])) cg.add(var.set_proximity_gain(config[CONF_PROXIMITY_GAIN])) cg.add(var.set_ambient_gain(config[CONF_AMBIENT_LIGHT_GAIN])) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index 3cc5ed5cc1a..d695e34235f 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_DIR_PIN, CONF_DIRECTION, CONF_HYSTERESIS, - CONF_ID, CONF_POWER_MODE, CONF_RANGE, CONF_WATCHDOG, @@ -209,9 +208,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_direction(config[CONF_DIRECTION])) cg.add(var.set_watchdog(config[CONF_WATCHDOG])) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index 3afab8422f9..65307def3d0 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR, CONF_GAIN, - CONF_ID, DEVICE_CLASS_EMPTY, ICON_BRIGHTNESS_5, STATE_CLASS_MEASUREMENT, @@ -97,9 +96,7 @@ SENSORS = { async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_gain(config[CONF_GAIN])) cg.add(var.set_atime(config[CONF_ATIME])) diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index 61eeaa5a35b..dc45ddd1308 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__init__.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CALIBRATION, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_TYPE, CONF_UPDATE_INTERVAL, ) @@ -166,9 +165,7 @@ def _per_band( async def to_code(config: ConfigType) -> None: chip = config[CONF_TYPE] cg.add_define("USE_AS7341" if chip == "AS7341" else "USE_AS7343") - var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&as734x::{chip}_CHIP")) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config, cg.RawExpression(f"&as734x::{chip}_CHIP")) cg.add(var.set_gain(config[CONF_GAIN])) cg.add(var.set_atime(config[CONF_ATIME])) cg.add(var.set_astep(config[CONF_ASTEP])) diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index fd1a8002921..2ba284bd223 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -73,9 +73,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) # Actions diff --git a/esphome/components/bh1745/__init__.py b/esphome/components/bh1745/__init__.py index a922340df71..daba7af4753 100644 --- a/esphome/components/bh1745/__init__.py +++ b/esphome/components/bh1745/__init__.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INTEGRATION_TIME, ) from esphome.types import ConfigType @@ -62,9 +61,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_adc_gain(config[CONF_GAIN])) cg.add(var.set_measurement_time(config[CONF_INTEGRATION_TIME])) diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index c28976eb872..6bbacf307a2 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_DURATION, CONF_ID +from esphome.const import CONF_DURATION from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -50,7 +50,5 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index 73f48526be6..8883659e979 100644 --- a/esphome/components/bme680/sensor.py +++ b/esphome/components/bme680/sensor.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_GAS_RESISTANCE, CONF_HEATER, CONF_HUMIDITY, - CONF_ID, CONF_IIR_FILTER, CONF_OVERSAMPLING, CONF_PRESSURE, @@ -115,9 +114,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 55fd1c975f5..1562b4f33b4 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -73,9 +73,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_device_id(str(config[CONF_ID]))) cg.add(var.set_temperature_offset(config[CONF_TEMPERATURE_OFFSET])) diff --git a/esphome/components/bmi160/sensor.py b/esphome/components/bmi160/sensor.py index 9ad1b19fa0e..845a37e91f5 100644 --- a/esphome/components/bmi160/sensor.py +++ b/esphome/components/bmi160/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_GYROSCOPE_X, CONF_GYROSCOPE_Y, CONF_GYROSCOPE_Z, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, ICON_ACCELERATION_X, @@ -84,9 +83,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ACCELERATION_X, var.set_accel_x_sensor) diff --git a/esphome/components/bmp085/sensor.py b/esphome/components/bmp085/sensor.py index 5eb8dae7314..898faff8531 100644 --- a/esphome/components/bmp085/sensor.py +++ b/esphome/components/bmp085/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py index 03a100d9297..26799b8ce51 100644 --- a/esphome/components/bq25186/__init__.py +++ b/esphome/components/bq25186/__init__.py @@ -338,9 +338,7 @@ CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if config[CONF_CONFIGURE_ON_BOOT]: cg.add(var.set_setup_registers(cg.ArrayInitializer(*pack_registers(config)))) diff --git a/esphome/components/bq27220/sensor.py b/esphome/components/bq27220/sensor.py index a3fcec22432..418632b2c67 100644 --- a/esphome/components/bq27220/sensor.py +++ b/esphome/components/bq27220/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CURRENT, - CONF_ID, CONF_TEMPERATURE, CONF_VOLTAGE, DEVICE_CLASS_BATTERY, @@ -108,9 +107,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/bq76972/__init__.py b/esphome/components/bq76972/__init__.py index 5ccf043c250..672d6428d26 100644 --- a/esphome/components/bq76972/__init__.py +++ b/esphome/components/bq76972/__init__.py @@ -4,7 +4,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, - CONF_ID, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, @@ -75,9 +74,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED])) cg.add(var.set_reg_disable(config[CONF_REG_DISABLED])) diff --git a/esphome/components/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index 95e51c825a8..80948a6b433 100644 --- a/esphome/components/ccs811/sensor.py +++ b/esphome/components/ccs811/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BASELINE, CONF_ECO2, CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, CONF_TVOC, CONF_VERSION, @@ -61,9 +60,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ECO2, var.set_co2) diff --git a/esphome/components/dht12/sensor.py b/esphome/components/dht12/sensor.py index 88877b2dc26..677b158633a 100644 --- a/esphome/components/dht12/sensor.py +++ b/esphome/components/dht12/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -42,9 +41,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/dps310/sensor.py b/esphome/components/dps310/sensor.py index e3827b484c4..7d6ab7b66c2 100644 --- a/esphome/components/dps310/sensor.py +++ b/esphome/components/dps310/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -50,9 +49,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index 3a2cb34747a..02faa806f7a 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE +from esphome.const import CONF_SLEEP_PIN, CONF_TYPE from esphome.types import ConfigType CODEOWNERS = ["@tomwellnitz"] @@ -86,9 +86,7 @@ def get_channel_count(config: ConfigType) -> int: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_active_pullup(config[CONF_ACTIVE_PULLUP])) cg.add(var.set_channel_count(get_channel_count(config))) diff --git a/esphome/components/ds3231/__init__.py b/esphome/components/ds3231/__init__.py index 262c951e80f..b3e496a883c 100644 --- a/esphome/components/ds3231/__init__.py +++ b/esphome/components/ds3231/__init__.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@linkedupbits"] @@ -23,9 +22,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) automation.register_apply_action( diff --git a/esphome/components/ee895/sensor.py b/esphome/components/ee895/sensor.py index a7110bd6d2e..fb0f8c59551 100644 --- a/esphome/components/ee895/sensor.py +++ b/esphome/components/ee895/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CO2, - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -53,9 +52,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 024ff3c8937..19661dbb584 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_RESOLUTION +from esphome.const import CONF_INVERTED, CONF_RESOLUTION from esphome.types import ConfigType CODEOWNERS = ["@ellull"] @@ -71,9 +71,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if pwm_config := config.get(CONF_PWM): cg.add(var.set_dac_mode(False)) diff --git a/esphome/components/ens210/sensor.py b/esphome/components/ens210/sensor.py index cb49986f7c5..a390b24eccb 100644 --- a/esphome/components/ens210/sensor.py +++ b/esphome/components/ens210/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/es7210/audio_adc.py b/esphome/components/es7210/audio_adc.py index c2dd40b3e86..09f575629d6 100644 --- a/esphome/components/es7210/audio_adc.py +++ b/esphome/components/es7210/audio_adc.py @@ -3,7 +3,7 @@ from esphome.components import esp32, i2c from esphome.components.audio_adc import AudioAdc from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_ESP32S2 import esphome.config_validation as cv -from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_MIC_GAIN, CONF_SAMPLE_RATE +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_MIC_GAIN, CONF_SAMPLE_RATE from esphome.core import CORE from esphome.types import ConfigType @@ -66,9 +66,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) if channel_gains := config.get(CONF_CHANNEL_GAINS): diff --git a/esphome/components/es7243e/audio_adc.py b/esphome/components/es7243e/audio_adc.py index 49161339822..c354ffcc6fb 100644 --- a/esphome/components/es7243e/audio_adc.py +++ b/esphome/components/es7243e/audio_adc.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_adc import AudioAdc import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MIC_GAIN +from esphome.const import CONF_MIC_GAIN from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -28,8 +28,6 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_mic_gain(config[CONF_MIC_GAIN])) diff --git a/esphome/components/es8156/audio_dac.py b/esphome/components/es8156/audio_dac.py index ebba75e70dd..e00f67571e6 100644 --- a/esphome/components/es8156/audio_dac.py +++ b/esphome/components/es8156/audio_dac.py @@ -48,6 +48,4 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/es8311/audio_dac.py b/esphome/components/es8311/audio_dac.py index 036ee93bd3e..785c91fe1c8 100644 --- a/esphome/components/es8311/audio_dac.py +++ b/esphome/components/es8311/audio_dac.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_MIC_GAIN, CONF_SAMPLE_RATE +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_MIC_GAIN, CONF_SAMPLE_RATE from esphome.types import ConfigType CODEOWNERS = ["@kroimon", "@kahrendt"] @@ -58,9 +58,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_mic_gain(config[CONF_MIC_GAIN])) diff --git a/esphome/components/es8388/audio_dac.py b/esphome/components/es8388/audio_dac.py index 2616cbfa536..ac1cc9f8177 100644 --- a/esphome/components/es8388/audio_dac.py +++ b/esphome/components/es8388/audio_dac.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@P4uLT"] @@ -22,6 +21,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/es9038q2m_katana/audio_dac.py b/esphome/components/es9038q2m_katana/audio_dac.py index 0568e5e202a..5f2a5ab557e 100644 --- a/esphome/components/es9038q2m_katana/audio_dac.py +++ b/esphome/components/es9038q2m_katana/audio_dac.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import audio_dac, i2c import esphome.config_validation as cv -from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_SAMPLE_RATE +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_SAMPLE_RATE from esphome.types import ConfigType CONF_DEEMPHASIS = "deemphasis" @@ -73,9 +73,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_filter_shape(config[CONF_FILTER_SHAPE])) cg.add( diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index aed071e8cf5..9d2c90529c0 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -34,9 +34,7 @@ CONFIG_SCHEMA = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) EZO_PMP_NO_ARGS_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index c9dd24463b4..26bf49b28e3 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -4,7 +4,6 @@ from esphome.components import i2c, sensor, text_sensor import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, - CONF_ID, CONF_INTERRUPT_PIN, CONF_ON_CONNECT, CONF_ON_DISCONNECT, @@ -99,9 +98,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - var = cg.new_Pvariable(config[CONF_ID], interrupt_pin) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config, interrupt_pin) cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/gdk101/__init__.py b/esphome/components/gdk101/__init__.py index e0ede6136dc..4463da8e685 100644 --- a/esphome/components/gdk101/__init__.py +++ b/esphome/components/gdk101/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Szewcson"] @@ -29,6 +28,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/gp8403/__init__.py b/esphome/components/gp8403/__init__.py index 59bab37d255..d19228fea74 100644 --- a/esphome/components/gp8403/__init__.py +++ b/esphome/components/gp8403/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MODEL, CONF_VOLTAGE +from esphome.const import CONF_MODEL, CONF_VOLTAGE from esphome.types import ConfigType CODEOWNERS = ["@jesserockz", "@sebydocky"] @@ -35,8 +35,6 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_model(config[CONF_MODEL])) cg.add(var.set_voltage(config[CONF_VOLTAGE])) diff --git a/esphome/components/grove_gas_mc_v2/sensor.py b/esphome/components/grove_gas_mc_v2/sensor.py index da687c4cd34..3378d94bf41 100644 --- a/esphome/components/grove_gas_mc_v2/sensor.py +++ b/esphome/components/grove_gas_mc_v2/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CARBON_MONOXIDE, CONF_ETHANOL, - CONF_ID, CONF_NITROGEN_DIOXIDE, CONF_TVOC, DEVICE_CLASS_CARBON_MONOXIDE, @@ -68,9 +67,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) for key in [CONF_TVOC, CONF_CARBON_MONOXIDE, CONF_NITROGEN_DIOXIDE, CONF_ETHANOL]: if sensor_config := config.get(key): diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index 0874613c5cf..501071f42b9 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -38,9 +38,7 @@ CONFIG_SCHEMA = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) automation.register_apply_action( diff --git a/esphome/components/hdc1080/sensor.py b/esphome/components/hdc1080/sensor.py index c35488c96c3..4f5dc69a6c9 100644 --- a/esphome/components/hdc1080/sensor.py +++ b/esphome/components/hdc1080/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/hdc2010/sensor.py b/esphome/components/hdc2010/sensor.py index 9837b9af333..93b469d20d8 100644 --- a/esphome/components/hdc2010/sensor.py +++ b/esphome/components/hdc2010/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/hdc2080/sensor.py b/esphome/components/hdc2080/sensor.py index 63625bbf948..3e511f6a880 100644 --- a/esphome/components/hdc2080/sensor.py +++ b/esphome/components/hdc2080/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -45,9 +44,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 604730f971a..94239ce0310 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -8,7 +8,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DURATION, CONF_HUMIDITY, - CONF_ID, CONF_POWER, CONF_POWER_MODE, CONF_TEMPERATURE, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temp_sensor) diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index d5e15f7c620..a8eefa4865a 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -6,7 +6,6 @@ from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE from esphome.components.const import CONF_AQI import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -89,9 +88,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) diff --git a/esphome/components/hmc5883l/sensor.py b/esphome/components/hmc5883l/sensor.py index 229d6c8b623..72d9148b62e 100644 --- a/esphome/components/hmc5883l/sensor.py +++ b/esphome/components/hmc5883l/sensor.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, CONF_HEADING, - CONF_ID, CONF_OVERSAMPLING, CONF_RANGE, CONF_UPDATE_INTERVAL, @@ -129,9 +128,7 @@ def auto_data_rate(config: ConfigType) -> MockObj: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) cg.add(var.set_datarate(auto_data_rate(config))) diff --git a/esphome/components/honeywell_hih_i2c/sensor.py b/esphome/components/honeywell_hih_i2c/sensor.py index 8ab2cd16b8b..d06e19747f0 100644 --- a/esphome/components/honeywell_hih_i2c/sensor.py +++ b/esphome/components/honeywell_hih_i2c/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/honeywellabp2_i2c/sensor.py b/esphome/components/honeywellabp2_i2c/sensor.py index 7f41fe21939..2d0f212d812 100644 --- a/esphome/components/honeywellabp2_i2c/sensor.py +++ b/esphome/components/honeywellabp2_i2c/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -56,9 +55,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) if await sensors(CONF_PRESSURE, var.set_pressure_sensor): diff --git a/esphome/components/hte501/sensor.py b/esphome/components/hte501/sensor.py index 80d59862959..8218dc32cbc 100644 --- a/esphome/components/hte501/sensor.py +++ b/esphome/components/hte501/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index bde4b244b4a..6f8d95f63c8 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_HEATER, CONF_HUMIDITY, - CONF_ID, CONF_LEVEL, CONF_MODEL, CONF_STATUS, @@ -61,9 +60,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/htu31d/sensor.py b/esphome/components/htu31d/sensor.py index 41cc54686a4..5fed7143ed0 100644 --- a/esphome/components/htu31d/sensor.py +++ b/esphome/components/htu31d/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/husb238/__init__.py b/esphome/components/husb238/__init__.py index 92bf8c998ff..e0e4b576eb7 100644 --- a/esphome/components/husb238/__init__.py +++ b/esphome/components/husb238/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@latonita"] @@ -28,6 +27,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/hyt271/sensor.py b/esphome/components/hyt271/sensor.py index 1fe5d9e14aa..1f13d16bfbc 100644 --- a/esphome/components/hyt271/sensor.py +++ b/esphome/components/hyt271/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/i2c_device/__init__.py b/esphome/components/i2c_device/__init__.py index a118cb3f172..a79b158b2fb 100644 --- a/esphome/components/i2c_device/__init__.py +++ b/esphome/components/i2c_device/__init__.py @@ -23,6 +23,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/ina219/sensor.py b/esphome/components/ina219/sensor.py index 0a81adc785e..98246b2a819 100644 --- a/esphome/components/ina219/sensor.py +++ b/esphome/components/ina219/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_MAX_CURRENT, CONF_MAX_VOLTAGE, CONF_POWER, @@ -72,9 +71,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_shunt_resistance_ohm(config[CONF_SHUNT_RESISTANCE])) cg.add(var.set_max_current_a(config[CONF_MAX_CURRENT])) diff --git a/esphome/components/ina226/sensor.py b/esphome/components/ina226/sensor.py index 53427fc63e1..40e2e41ffed 100644 --- a/esphome/components/ina226/sensor.py +++ b/esphome/components/ina226/sensor.py @@ -7,7 +7,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_MAX_CURRENT, CONF_POWER, CONF_SHUNT_RESISTANCE, @@ -115,9 +114,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_shunt_resistance_ohm(config[CONF_SHUNT_RESISTANCE])) cg.add(var.set_max_current_a(config[CONF_MAX_CURRENT])) diff --git a/esphome/components/ina3221/sensor.py b/esphome/components/ina3221/sensor.py index e872b73b09f..69e66a3382d 100644 --- a/esphome/components/ina3221/sensor.py +++ b/esphome/components/ina3221/sensor.py @@ -7,7 +7,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_POWER, CONF_SHUNT_RESISTANCE, CONF_SHUNT_VOLTAGE, @@ -199,9 +198,7 @@ def alert_limit_register(current_a: float, shunt_ohm: float) -> int: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) bus_us, shunt_us = adc_times_us(config) register = ( diff --git a/esphome/components/kmeteriso/sensor.py b/esphome/components/kmeteriso/sensor.py index 5b7087362f9..d0637d27873 100644 --- a/esphome/components/kmeteriso/sensor.py +++ b/esphome/components/kmeteriso/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 67e5942a43b..8ebb14d2ca7 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BATTERY_VOLTAGE, - CONF_ID, CONF_SIZE, CONF_TEMPERATURE, CONF_VOLTAGE, @@ -72,9 +71,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_pack_size(config.get(CONF_SIZE))) cg.add(var.set_pack_voltage(BATTERY_VOLTAGE_OPTIONS[config[CONF_VOLTAGE]])) diff --git a/esphome/components/lp5562/__init__.py b/esphome/components/lp5562/__init__.py index 4a5b77f36bb..f6c9956d70f 100644 --- a/esphome/components/lp5562/__init__.py +++ b/esphome/components/lp5562/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@DennisGaida"] @@ -27,8 +26,6 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if config[CONF_LOGARITHMIC_DIMMING]: cg.add(var.set_logarithmic_dimming(True)) diff --git a/esphome/components/lps22/sensor.py b/esphome/components/lps22/sensor.py index f219639eeea..8590ec5fbeb 100644 --- a/esphome/components/lps22/sensor.py +++ b/esphome/components/lps22/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ltr390/sensor.py b/esphome/components/ltr390/sensor.py index 6c25729beec..69ee5020c48 100644 --- a/esphome/components/ltr390/sensor.py +++ b/esphome/components/ltr390/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_AMBIENT_LIGHT, CONF_GAIN, - CONF_ID, CONF_LIGHT, CONF_RESOLUTION, DEVICE_CLASS_EMPTY, @@ -119,9 +118,7 @@ TYPES = { async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_wfac_value(config[CONF_WINDOW_CORRECTION_FACTOR])) diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index 51378e4cddf..ca9416f924f 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM_COUNTS, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INTEGRATION_TIME, CONF_NAME, CONF_REPEAT, @@ -225,9 +224,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 223528581e9..5ebaed1f249 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM_COUNTS, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INTEGRATION_TIME, CONF_NAME, CONF_REPEAT, @@ -215,9 +214,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) diff --git a/esphome/components/m5stack_8angle/__init__.py b/esphome/components/m5stack_8angle/__init__.py index 96b9782e811..ef686174659 100644 --- a/esphome/components/m5stack_8angle/__init__.py +++ b/esphome/components/m5stack_8angle/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -29,6 +28,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index 1b621057c67..4fdc6fda544 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -49,9 +49,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BATTERY_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/max17048/sensor.py b/esphome/components/max17048/sensor.py index 30cd0ec59f0..40f676c9fe1 100644 --- a/esphome/components/max17048/sensor.py +++ b/esphome/components/max17048/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BATTERY_VOLTAGE, - CONF_ID, CONF_RATE, DEVICE_CLASS_BATTERY, DEVICE_CLASS_VOLTAGE, @@ -56,9 +55,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor) diff --git a/esphome/components/max44009/sensor.py b/esphome/components/max44009/sensor.py index 09b480335ec..a74dc8242ac 100644 --- a/esphome/components/max44009/sensor.py +++ b/esphome/components/max44009/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_MODE, DEVICE_CLASS_ILLUMINANCE, STATE_CLASS_MEASUREMENT, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) await sensor.register_sensor(var, config) cg.add(var.set_mode(config[CONF_MODE])) diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index b5d5041c968..6dd9dc0b09f 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -51,9 +51,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_brightness_mode(config[CONF_BRIGHTNESS_MODE])) cg.add(var.set_brightness_global(config[CONF_BRIGHTNESS_GLOBAL])) diff --git a/esphome/components/max9611/sensor.py b/esphome/components/max9611/sensor.py index e6309b21ccb..e1f705cdbab 100644 --- a/esphome/components/max9611/sensor.py +++ b/esphome/components/max9611/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, CONF_GAIN, - CONF_ID, CONF_POWER, CONF_SHUNT_RESISTANCE, CONF_TEMPERATURE, @@ -72,9 +71,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_current_resistor(config[CONF_SHUNT_RESISTANCE])) cg.add(var.set_gain(config[CONF_GAIN])) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 7dddf4b0422..ab77bc5a5b3 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -37,9 +37,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/mcp4461/__init__.py b/esphome/components/mcp4461/__init__.py index d5465098f29..8f12212b481 100644 --- a/esphome/components/mcp4461/__init__.py +++ b/esphome/components/mcp4461/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@p1ngb4ck"] @@ -33,12 +32,10 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable( - config[CONF_ID], + await i2c.new_i2c_component( + config, config[CONF_DISABLE_WIPER_0], config[CONF_DISABLE_WIPER_1], config[CONF_DISABLE_WIPER_2], config[CONF_DISABLE_WIPER_3], ) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) diff --git a/esphome/components/mcp4725/output.py b/esphome/components/mcp4725/output.py index 0c1224a1a7f..5dda2145b15 100644 --- a/esphome/components/mcp4725/output.py +++ b/esphome/components/mcp4725/output.py @@ -21,7 +21,5 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) await output.register_output(var, config) diff --git a/esphome/components/mcp4728/__init__.py b/esphome/components/mcp4728/__init__.py index 7156b4aa7fe..54678204bc3 100644 --- a/esphome/components/mcp4728/__init__.py +++ b/esphome/components/mcp4728/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@berfenger"] @@ -27,6 +26,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_STORE_IN_EEPROM]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config, config[CONF_STORE_IN_EEPROM]) diff --git a/esphome/components/mcp47a1/output.py b/esphome/components/mcp47a1/output.py index 91bb3b47dbb..2386ac12abb 100644 --- a/esphome/components/mcp47a1/output.py +++ b/esphome/components/mcp47a1/output.py @@ -22,7 +22,5 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) await output.register_output(var, config) diff --git a/esphome/components/mics_4514/sensor.py b/esphome/components/mics_4514/sensor.py index 3ba560c781a..1dee96e5abc 100644 --- a/esphome/components/mics_4514/sensor.py +++ b/esphome/components/mics_4514/sensor.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CARBON_MONOXIDE, CONF_ETHANOL, CONF_HYDROGEN, - CONF_ID, CONF_METHANE, CONF_NITROGEN_DIOXIDE, DEVICE_CLASS_CARBON_MONOXIDE, @@ -58,9 +57,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) for sensor_type in SENSORS: if sensor_config := config.get(sensor_type): diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index 4ff42c7d9c7..d6a777e7d88 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FILTER, CONF_GAIN, - CONF_ID, CONF_OVERSAMPLING, CONF_RESOLUTION, CONF_TEMPERATURE, @@ -129,9 +128,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_gain(GAIN[config[CONF_GAIN]])) cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) diff --git a/esphome/components/mlx90614/sensor.py b/esphome/components/mlx90614/sensor.py index e818ea708d0..8a4c4e90eff 100644 --- a/esphome/components/mlx90614/sensor.py +++ b/esphome/components/mlx90614/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, @@ -49,9 +48,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT, var.set_ambient_sensor) diff --git a/esphome/components/mmc5603/sensor.py b/esphome/components/mmc5603/sensor.py index 70c70877b75..874b774829e 100644 --- a/esphome/components/mmc5603/sensor.py +++ b/esphome/components/mmc5603/sensor.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, CONF_HEADING, - CONF_ID, CONF_UPDATE_INTERVAL, ICON_MAGNET, ICON_SCREEN_ROTATION, @@ -77,9 +76,7 @@ def auto_data_rate(config: ConfigType) -> MockObj: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_datarate(auto_data_rate(config))) sensors = sensor.sub_sensors(config) diff --git a/esphome/components/mmc5983/sensor.py b/esphome/components/mmc5983/sensor.py index 48c532b0c67..4ed73664bf7 100644 --- a/esphome/components/mmc5983/sensor.py +++ b/esphome/components/mmc5983/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_X, CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, - CONF_ID, ICON_MAGNET, STATE_CLASS_MEASUREMENT, UNIT_MICROTESLA, @@ -41,9 +40,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FIELD_STRENGTH_X, var.set_x_sensor) diff --git a/esphome/components/mpl3115a2/sensor.py b/esphome/components/mpl3115a2/sensor.py index 8235e2d6183..c97b8df2b32 100644 --- a/esphome/components/mpl3115a2/sensor.py +++ b/esphome/components/mpl3115a2/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_ALTITUDE, - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PRESSURE, var.set_pressure) diff --git a/esphome/components/mpu6050/sensor.py b/esphome/components/mpu6050/sensor.py index bd3de2d32cd..093ededcd64 100644 --- a/esphome/components/mpu6050/sensor.py +++ b/esphome/components/mpu6050/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, ICON_BRIEFCASE_DOWNLOAD, @@ -66,9 +65,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ACCEL_X, var.set_accel_x_sensor) diff --git a/esphome/components/mpu6886/sensor.py b/esphome/components/mpu6886/sensor.py index 878540d5160..aa60ad962de 100644 --- a/esphome/components/mpu6886/sensor.py +++ b/esphome/components/mpu6886/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, ICON_BRIEFCASE_DOWNLOAD, @@ -67,9 +66,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ACCEL_X, var.set_accel_x_sensor) diff --git a/esphome/components/ms5611/sensor.py b/esphome/components/ms5611/sensor.py index bf6d4528e68..d6f7012985d 100644 --- a/esphome/components/ms5611/sensor.py +++ b/esphome/components/ms5611/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -46,9 +45,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ms8607/sensor.py b/esphome/components/ms8607/sensor.py index 13377394beb..83609916ef4 100644 --- a/esphome/components/ms8607/sensor.py +++ b/esphome/components/ms8607/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, @@ -64,9 +63,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 3c491ffc898..4623e1d66ff 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -4,7 +4,6 @@ from esphome.components import i2c import esphome.config_validation as cv from esphome.const import ( CONF_CALIBRATION, - CONF_ID, CONF_MIRROR_X, CONF_MIRROR_Y, CONF_OFFSET_X, @@ -121,9 +120,7 @@ MSA_SENSOR_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_model(config[CONF_TYPE])) cg.add(var.set_range(MSA_RANGES[config[CONF_RANGE]])) diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 2647d40441a..ac46a9fb786 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -82,9 +82,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) await sensor.register_sensor(var, config) cg.add(var.set_samples_per_second(config[CONF_SAMPLES_PER_SECOND])) diff --git a/esphome/components/npi19/sensor.py b/esphome/components/npi19/sensor.py index 14dcd72384d..e3521383736 100644 --- a/esphome/components/npi19/sensor.py +++ b/esphome/components/npi19/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, @@ -40,9 +39,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 69d8935977c..33f8af9d528 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -41,9 +41,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py index d50e8205a17..4e3acb94e6e 100644 --- a/esphome/components/pca9505/__init__.py +++ b/esphome/components/pca9505/__init__.py @@ -41,9 +41,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 5ede39d7145..f1db7c29748 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -44,9 +44,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_pcf8575(config[CONF_PCF8575])) if (reset := config[CONF_RESET]) != DEFAULT_RESET: cg.add(var.set_reset(reset)) diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index 5091efabeac..cb50b101c13 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -133,9 +133,7 @@ async def pcm5122_pin_to_code(config: ConfigType) -> MockObj: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index cd901cb448d..4128114b3cc 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -46,9 +46,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_reset(config[CONF_RESET])) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): diff --git a/esphome/components/pm2005/sensor.py b/esphome/components/pm2005/sensor.py index 0e36992141b..76ca2b6862b 100644 --- a/esphome/components/pm2005/sensor.py +++ b/esphome/components/pm2005/sensor.py @@ -4,7 +4,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -65,9 +64,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: """Code generation entry point.""" - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_sensor_type(config[CONF_TYPE])) diff --git a/esphome/components/pmsa003i/sensor.py b/esphome/components/pmsa003i/sensor.py index 14f27f89ccd..04e3b1a70af 100644 --- a/esphome/components/pmsa003i/sensor.py +++ b/esphome/components/pmsa003i/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -116,9 +115,7 @@ TYPES = { async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_standard_units(config[CONF_STANDARD_UNITS])) diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index 61f89afa41b..5420317716b 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, CONF_EC, - CONF_ID, CONF_TEMPERATURE, ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, @@ -63,9 +62,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_E25, var.set_e25_sensor) diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index 06091c4f1ca..deba7eab7e0 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FIELD_STRENGTH_Y, CONF_FIELD_STRENGTH_Z, CONF_HEADING, - CONF_ID, CONF_OVERSAMPLING, CONF_RANGE, CONF_TEMPERATURE, @@ -143,9 +142,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) cg.add(var.set_datarate(config[CONF_DATA_RATE])) diff --git a/esphome/components/qmp6988/sensor.py b/esphome/components/qmp6988/sensor.py index 955e7026062..b357f03ea31 100644 --- a/esphome/components/qmp6988/sensor.py +++ b/esphome/components/qmp6988/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_IIR_FILTER, CONF_OVERSAMPLING, CONF_PRESSURE, @@ -78,9 +77,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if CONF_TEMPERATURE in config: conf = config[CONF_TEMPERATURE] diff --git a/esphome/components/rx8111/time.py b/esphome/components/rx8111/time.py index 0b166807a22..fc6a1cca0d5 100644 --- a/esphome/components/rx8111/time.py +++ b/esphome/components/rx8111/time.py @@ -4,7 +4,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@techdregs"] @@ -30,7 +29,5 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index 2082c487593..964141596fd 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@beormund"] @@ -34,7 +33,5 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index f3f4ede8c93..3c39f3e7c21 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_AUTOMATIC_SELF_CALIBRATION, CONF_CO2, CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, CONF_TEMPERATURE_OFFSET, CONF_UPDATE_INTERVAL, @@ -79,9 +78,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_automatic_self_calibration(config[CONF_AUTOMATIC_SELF_CALIBRATION])) if CONF_ALTITUDE_COMPENSATION in config: diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index e78ae2a6b22..12a07a3f63a 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -101,9 +101,7 @@ SETTING_MAP = { async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) for key, funcName in SETTING_MAP.items(): if key in config: diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index d45bb2c7916..b3fde64f820 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -219,9 +219,7 @@ SETTING_MAP = { async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) for key, funcName in SETTING_MAP.items(): if cfg := config.get(key): diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index a5c46801887..9893ef80233 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -183,9 +183,7 @@ SENSOR_MAP = { async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) diff --git a/esphome/components/sfa30/sensor.py b/esphome/components/sfa30/sensor.py index b8039af9e3e..8a07b79e149 100644 --- a/esphome/components/sfa30/sensor.py +++ b/esphome/components/sfa30/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_GAS, DEVICE_CLASS_HUMIDITY, @@ -68,9 +67,7 @@ SENSOR_MAP = { async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) for key, funcName in SENSOR_MAP.items(): if sensor_config := config.get(key): diff --git a/esphome/components/sfa40/sensor.py b/esphome/components/sfa40/sensor.py index 701660eb8fe..ca5f72de6dd 100644 --- a/esphome/components/sfa40/sensor.py +++ b/esphome/components/sfa40/sensor.py @@ -68,9 +68,7 @@ SENSOR_MAP = { async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_wait_for_ready(config[CONF_WAIT_FOR_READY])) for key, func_name in SENSOR_MAP.items(): diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index b56df3fb2e1..5d4c43fccae 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_BASELINE, CONF_COMPENSATION, CONF_ECO2, - CONF_ID, CONF_STORE_BASELINE, CONF_TEMPERATURE_SOURCE, CONF_TVOC, @@ -83,9 +82,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_ECO2, var.set_eco2_sensor) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index bf8fbe1a975..866445f2464 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -11,7 +11,6 @@ from esphome.const import ( CONF_COMPENSATION, CONF_GAIN_FACTOR, CONF_GATING_MAX_DURATION_MINUTES, - CONF_ID, CONF_INDEX_OFFSET, CONF_LEARNING_TIME_GAIN_HOURS, CONF_LEARNING_TIME_OFFSET_HOURS, @@ -100,9 +99,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if CONF_COMPENSATION in config: compensation_config = config[CONF_COMPENSATION] diff --git a/esphome/components/sht3xd/sensor.py b/esphome/components/sht3xd/sensor.py index baee146a22d..8d1aa5d7b4e 100644 --- a/esphome/components/sht3xd/sensor.py +++ b/esphome/components/sht3xd/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -50,9 +49,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_heater_enabled(config[CONF_HEATER_ENABLED])) diff --git a/esphome/components/sht4x/sensor.py b/esphome/components/sht4x/sensor.py index d491d94ba14..48c652224ab 100644 --- a/esphome/components/sht4x/sensor.py +++ b/esphome/components/sht4x/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -84,9 +83,7 @@ TYPES = { async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_precision_value(config[CONF_PRECISION])) cg.add(var.set_heater_power_value(config[CONF_HEATER_POWER])) diff --git a/esphome/components/shtcx/sensor.py b/esphome/components/shtcx/sensor.py index d6ca2b131bd..41d3e72b247 100644 --- a/esphome/components/shtcx/sensor.py +++ b/esphome/components/shtcx/sensor.py @@ -3,7 +3,6 @@ from esphome.components import i2c, sensirion_common, sensor import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -47,9 +46,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index b8a4e080213..d86d5b7ecf5 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -117,9 +117,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py index 4385164384a..b103aa39534 100644 --- a/esphome/components/stcc4/sensor.py +++ b/esphome/components/stcc4/sensor.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE, CONF_CO2, CONF_HUMIDITY, - CONF_ID, CONF_MEASUREMENT_MODE, CONF_TEMPERATURE, CONF_TEMPERATURE_SOURCE, @@ -101,9 +100,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index f96479498d6..c44c52a6fc6 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -90,9 +90,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if conf := config.get(CONF_KEYPAD): cg.add(var.set_rows_cols(conf[CONF_KEY_ROWS], conf[CONF_KEY_COLUMNS])) if ( diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 7ed2b4fe869..23aaceb6a3c 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@linkedupbits"] @@ -61,8 +60,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable( - config[CONF_ID], + await i2c.new_i2c_component( + config, config[CONF_ENABLE_STATUS_LED], config[CONF_INPUT_CURRENT_LIMIT], config[CONF_CHARGE_VOLTAGE], @@ -72,5 +71,3 @@ async def to_code(config: ConfigType) -> None: config[CONF_ENABLE_ADC], config[CONF_I2C_WATCHDOG_TIMEOUT], ) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) diff --git a/esphome/components/tas2780/audio_dac.py b/esphome/components/tas2780/audio_dac.py index 9fbee8cc297..407ae55604e 100644 --- a/esphome/components/tas2780/audio_dac.py +++ b/esphome/components/tas2780/audio_dac.py @@ -4,7 +4,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_ID, CONF_POWER_MODE +from esphome.const import CONF_CHANNEL, CONF_POWER_MODE from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -97,9 +97,7 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_amp_level(config[CONF_AMP_LEVEL])) cg.add(var.set_power_mode(config[CONF_POWER_MODE])) diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 94d37832eb4..332d994d5e9 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -168,9 +168,7 @@ for _name, _call in ( async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL]] cg.add_define(f"USE_TAS58XX_{config[CONF_MODEL].upper()}") - var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&{model.model_info}")) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config, cg.RawExpression(f"&{model.model_info}")) cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) cg.add(var.set_dac_mode(config[CONF_DAC_MODE])) diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py index 67b38dc21ee..ee9decd6680 100644 --- a/esphome/components/tca8418/__init__.py +++ b/esphome/components/tca8418/__init__.py @@ -5,7 +5,7 @@ import esphome.codegen as cg from esphome.components import gpio_expander, i2c, key_provider from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_ON_KEY +from esphome.const import CONF_INTERRUPT_PIN, CONF_ON_KEY from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -131,9 +131,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if (rows := config.get(CONF_ROWS)) is not None: columns = config[CONF_COLUMNS] diff --git a/esphome/components/tca9548a/__init__.py b/esphome/components/tca9548a/__init__.py index 368e34f3250..c28ceb015ea 100644 --- a/esphome/components/tca9548a/__init__.py +++ b/esphome/components/tca9548a/__init__.py @@ -78,9 +78,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) root_bus_id = _root_bus_id(config[CONF_I2C_ID]) if (frequency := config.get(CONF_FREQUENCY)) is not None: await _add_frequency(root_bus_id, int(frequency)) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index a039872e164..0554f9a569d 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -40,9 +40,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index de327f0eba7..12fc3475740 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -11,7 +11,6 @@ from esphome.const import ( CONF_COLOR_TEMPERATURE, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_ILLUMINANCE, CONF_INTEGRATION_TIME, DEVICE_CLASS_ILLUMINANCE, @@ -102,9 +101,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_integration_time(config[CONF_INTEGRATION_TIME])) cg.add(var.set_gain(config[CONF_GAIN])) diff --git a/esphome/components/tem3200/sensor.py b/esphome/components/tem3200/sensor.py index 2b1839c529f..7eb35ad5337 100644 --- a/esphome/components/tem3200/sensor.py +++ b/esphome/components/tem3200/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_TEMPERATURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, @@ -42,9 +41,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/tfluna/__init__.py b/esphome/components/tfluna/__init__.py index 6914894ba78..7bd7e0f5c94 100644 --- a/esphome/components/tfluna/__init__.py +++ b/esphome/components/tfluna/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@candrews"] @@ -30,6 +29,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/tlc59208f/__init__.py b/esphome/components/tlc59208f/__init__.py index 5d719e40ba2..c87478715f3 100644 --- a/esphome/components/tlc59208f/__init__.py +++ b/esphome/components/tlc59208f/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -23,6 +22,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/tsl2591/sensor.py b/esphome/components/tsl2591/sensor.py index ee6ebaeb75c..7f4e9e43db8 100644 --- a/esphome/components/tsl2591/sensor.py +++ b/esphome/components/tsl2591/sensor.py @@ -31,7 +31,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INFRARED, CONF_INTEGRATION_TIME, CONF_NAME, @@ -136,9 +135,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FULL_SPECTRUM, var.set_full_spectrum_sensor) diff --git a/esphome/components/ttp229_lsf/__init__.py b/esphome/components/ttp229_lsf/__init__.py index e89b177a3ee..1d802ec5c67 100644 --- a/esphome/components/ttp229_lsf/__init__.py +++ b/esphome/components/ttp229_lsf/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -28,6 +27,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 9ed98fc4dfd..01e71aef26b 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FULL_SPECTRUM_COUNTS, CONF_GAIN, CONF_GLASS_ATTENUATION_FACTOR, - CONF_ID, CONF_INFRARED, CONF_INTEGRATION_TIME, CONF_NAME, @@ -156,9 +155,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py index 5e53d30149b..0b89ca8b561 100644 --- a/esphome/components/waveshare_io_ch32v003/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -45,9 +45,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/xdb401/sensor.py b/esphome/components/xdb401/sensor.py index df274ac09b3..d18122b70f8 100644 --- a/esphome/components/xdb401/sensor.py +++ b/esphome/components/xdb401/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -50,9 +49,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_pressure_range_bar(config[CONF_PRESSURE_RANGE_BAR])) diff --git a/esphome/components/xgzp68xx/sensor.py b/esphome/components/xgzp68xx/sensor.py index b67b2d8a9b4..778fb48647f 100644 --- a/esphome/components/xgzp68xx/sensor.py +++ b/esphome/components/xgzp68xx/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_OVERSAMPLING, CONF_PRESSURE, CONF_TEMPERATURE, @@ -67,9 +66,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 1c6548bb5fe..85330357d2d 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -33,9 +33,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + await i2c.new_i2c_component(config) def validate_mode(mode: ConfigType) -> ConfigType: diff --git a/tests/unit_tests/components/test_pcf8574.py b/tests/unit_tests/components/test_pcf8574.py index 43751815358..e5b6519700d 100644 --- a/tests/unit_tests/components/test_pcf8574.py +++ b/tests/unit_tests/components/test_pcf8574.py @@ -22,9 +22,7 @@ async def test_reset_configuration(options: dict, expected: bool) -> None: assert config[CONF_RESET] is expected var = Mock() with ( - patch.object(pcf8574.cg, "new_Pvariable", return_value=var), - patch.object(pcf8574.cg, "register_component", new_callable=AsyncMock), - patch.object(pcf8574.i2c, "register_i2c_device", new_callable=AsyncMock), + patch.object(pcf8574.i2c, "new_i2c_component", AsyncMock(return_value=var)), patch.object(pcf8574.cg, "add"), ): await pcf8574.to_code(config) From 905267a9c30aae83323e8d9f7dcdfddbaac804a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:38:25 -1000 Subject: [PATCH 031/179] [esp32_ble] Build ble.enabled, ble.enable and ble.disable with the apply helpers (#20839) --- esphome/components/esp32_ble/__init__.py | 29 +++++------------------- esphome/components/esp32_ble/ble.h | 21 ----------------- 2 files changed, 6 insertions(+), 44 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 95a83edf76f..6c83d5e44fe 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -296,10 +296,6 @@ NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] esp32_ble_ns = cg.esphome_ns.namespace("esp32_ble") ESP32BLE = esp32_ble_ns.class_("ESP32BLE", cg.Component) -BLEEnabledCondition = esp32_ble_ns.class_("BLEEnabledCondition", automation.Condition) -BLEEnableAction = esp32_ble_ns.class_("BLEEnableAction", automation.Action) -BLEDisableAction = esp32_ble_ns.class_("BLEDisableAction", automation.Action) - IoCapability = esp32_ble_ns.enum("IoCapability") IO_CAPABILITY = { "none": IoCapability.IO_CAP_NONE, @@ -613,27 +609,14 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_ESP32_BLE_UUID") -automation.register_bare_condition( - "ble.enabled", - BLEEnabledCondition, - cv.Schema({}), -) +BLE_AUTOMATION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(ESP32BLE)}) +automation.register_apply_condition("ble.enabled", BLE_AUTOMATION_SCHEMA, "is_active()") -automation.register_bare_action( - "ble.enable", - BLEEnableAction, - cv.Schema({}), - synchronous=True, -) - - -automation.register_bare_action( - "ble.disable", - BLEDisableAction, - cv.Schema({}), - synchronous=True, -) +for _name, _call in (("ble.enable", "enable()"), ("ble.disable", "disable()")): + automation.register_apply_action( + _name, BLE_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) # ble_advertising.cpp is fully #ifdef'd on USE_ESP32_BLE_ADVERTISING, set diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index ef326e1532d..c21daabdbaa 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -259,27 +259,6 @@ class ESP32BLE final : public Component { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern ESP32BLE *global_ble; -template class BLEEnabledCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_ble != nullptr && global_ble->is_active(); } -}; - -template class BLEEnableAction final : public Action { - public: - void play(const Ts &...x) override { - if (global_ble != nullptr) - global_ble->enable(); - } -}; - -template class BLEDisableAction final : public Action { - public: - void play(const Ts &...x) override { - if (global_ble != nullptr) - global_ble->disable(); - } -}; - } // namespace esphome::esp32_ble #endif From a384aae5b18a2cae264f34e8357e5501ed3a42a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:38:45 -1000 Subject: [PATCH 032/179] [button] Add factory reset, restart and query button schema presets (#20915) --- esphome/components/button/__init__.py | 39 +++++++++++++ tests/unit_tests/components/test_button.py | 65 ++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/unit_tests/components/test_button.py diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index f27475bdca3..a4543c06c07 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -15,6 +15,11 @@ from esphome.const import ( DEVICE_CLASS_IDENTIFY, DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_DATABASE, + ICON_RESTART, + ICON_RESTART_ALERT, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( @@ -83,6 +88,40 @@ def button_schema( return _BUTTON_SCHEMA.extend(schema) +def factory_reset_button_schema(class_: MockObjClass) -> cv.Schema: + """Schema for a button that resets the connected module to factory settings. + + For a sensor or radio module's own reset, not the ESPHome node. + """ + return button_schema( + class_, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_RESTART_ALERT, + ) + + +def restart_button_schema(class_: MockObjClass) -> cv.Schema: + """Schema for a button that restarts the connected module. + + For a sensor or radio module's own restart, not the ESPHome node; the core + restart button uses the config category instead of diagnostic. + """ + return button_schema( + class_, + device_class=DEVICE_CLASS_RESTART, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + icon=ICON_RESTART, + ) + + +def query_button_schema(class_: MockObjClass) -> cv.Schema: + """Schema for a button that reads the settings back from the connected module.""" + return button_schema( + class_, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_DATABASE + ) + + _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation(CONF_ON_PRESS, "add_on_press_callback"), ) diff --git a/tests/unit_tests/components/test_button.py b/tests/unit_tests/components/test_button.py new file mode 100644 index 00000000000..3d1c3b43d47 --- /dev/null +++ b/tests/unit_tests/components/test_button.py @@ -0,0 +1,65 @@ +"""Tests for the button schema presets.""" + +from collections.abc import Callable + +import pytest +import voluptuous as vol + +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ENTITY_CATEGORY, + CONF_ICON, + DEVICE_CLASS_RESTART, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_DATABASE, + ICON_RESTART, + ICON_RESTART_ALERT, +) + +MyButton = button.button_ns.class_("MyButton", button.Button) + + +def _defaults(schema: cv.Schema) -> dict[str, str]: + return { + str(key): key.default() + for key in schema.schema + if str(key) in (CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_ICON) + and key.default is not vol.UNDEFINED + } + + +@pytest.mark.parametrize( + ("preset", "expected"), + [ + ( + button.factory_reset_button_schema, + { + CONF_DEVICE_CLASS: DEVICE_CLASS_RESTART, + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_CONFIG, + CONF_ICON: ICON_RESTART_ALERT, + }, + ), + ( + button.restart_button_schema, + { + CONF_DEVICE_CLASS: DEVICE_CLASS_RESTART, + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC, + CONF_ICON: ICON_RESTART, + }, + ), + ( + button.query_button_schema, + { + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC, + CONF_ICON: ICON_DATABASE, + }, + ), + ], +) +def test_preset_defaults( + preset: Callable[..., cv.Schema], expected: dict[str, str] +) -> None: + assert _defaults(preset(MyButton)) == expected From 73af6b05006da1331d72d6dce3088d49bc7ae2c1 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:40:39 +0200 Subject: [PATCH 033/179] [core][scheduler] run process_to_add after component phase (#20993) --- esphome/core/application.h | 2 + .../__init__.py | 23 ++++++++++ .../scheduler_loop_phase_component.cpp | 21 +++++++++ .../scheduler_loop_phase_component.h | 15 ++++++ .../scheduler_loop_phase_timeout.yaml | 20 ++++++++ .../test_scheduler_loop_phase_timeout.py | 46 +++++++++++++++++++ 6 files changed, 127 insertions(+) create mode 100644 tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py create mode 100644 tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp create mode 100644 tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h create mode 100644 tests/integration/fixtures/scheduler_loop_phase_timeout.yaml create mode 100644 tests/integration/test_scheduler_loop_phase_timeout.py diff --git a/esphome/core/application.h b/esphome/core/application.h index f9f98a67070..b19d4ae746b 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -784,6 +784,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #ifdef USE_RUNTIME_STATS loop_tail_start_us = micros(); #endif + // process scheduler items added in component phase + this->scheduler.process_to_add(); this->last_loop_ = last_op_end_time; now = last_op_end_time; // phase_guard destructor clears in_loop_ at scope exit diff --git a/tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py new file mode 100644 index 00000000000..5aaf79a186b --- /dev/null +++ b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/__init__.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@esphome/tests"] + +scheduler_loop_phase_component_ns = cg.esphome_ns.namespace( + "scheduler_loop_phase_component" +) +SchedulerLoopPhaseComponent = scheduler_loop_phase_component_ns.class_( + "SchedulerLoopPhaseComponent", cg.Component +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(SchedulerLoopPhaseComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp new file mode 100644 index 00000000000..5969dee384b --- /dev/null +++ b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.cpp @@ -0,0 +1,21 @@ +#include "scheduler_loop_phase_component.h" + +#include "esphome/core/log.h" + +namespace esphome::scheduler_loop_phase_component { + +static const char *const TAG = "scheduler_loop_phase_component"; + +void SchedulerLoopPhaseComponent::loop() { + // There might be additional wakeups after the first loop phase because the test harness is connecting. + // The second component phase runs about 5s after boot, so the timeout is queued just before a long sleep. + if (++this->loop_count_ != 2) + return; + + const uint32_t start_time = millis(); + this->set_timeout(100, [start_time]() { + ESP_LOGI(TAG, "SCHEDULER_LOOP_PHASE_RESULT elapsed=%u", static_cast(millis() - start_time)); + }); +} + +} // namespace esphome::scheduler_loop_phase_component diff --git a/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h new file mode 100644 index 00000000000..aa65a19e901 --- /dev/null +++ b/tests/integration/fixtures/external_components/scheduler_loop_phase_component/scheduler_loop_phase_component.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::scheduler_loop_phase_component { + +class SchedulerLoopPhaseComponent : public Component { + public: + void loop() override; + + protected: + uint8_t loop_count_{0}; +}; + +} // namespace esphome::scheduler_loop_phase_component diff --git a/tests/integration/fixtures/scheduler_loop_phase_timeout.yaml b/tests/integration/fixtures/scheduler_loop_phase_timeout.yaml new file mode 100644 index 00000000000..2d2d1dc677d --- /dev/null +++ b/tests/integration/fixtures/scheduler_loop_phase_timeout.yaml @@ -0,0 +1,20 @@ +esphome: + name: scheduler-loop-phase-timeout + on_boot: + priority: -100 + then: + - lambda: |- + App.set_loop_interval(5000); + +host: +api: +logger: + level: INFO + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [scheduler_loop_phase_component] + +scheduler_loop_phase_component: diff --git a/tests/integration/test_scheduler_loop_phase_timeout.py b/tests/integration/test_scheduler_loop_phase_timeout.py new file mode 100644 index 00000000000..a8b2e76a863 --- /dev/null +++ b/tests/integration/test_scheduler_loop_phase_timeout.py @@ -0,0 +1,46 @@ +"""A timeout scheduled by a component loop must affect the current sleep.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +import pytest + +from .conftest import run_binary +from .types import CompileFunction, ConfigWriter + + +@pytest.mark.asyncio +async def test_scheduler_loop_phase_timeout( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, +) -> None: + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + loop = asyncio.get_running_loop() + timeout_result: asyncio.Future[int] = loop.create_future() + + def on_log_line(line: str) -> None: + match = re.search(r"SCHEDULER_LOOP_PHASE_RESULT elapsed=(\d+)", line) + if match and not timeout_result.done(): + timeout_result.set_result(int(match.group(1))) + + async with run_binary(binary_path, line_callback=on_log_line): + try: + elapsed = await asyncio.wait_for(timeout_result, timeout=15.0) + except TimeoutError: + pytest.fail("loop-phase timeout did not fire within 15 seconds") + + assert elapsed < 1000, ( + f"loop-phase timeout should interrupt the five-second sleep, but fired after {elapsed}ms" + ) From 34f9152d61b98d85b95d8097020746906e36baf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:41:20 -1000 Subject: [PATCH 034/179] [i2c] Add write_registers for constant register tables (#20945) --- esphome/components/i2c/i2c.cpp | 14 +++++ esphome/components/i2c/i2c.h | 14 +++++ .../i2c/test_i2c_write_registers.cpp | 60 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 tests/components/i2c/test_i2c_write_registers.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 59b0cb744fb..61382ccf21e 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -53,6 +53,20 @@ ErrorCode I2CDevice::write_register16(uint16_t a_register, const uint8_t *data, return this->bus_->write_readv(this->address_, buffer, len + 2, nullptr, 0); } +bool I2CDevice::write_registers(const I2CRegValue *table, size_t count) const { + for (size_t i = 0; i < count; i++) { + I2CRegValue entry; + progmem_memcpy(&entry, &table[i], sizeof(entry)); + if (this->bus_->write_readv(this->address_, reinterpret_cast(&entry), sizeof(entry), nullptr, 0) != + ERROR_OK) { + ESP_LOGW(TAG, "Writing register 0x%02X (table entry %u) to 0x%02X failed", entry.reg, static_cast(i), + this->address_); + return false; + } + } + return true; +} + bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { if (read_register(a_register, reinterpret_cast(data), len * 2) != ERROR_OK) return false; diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 4546047d603..6a9a6533436 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -14,6 +14,13 @@ namespace esphome::i2c { class I2CDevice; // forward declaration +/// One register write for I2CDevice::write_registers(); tables of these may live in PROGMEM. +struct I2CRegValue { + uint8_t reg; + uint8_t value; +}; +static_assert(sizeof(I2CRegValue) == 2, "each entry is sent to the bus as is"); + /// Out of line so each check_chip_id() caller only pays for a call. void log_wrong_chip_id(uint8_t id, uint8_t expected); @@ -213,6 +220,13 @@ class I2CDevice { /// @return an i2c::ErrorCode ErrorCode write_register16(uint16_t a_register, const uint8_t *data, size_t len) const; + /// Write each register and value of \p table in order, stopping at the first failure. + /// The table may be in PROGMEM. + bool write_registers(const I2CRegValue *table, size_t count) const; + template bool write_registers(const I2CRegValue (&table)[N]) const { + return this->write_registers(table, N); + } + /// Read the 8-bit ID at \p a_register and accept it when it matches \p expected in the bits set in \p mask. /// Otherwise mark \p component failed and return false. bool check_chip_id(Component *component, uint8_t a_register, uint8_t expected, uint8_t mask = 0xFF) { diff --git a/tests/components/i2c/test_i2c_write_registers.cpp b/tests/components/i2c/test_i2c_write_registers.cpp new file mode 100644 index 00000000000..133c86dcc0a --- /dev/null +++ b/tests/components/i2c/test_i2c_write_registers.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/hal.h" + +namespace esphome::i2c::testing { + +class RegTableBus : public I2CBus { + public: + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + EXPECT_EQ(address, 0x18); + EXPECT_EQ(write_count, 2u); + EXPECT_EQ(read_count, 0u); + // Only this attempt fails, so any write after it would be recorded + if (this->attempts++ == this->fail_at) + return ERROR_NOT_ACKNOWLEDGED; + this->writes.emplace_back(write_buffer[0], write_buffer[1]); + return ERROR_OK; + } + + std::vector> writes; + size_t fail_at{SIZE_MAX}; + size_t attempts{0}; +}; + +static constexpr I2CRegValue INIT[] PROGMEM = {{0x01, 0x3A}, {0x00, 0x80}, {0xF9, 0x00}, {0x01, 0x00}}; + +class I2CWriteRegistersTest : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x18); + } + RegTableBus bus_; + I2CDevice device_; +}; + +TEST_F(I2CWriteRegistersTest, WritesEveryEntryInOrder) { + ASSERT_TRUE(this->device_.write_registers(INIT)); + const std::vector> expected{{0x01, 0x3A}, {0x00, 0x80}, {0xF9, 0x00}, {0x01, 0x00}}; + EXPECT_EQ(this->bus_.writes, expected); +} + +TEST_F(I2CWriteRegistersTest, StopsAtFirstFailure) { + this->bus_.fail_at = 2; + EXPECT_FALSE(this->device_.write_registers(INIT)); + const std::vector> expected{{0x01, 0x3A}, {0x00, 0x80}}; + EXPECT_EQ(this->bus_.writes, expected); +} + +TEST_F(I2CWriteRegistersTest, EmptyTableSucceeds) { + EXPECT_TRUE(this->device_.write_registers(INIT, 0)); + EXPECT_TRUE(this->bus_.writes.empty()); +} + +} // namespace esphome::i2c::testing From 62f8cc343674136751941c5dbcf62b5690788830 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:41:51 -1000 Subject: [PATCH 035/179] [uart] Add read_available helpers to drain available bytes in chunks (#20947) --- esphome/components/uart/uart.h | 54 +++++++ tests/components/uart/uart_read_available.cpp | 144 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 tests/components/uart/uart_read_available.cpp diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index 7a34a20583e..a0840aa26a7 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -1,5 +1,8 @@ #pragma once +#include +#include +#include #include #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -48,6 +51,45 @@ class UARTDevice { size_t available() { return this->parent_->available(); } + /// Pass the bytes available now to on_chunk(data, len) in N byte chunks; returning false stops. + /// Returns false only on a read error. + template bool read_available_chunks(F &&on_chunk) { + return this->read_available_chunks(on_chunk, this->available()); + } + /// Same, reading exactly count bytes; count must not exceed available(). + template bool read_available_chunks(F &&on_chunk, size_t count) { + static_assert(N > 0, "read buffer size must be non-zero"); + uint8_t buf[N]; + while (count > 0) { + const size_t len = std::min(count, N); + if (!this->read_array(buf, len)) { + return false; + } + count -= len; + if (!continue_after(on_chunk, static_cast(buf), len)) { + break; + } + } + return true; + } + + /// Same as read_available_chunks(), calling on_byte(uint8_t) once per byte. + template bool read_available(F &&on_byte) { + return this->read_available(on_byte, this->available()); + } + template bool read_available(F &&on_byte, size_t count) { + return this->read_available_chunks( + [&on_byte](const uint8_t *data, size_t len) { + for (const uint8_t *p = data; p != data + len; p++) { + if (!continue_after(on_byte, *p)) { + return false; + } + } + return true; + }, + count); + } + UARTFlushResult flush() { return this->parent_->flush(); } // Compat APIs @@ -74,6 +116,18 @@ class UARTDevice { UARTParityOptions parity = UART_CONFIG_PARITY_NONE, uint8_t data_bits = 8); protected: + /// Call f(args...) and return its result; a void callback always continues. + template static bool continue_after(F &f, A... args) { + using R = std::invoke_result_t; + if constexpr (std::is_void_v) { + f(args...); + return true; + } else { + static_assert(std::is_same_v, bool>, "callback must return void or bool"); + return f(args...); + } + } + UARTComponent *parent_{nullptr}; }; diff --git a/tests/components/uart/uart_read_available.cpp b/tests/components/uart/uart_read_available.cpp new file mode 100644 index 00000000000..10c3e7fa0ef --- /dev/null +++ b/tests/components/uart/uart_read_available.cpp @@ -0,0 +1,144 @@ +#include +#include "common.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::uart::testing { + +// Feeds bytes from a fixed vector so available() and read_array() agree like a real UART. +class FakeRxUART : public MockUARTComponent { + public: + explicit FakeRxUART(std::vector rx) : rx_(std::move(rx)) { + ON_CALL(*this, available()).WillByDefault(Invoke([this]() { return this->rx_.size() - this->pos_; })); + ON_CALL(*this, read_array(_, _)).WillByDefault(Invoke([this](uint8_t *data, size_t len) { + this->read_sizes.push_back(len); + if (this->fail_after_reads >= 0 && static_cast(this->read_sizes.size()) > this->fail_after_reads) + return false; + if (len > this->rx_.size() - this->pos_) + return false; + std::memcpy(data, this->rx_.data() + this->pos_, len); + this->pos_ += len; + return true; + })); + } + void append(std::initializer_list more) { this->rx_.insert(this->rx_.end(), more); } + size_t remaining() const { return this->rx_.size() - this->pos_; } + + std::vector read_sizes; + int fail_after_reads{-1}; + + protected: + std::vector rx_; + size_t pos_{0}; +}; + +static std::vector make_bytes(size_t n) { + std::vector v(n); + for (size_t i = 0; i < n; i++) + v[i] = static_cast(i); + return v; +} + +TEST(UARTReadAvailableTest, NothingAvailableDoesNotRead) { + ::testing::NiceMock uart(std::vector{}); + UARTDevice dev(&uart); + int calls = 0; + EXPECT_TRUE(dev.read_available([&calls](uint8_t) { calls++; })); + EXPECT_EQ(calls, 0); + EXPECT_TRUE(uart.read_sizes.empty()); +} + +TEST(UARTReadAvailableTest, ReadsEveryByteInChunks) { + ::testing::NiceMock uart(make_bytes(10)); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<4>([&got](uint8_t b) { got.push_back(b); })); + EXPECT_EQ(got, make_bytes(10)); + EXPECT_EQ(uart.read_sizes, (std::vector{4, 4, 2})); +} + +TEST(UARTReadAvailableTest, OnlyBytesAvailableOnEntry) { + ::testing::NiceMock uart(std::vector{1, 2, 3}); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<2>([&](uint8_t b) { + got.push_back(b); + if (b == 1) + uart.append({9, 9}); // arrives while draining; left for the next call + })); + EXPECT_EQ(got, (std::vector{1, 2, 3})); + EXPECT_EQ(uart.remaining(), 2u); +} + +TEST(UARTReadAvailableTest, CountReadsExactlyThatMany) { + ::testing::NiceMock uart(make_bytes(100)); + UARTDevice dev(&uart); + size_t count = 0; + EXPECT_TRUE(dev.read_available<16>([&count](uint8_t) { count++; }, 40)); + EXPECT_EQ(count, 40u); + EXPECT_EQ(uart.read_sizes, (std::vector{16, 16, 8})); + EXPECT_EQ(uart.remaining(), 60u); +} + +TEST(UARTReadAvailableTest, ReadFailureStopsAndReturnsFalse) { + ::testing::NiceMock uart(make_bytes(10)); + uart.fail_after_reads = 1; + UARTDevice dev(&uart); + std::vector got; + EXPECT_FALSE(dev.read_available<4>([&got](uint8_t b) { got.push_back(b); })); + EXPECT_EQ(got, (std::vector{0, 1, 2, 3})); + EXPECT_EQ(uart.read_sizes.size(), 2u); +} + +TEST(UARTReadAvailableTest, ByteCallbackReturningFalseStops) { + ::testing::NiceMock uart(make_bytes(10)); + UARTDevice dev(&uart); + std::vector got; + EXPECT_TRUE(dev.read_available<4>([&got](uint8_t b) { + got.push_back(b); + return b != 5; + })); + // Stops mid chunk: bytes 6 and 7 were read from the UART but not handed over, 8 and 9 were never read. + EXPECT_EQ(got, (std::vector{0, 1, 2, 3, 4, 5})); + EXPECT_EQ(uart.remaining(), 2u); +} + +TEST(UARTReadAvailableTest, ChunkCallbackGetsWholeChunks) { + ::testing::NiceMock uart(make_bytes(70)); + UARTDevice dev(&uart); + std::vector lens; + std::vector got; + EXPECT_TRUE(dev.read_available_chunks([&](const uint8_t *data, size_t len) { + lens.push_back(len); + got.insert(got.end(), data, data + len); + })); + EXPECT_EQ(lens, (std::vector{64, 6})); + EXPECT_EQ(got, make_bytes(70)); +} + +TEST(UARTReadAvailableTest, ChunkCallbackReturningFalseStops) { + ::testing::NiceMock uart(make_bytes(20)); + UARTDevice dev(&uart); + int chunks = 0; + EXPECT_TRUE(dev.read_available_chunks<8>([&chunks](const uint8_t *, size_t) { + chunks++; + return false; + })); + EXPECT_EQ(chunks, 1); + EXPECT_EQ(uart.remaining(), 12u); +} + +TEST(UARTReadAvailableTest, ChunksWithCountStopEarly) { + ::testing::NiceMock uart(make_bytes(30)); + UARTDevice dev(&uart); + std::vector lens; + EXPECT_TRUE(dev.read_available_chunks<8>( + [&lens](const uint8_t *, size_t len) { + lens.push_back(len); + return lens.size() < 2; + }, + 20)); + EXPECT_EQ(lens, (std::vector{8, 8})); + EXPECT_EQ(uart.remaining(), 14u); +} + +} // namespace esphome::uart::testing From 25b684f7628326deb3b7b86acbd89ef69c359896 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:44:41 -1000 Subject: [PATCH 036/179] [pylontech] Parse each line as it arrives with one fixed buffer (#20959) --- esphome/components/pylontech/__init__.py | 31 +++ esphome/components/pylontech/pylontech.cpp | 192 +++++++------ esphome/components/pylontech/pylontech.h | 66 +++-- .../components/pylontech/sensor/__init__.py | 5 +- .../pylontech/sensor/pylontech_sensor.cpp | 7 +- .../pylontech/sensor/pylontech_sensor.h | 5 +- .../pylontech/text_sensor/__init__.py | 5 +- .../text_sensor/pylontech_text_sensor.cpp | 7 +- .../text_sensor/pylontech_text_sensor.h | 5 +- esphome/core/defines.h | 2 + tests/components/pylontech/__init__.py | 15 ++ tests/components/pylontech/pylontech_test.cpp | 252 ++++++++++++++++++ .../fixtures/uart_mock_pylontech.yaml | 105 ++++++++ tests/integration/test_uart_mock_pylontech.py | 136 ++++++++++ 14 files changed, 716 insertions(+), 117 deletions(-) create mode 100644 tests/components/pylontech/__init__.py create mode 100644 tests/components/pylontech/pylontech_test.cpp create mode 100644 tests/integration/fixtures/uart_mock_pylontech.yaml create mode 100644 tests/integration/test_uart_mock_pylontech.py diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index f9eef4c8096..8bd57f252c1 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -1,9 +1,12 @@ +from dataclasses import dataclass, field import logging import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -24,6 +27,33 @@ PylontechBattery = pylontech_ns.class_("PylontechBattery") CV_NUM_BATTERIES = cv.int_range(1, 16) +_request_listener_slot = cg.slot_counter("PYLONTECH_LISTENER_COUNT") +_request_battery_slot = cg.slot_counter("PYLONTECH_BATTERY_COUNT") + + +@dataclass +class PylontechData: + batteries: set[tuple[str, int]] = field(default_factory=set) + + +def _get_data() -> PylontechData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = PylontechData() + return CORE.data[DOMAIN] + + +async def register_listener(config: ConfigType, listener: MockObj) -> None: + """Register a battery listener; the hub keeps one line slot per distinct battery.""" + hub = await cg.get_variable(config[CONF_PYLONTECH_ID]) + hub_key = str(hub) + _request_listener_slot(hub_key) + data = _get_data() + if (key := (hub_key, config[CONF_BATTERY])) not in data.batteries: + data.batteries.add(key) + _request_battery_slot(hub_key) + cg.add(hub.register_listener(listener)) + + PYLONTECH_COMPONENT_SCHEMA = cv.Schema( { cv.GenerateID(CONF_PYLONTECH_ID): cv.use_id(PylontechComponent), @@ -53,5 +83,6 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + cg.add_define("USE_LINE_READER") await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 50d8c814fa4..5e48e8b726c 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -1,25 +1,35 @@ #include "pylontech.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#include +#include // Helper macros +#define PARSE_TOKEN_INT(field, field_name) \ + { \ + auto val = parse_number(token_buf); \ + if (val.has_value() && in_range(*val)) { \ + (field) = *val; \ + } else { \ + ESP_LOGV(TAG, "invalid " field_name " in line %s", buffer); \ + this->reject_line_(); \ + return; \ + } \ + } + #define PARSE_INT(field, field_name) \ { \ get_token(token_buf); \ - auto val = parse_number(token_buf); \ - if (val.has_value()) { \ - (field) = val.value(); \ - } else { \ - ESP_LOGD(TAG, "invalid " field_name " in line %s", buffer.substr(0, buffer.size() - 2).c_str()); \ - return; \ - } \ + PARSE_TOKEN_INT(field, field_name) \ } #define PARSE_STR(field, field_name) \ { \ get_token(field); \ if (strlen(field) < 2) { \ - ESP_LOGD(TAG, "too short " field_name " in line %s", buffer.substr(0, buffer.size() - 2).c_str()); \ + ESP_LOGV(TAG, "too short " field_name " in line %s", buffer); \ + this->reject_line_(); \ return; \ } \ } @@ -27,17 +37,26 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech"); -static const int MAX_DATA_LENGTH_BYTES = 256; -static const uint8_t ASCII_LF = 0x0A; + +// Rejects values that would wrap in a narrowed field +template static bool in_range(int value) { + if constexpr (sizeof(T) >= sizeof(int)) { + return true; + } else { + return value >= std::numeric_limits::min() && value <= std::numeric_limits::max(); + } +} PylontechComponent::PylontechComponent() {} void PylontechComponent::dump_config() { ESP_LOGCONFIG(TAG, "pylontech:"); +#ifdef PYLONTECH_LISTENER_COUNT for (PylontechListener *listener : this->listeners_) { listener->dump_config(); } +#endif LOG_UPDATE_INTERVAL(this); } @@ -51,41 +70,70 @@ void PylontechComponent::setup() { void PylontechComponent::update() { this->write_str("pwr\n"); } void PylontechComponent::loop() { + // Drain the UART quickly; pylontech sends large bursts. Logging and publishing wait until it is idle. size_t avail = this->available(); - if (avail > 0) { - // pylontech sends a lot of data very suddenly - // we need to quickly put it all into our own buffer, otherwise the uart's buffer will overflow - int recv = 0; - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; - } - avail -= to_read; - recv += to_read; - - for (size_t i = 0; i < to_read; i++) { - buffer_[buffer_index_write_] += (char) buf[i]; - if (buf[i] == ASCII_LF || buffer_[buffer_index_write_].length() >= MAX_DATA_LENGTH_BYTES) { - // complete line received - buffer_index_write_ = (buffer_index_write_ + 1) % NUM_BUFFERS; +#ifdef PYLONTECH_LISTENER_COUNT + if (avail == 0) { + this->dispatch_one_(); + return; + } +#endif + uint8_t buf[64]; + while (avail > 0) { + size_t to_read = std::min(avail, sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; +#ifdef PYLONTECH_LISTENER_COUNT + for (size_t i = 0; i < to_read; i++) { + const char *line = this->line_reader_.feed(buf[i]); + if (line != nullptr) { + this->process_line_(line); + } else if (this->line_reader_.overflowed()) { + this->line_reader_.log_if_overflowed(TAG); + // Tlow.Id sits near the start, so a too long header still sets the column layout + const char *partial = this->line_reader_.partial(); + if (strncmp(partial, "Power", 5) == 0) { + this->parse_header_(partial); } } } - ESP_LOGV(TAG, "received %d bytes", recv); - } else { - // only process one line per call of loop() to not block esphome for too long - if (buffer_index_read_ != buffer_index_write_) { - this->process_line_(buffer_[buffer_index_read_]); - buffer_[buffer_index_read_].clear(); - buffer_index_read_ = (buffer_index_read_ + 1) % NUM_BUFFERS; - } +#endif } } -void PylontechComponent::process_line_(std::string &buffer) { - ESP_LOGV(TAG, "Read from serial: %s", buffer.substr(0, buffer.size() - 2).c_str()); +#ifdef PYLONTECH_LISTENER_COUNT +void PylontechComponent::dispatch_one_() { + for (uint8_t i = 0; this->pending_ != 0 && i < PYLONTECH_BATTERY_COUNT; i++) { + const battery_mask_t bit = 1u << i; + if ((this->pending_ & bit) == 0) { + continue; + } + this->pending_ &= ~bit; + PylontechListener::LineContents *l = &this->slots_[i]; + ESP_LOGV(TAG, "Publishing battery %u", l->bat_num); + for (PylontechListener *listener : this->listeners_) { + if (listener->get_battery() == l->bat_num) { + listener->on_line_read(l); + } + } + return; + } + if (this->rejected_ != 0) { + ESP_LOGW(TAG, "Rejected %u malformed battery lines", this->rejected_); + this->rejected_ = 0; + } +} + +void PylontechComponent::parse_header_(const char *buffer) { + this->has_tlow_id_ = strstr(buffer, "Tlow.Id") != nullptr; + ESP_LOGV(TAG, "header line %s Tlow.Id: %s", this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), + buffer); +} + +// Runs while the UART drains: no logging above VERBOSE and no publishing here +void PylontechComponent::process_line_(const char *buffer) { // clang-format off // example lines to parse: // Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St @@ -96,9 +144,7 @@ void PylontechComponent::process_line_(std::string &buffer) { // 1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal 60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal // clang-format on - PylontechListener::LineContents l{}; - - const char *cursor = buffer.c_str(); + const char *cursor = buffer; char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; // Helper Lambda to extract tokens @@ -116,7 +162,7 @@ void PylontechComponent::process_line_(std::string &buffer) { const char *start = cursor; // Find end of field - while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t' && *cursor != '\r') { + while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { cursor++; } @@ -125,23 +171,33 @@ void PylontechComponent::process_line_(std::string &buffer) { token_buf[token_len] = 0; }; - { - get_token(token_buf); - auto val = parse_number(token_buf); - if (val.has_value() && val.value() > 0) { - l.bat_num = val.value(); - } else if (strcmp(token_buf, "Power") == 0) { + get_token(token_buf); + auto bat = parse_number(token_buf); + if (!bat.has_value()) { + if (strcmp(token_buf, "Power") == 0) { // header line i.e. "Power Volt Curr" and so on - this->has_tlow_id_ = buffer.find("Tlow.Id") != std::string::npos; - ESP_LOGD(TAG, "header line %s Tlow.Id: %s", - this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), - buffer.substr(0, buffer.size() - 2).c_str()); - return; + this->parse_header_(buffer); } else { - ESP_LOGD(TAG, "unknown line %s", buffer.substr(0, buffer.size() - 2).c_str()); - return; + ESP_LOGV(TAG, "unknown line %s", buffer); } + return; } + const unsigned bat_index = static_cast(*bat) - 1u; + if (bat_index >= MAX_BATTERIES || (this->batteries_ & (1u << bat_index)) == 0) { + return; // no listener for this battery + } + const battery_mask_t bat_bit = 1u << bat_index; + // The slot index is the number of listened batteries below this one + uint8_t slot = 0; + for (battery_mask_t below = this->batteries_ & (bat_bit - 1); below != 0; below &= below - 1) { + slot++; + } + const battery_mask_t slot_bit = 1u << slot; + // Cleared until the whole line parses, so a bad line never publishes a half written slot + this->pending_ &= ~slot_bit; + PylontechListener::LineContents &l = this->slots_[slot]; + l.bat_num = *bat; + PARSE_INT(l.volt, "Volt"); PARSE_INT(l.curr, "Curr"); PARSE_INT(l.tempr, "Tempr"); @@ -165,34 +221,20 @@ void PylontechComponent::process_line_(std::string &buffer) { PARSE_STR(l.volt_st, "Volt.St"); PARSE_STR(l.curr_st, "Curr.St"); PARSE_STR(l.temp_st, "Temp.St"); - { - get_token(token_buf); - for (char &i : token_buf) { - if (i == '%') { - i = 0; - break; - } - } - auto coul_val = parse_number(token_buf); - if (coul_val.has_value()) { - l.coulomb = coul_val.value(); - } else { - ESP_LOGD(TAG, "invalid Coulomb in line %s", buffer.substr(0, buffer.size() - 2).c_str()); - return; - } + get_token(token_buf); + if (char *pct = strchr(token_buf, '%')) { + *pct = '\0'; } + PARSE_TOKEN_INT(l.coulomb, "Coulomb"); get_token(token_buf); // Skip Date get_token(token_buf); // Skip Time get_token(token_buf); // Skip B.V.St get_token(token_buf); // Skip B.T.St PARSE_INT(l.mostempr, "Mostempr"); - ESP_LOGD(TAG, "successful line %s", buffer.substr(0, buffer.size() - 2).c_str()); - - for (PylontechListener *listener : this->listeners_) { - listener->on_line_read(&l); - } + this->pending_ |= slot_bit; } +#endif } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index eae5e6e7bca..aa652056dca 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -1,24 +1,42 @@ #pragma once +#include +#include + #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/line_reader.h" #include "esphome/components/uart/uart.h" namespace esphome::pylontech { -static const uint8_t NUM_BUFFERS = 20; -static const uint8_t TEXT_SENSOR_MAX_LEN = 14; +static constexpr uint8_t TEXT_SENSOR_MAX_LEN = 14; +// Battery numbers run 1 to MAX_BATTERIES; bit n - 1 of a battery_mask_t stands for battery n +static constexpr uint8_t MAX_BATTERIES = 16; +using battery_mask_t = uint16_t; +static_assert(MAX_BATTERIES <= sizeof(battery_mask_t) * 8, "battery mask too narrow"); class PylontechListener { public: + // No default initializers: the hub's slots stay plain zeroed storage; a parse writes every field struct LineContents { - int bat_num = 0, volt, curr, tempr, tlow, thigh, vlow, vhigh, coulomb, mostempr; - char base_st[TEXT_SENSOR_MAX_LEN] = {0}, volt_st[TEXT_SENSOR_MAX_LEN] = {0}, curr_st[TEXT_SENSOR_MAX_LEN] = {0}, - temp_st[TEXT_SENSOR_MAX_LEN] = {0}; + int32_t curr, tempr, tlow, thigh, mostempr; + uint16_t volt, vlow, vhigh; // mV + uint8_t bat_num, coulomb; + char base_st[TEXT_SENSOR_MAX_LEN], volt_st[TEXT_SENSOR_MAX_LEN], curr_st[TEXT_SENSOR_MAX_LEN], + temp_st[TEXT_SENSOR_MAX_LEN]; }; - virtual void on_line_read(LineContents *line); - virtual void dump_config(); + explicit PylontechListener(uint8_t bat_num) : bat_num_(bat_num) {} + uint8_t get_battery() const { return this->bat_num_; } + + /// Called only with records for this listener's battery. + virtual void on_line_read(LineContents *line) = 0; + virtual void dump_config() = 0; + + protected: + uint8_t bat_num_; }; class PylontechComponent final : public PollingComponent, public uart::UARTDevice { @@ -33,18 +51,34 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic void setup() override; void dump_config() override; - void register_listener(PylontechListener *listener) { this->listeners_.push_back(listener); } +#ifdef PYLONTECH_LISTENER_COUNT + void register_listener(PylontechListener *listener) { + this->listeners_.push_back(listener); + this->batteries_ |= 1u << (listener->get_battery() - 1); + } protected: - void process_line_(std::string &buffer); + void process_line_(const char *buffer); + void parse_header_(const char *buffer); + void dispatch_one_(); + void reject_line_() { + if (this->rejected_ < UINT8_MAX) { + this->rejected_++; + } + } - // ring buffer - std::string buffer_[NUM_BUFFERS]; - int buffer_index_write_ = 0; - int buffer_index_read_ = 0; - bool has_tlow_id_ = false; - - std::vector listeners_{}; + // The longest line, the newer firmware header, is 182 characters + LineReader<200> line_reader_; + StaticVector listeners_; + // One slot per listened battery, in battery order; parsed while the UART drains, published one per + // loop once it is idle. A newer line for a battery replaces the unpublished one. + static_assert(PYLONTECH_BATTERY_COUNT <= MAX_BATTERIES, "more slots than batteries"); + std::array slots_; + battery_mask_t batteries_{0}; // batteries with a listener + battery_mask_t pending_{0}; // bit i set: slots_[i] is waiting to be published + bool has_tlow_id_{false}; + uint8_t rejected_{0}; // malformed battery lines since the last idle pass +#endif }; } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 40391206fbc..51aceace3e7 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -18,7 +18,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns +from .. import CONF_BATTERY, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns, register_listener PylontechSensor = pylontech_ns.class_("PylontechSensor") @@ -92,7 +92,6 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) for marker in TYPES: @@ -100,4 +99,4 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_sensor")(sens)) - cg.add(paren.register_listener(bat)) + await register_listener(config, bat) diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index dc236f47a55..bead0387bef 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -6,12 +6,10 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech.sensor"); -PylontechSensor::PylontechSensor(int8_t bat_num) { this->bat_num_ = bat_num; } - void PylontechSensor::dump_config() { ESP_LOGCONFIG(TAG, "Pylontech Sensor:\n" - " Battery %d", + " Battery %u", this->bat_num_); LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); LOG_SENSOR(" ", "Current", this->current_sensor_); @@ -25,9 +23,6 @@ void PylontechSensor::dump_config() { } void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { - if (this->bat_num_ != line->bat_num) { - return; - } if (this->voltage_sensor_ != nullptr) { this->voltage_sensor_->publish_state(((float) line->volt) / 1000.0f); } diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 1403d3445dd..6b466b74664 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -7,7 +7,7 @@ namespace esphome::pylontech { class PylontechSensor final : public PylontechListener { public: - PylontechSensor(int8_t bat_num); + explicit PylontechSensor(uint8_t bat_num) : PylontechListener(bat_num) {} void dump_config() override; SUB_SENSOR(voltage) @@ -22,9 +22,6 @@ class PylontechSensor final : public PylontechListener { SUB_SENSOR(mos_temperature) void on_line_read(LineContents *line) override; - - protected: - int8_t bat_num_; }; } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/text_sensor/__init__.py b/esphome/components/pylontech/text_sensor/__init__.py index 511eb7d542e..cb3e04a91d8 100644 --- a/esphome/components/pylontech/text_sensor/__init__.py +++ b/esphome/components/pylontech/text_sensor/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType -from .. import CONF_BATTERY, CONF_PYLONTECH_ID, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns +from .. import CONF_BATTERY, PYLONTECH_COMPONENT_SCHEMA, pylontech_ns, register_listener PylontechTextSensor = pylontech_ns.class_("PylontechTextSensor") @@ -26,7 +26,6 @@ CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - paren = await cg.get_variable(config[CONF_PYLONTECH_ID]) bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY]) for marker in MARKERS: @@ -34,4 +33,4 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_text_sensor")(var)) - cg.add(paren.register_listener(bat)) + await register_listener(config, bat) diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp index 1beb04064ec..07b8e36df03 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp @@ -6,12 +6,10 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech.textsensor"); -PylontechTextSensor::PylontechTextSensor(int8_t bat_num) { this->bat_num_ = bat_num; } - void PylontechTextSensor::dump_config() { ESP_LOGCONFIG(TAG, "Pylontech Text Sensor:\n" - " Battery %d", + " Battery %u", this->bat_num_); LOG_TEXT_SENSOR(" ", "Base state", this->base_state_text_sensor_); LOG_TEXT_SENSOR(" ", "Voltage state", this->voltage_state_text_sensor_); @@ -20,9 +18,6 @@ void PylontechTextSensor::dump_config() { } void PylontechTextSensor::on_line_read(PylontechListener::LineContents *line) { - if (this->bat_num_ != line->bat_num) { - return; - } if (this->base_state_text_sensor_ != nullptr) { this->base_state_text_sensor_->publish_state(line->base_st); } diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h index 3ba4f1fd4e7..6f229893c0c 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h @@ -7,7 +7,7 @@ namespace esphome::pylontech { class PylontechTextSensor final : public PylontechListener { public: - PylontechTextSensor(int8_t bat_num); + explicit PylontechTextSensor(uint8_t bat_num) : PylontechListener(bat_num) {} void dump_config() override; SUB_TEXT_SENSOR(base_state) @@ -16,9 +16,6 @@ class PylontechTextSensor final : public PylontechListener { SUB_TEXT_SENSOR(temperature_state) void on_line_read(LineContents *line) override; - - protected: - int8_t bat_num_; }; } // namespace esphome::pylontech diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3555b3cd1c2..8d17dc3f514 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -178,6 +178,8 @@ #define PN532_ON_TAG_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_TRIGGER_COUNT 1 +#define PYLONTECH_BATTERY_COUNT 1 +#define PYLONTECH_LISTENER_COUNT 2 #define REMOTE_BASE_DUMPER_COUNT 1 #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME diff --git a/tests/components/pylontech/__init__.py b/tests/components/pylontech/__init__.py new file mode 100644 index 00000000000..c16210128f6 --- /dev/null +++ b/tests/components/pylontech/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # Storage is sized by code generation; the gtests register three batteries. + cg.add_define("PYLONTECH_LISTENER_COUNT", 3) + cg.add_define("PYLONTECH_BATTERY_COUNT", 3) + cg.add_define("USE_LINE_READER") + + # A MULTI_CONF component gets no entry in the host build, so its to_code would never run. + manifest.multi_conf = False + manifest.to_code = to_code_testing diff --git a/tests/components/pylontech/pylontech_test.cpp b/tests/components/pylontech/pylontech_test.cpp new file mode 100644 index 00000000000..479530571c0 --- /dev/null +++ b/tests/components/pylontech/pylontech_test.cpp @@ -0,0 +1,252 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/pylontech/pylontech.h" +#include "esphome/components/uart/uart_component.h" + +namespace esphome::pylontech::testing { + +namespace { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void push_rx(std::initializer_list lines) { + for (const std::string &line : lines) { + this->rx.insert(this->rx.end(), line.begin(), line.end()); + } + } + + void write_array(const uint8_t *data, size_t len) override {} + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +// Every record any listener gets, in publish order +std::vector published; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +class TestListener final : public PylontechListener { + public: + explicit TestListener(uint8_t bat_num) : PylontechListener(bat_num) {} + void on_line_read(LineContents *line) override { + EXPECT_EQ(line->bat_num, this->bat_num_); + published.push_back(*line); + } + void dump_config() override {} +}; + +// clang-format off +constexpr const char *HEADER = + "Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time" + " B.V.St B.T.St MosTempr M.T.St\r\n"; +constexpr const char *BAT1 = + "1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% " + "2021-06-30 20:49:45 Normal Normal 22700 Normal\r\n"; +constexpr const char *BAT2 = + "2 46012 -1255 9100 5300 5500 3047 3091 SysError Low Normal Normal 4% " + "2025-11-28 17:56:33 Low Normal 7800 Normal\r\n"; +constexpr const char *NEW_HEADER = + "Power Volt Curr Tempr Tlow Tlow.Id Thigh Thigh.Id Vlow Vlow.Id Vhigh Vhigh.Id Base.St Volt.St Curr.St Temp.St " + "Coulomb Time B.V.St B.T.St MosTempr M.T.St SysAlarm.St\r\n"; +constexpr const char *NEW_BAT1 = + "1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal " + "60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n"; +// clang-format on + +// An older firmware line for battery bat with pack voltage volt (mV) +std::string bat_line(int bat, int volt) { + char buf[192]; + snprintf(buf, sizeof(buf), + "%d %d 0 17000 16000 18000 3200 3300 Idle Normal Normal Normal 50%% " + "2025-11-28 17:56:33 Normal Normal 16000 Normal\r\n", + bat, volt); + return buf; +} + +class PylontechTest : public ::testing::Test { + protected: + void SetUp() override { + published.clear(); + this->hub_.set_uart_parent(&this->uart_); + for (TestListener &listener : this->listeners_) { + this->hub_.register_listener(&listener); + } + } + + std::vector &published_() { return published; } + + MockUARTComponent uart_; + PylontechComponent hub_; + TestListener listeners_[3]{TestListener(1), TestListener(2), TestListener(3)}; +}; + +} // namespace + +TEST_F(PylontechTest, BurstIsPublishedAfterTheDrainOnePerLoop) { + this->uart_.push_rx({HEADER, BAT1, BAT2}); + this->hub_.loop(); + EXPECT_TRUE(this->uart_.rx.empty()); + EXPECT_TRUE(this->published_().empty()); + + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 2u); + this->hub_.loop(); + EXPECT_EQ(this->published_().size(), 2u); + + const auto &b1 = this->published_()[0]; + EXPECT_EQ(b1.bat_num, 1); + EXPECT_EQ(b1.volt, 50548); + EXPECT_EQ(b1.curr, 8910); + EXPECT_EQ(b1.tempr, 25000); + EXPECT_EQ(b1.tlow, 24200); + EXPECT_EQ(b1.thigh, 25000); + EXPECT_EQ(b1.vlow, 3368); + EXPECT_EQ(b1.vhigh, 3371); + EXPECT_STREQ(b1.base_st, "Charge"); + EXPECT_STREQ(b1.volt_st, "Normal"); + EXPECT_EQ(b1.coulomb, 97); + EXPECT_EQ(b1.mostempr, 22700); + + const auto &b2 = this->published_()[1]; + EXPECT_EQ(b2.bat_num, 2); + EXPECT_EQ(b2.curr, -1255); + EXPECT_STREQ(b2.base_st, "SysError"); + EXPECT_STREQ(b2.volt_st, "Low"); + EXPECT_EQ(b2.coulomb, 4); + EXPECT_EQ(b2.mostempr, 7800); +} + +TEST_F(PylontechTest, NothingIsPublishedWhileBytesKeepArriving) { + this->uart_.push_rx({HEADER, BAT1}); + this->hub_.loop(); + this->uart_.push_rx({BAT2}); + this->hub_.loop(); + EXPECT_TRUE(this->published_().empty()); + this->hub_.loop(); + this->hub_.loop(); + EXPECT_EQ(this->published_().size(), 2u); +} + +TEST_F(PylontechTest, NewerFirmwareSkipsIdColumns) { + this->uart_.push_rx({NEW_HEADER, NEW_BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + const auto &b1 = this->published_()[0]; + EXPECT_EQ(b1.volt, 49405); + EXPECT_EQ(b1.tlow, 13700); + EXPECT_EQ(b1.thigh, 14500); + EXPECT_EQ(b1.vlow, 3293); + EXPECT_EQ(b1.vhigh, 3294); + EXPECT_STREQ(b1.base_st, "Idle"); + EXPECT_EQ(b1.coulomb, 60); + EXPECT_EQ(b1.mostempr, 16600); +} + +TEST_F(PylontechTest, OverlongHeaderStillSetsTheColumnLayout) { + // Past the 199 character line limit: the line is dropped but its start still selects the layout + const std::string pad(40, ' '); + std::string new_header(NEW_HEADER); + new_header.insert(new_header.size() - 2, pad + "Extra.St"); + ASSERT_GT(new_header.size(), 201u); + this->uart_.push_rx({new_header, NEW_BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].thigh, 14500); + EXPECT_EQ(this->published_()[0].mostempr, 16600); + + // An overlong older header switches back + std::string old_header(HEADER); + old_header.insert(old_header.size() - 2, pad + pad + "Extra.St"); + ASSERT_GT(old_header.size(), 201u); + this->uart_.push_rx({old_header, BAT1}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[1].thigh, 25000); + EXPECT_EQ(this->published_()[1].mostempr, 22700); +} + +TEST_F(PylontechTest, BatteryWithoutListenerIsIgnored) { + this->uart_.push_rx( + {HEADER, bat_line(5, 49000), bat_line(17, 49000), bat_line(0, 49000), bat_line(INT32_MIN, 49000), BAT1, BAT2}); + for (int i = 0; i < 4; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + EXPECT_EQ(this->published_()[1].bat_num, 2); +} + +TEST_F(PylontechTest, OutOfRangeValueRejectsTheLine) { + this->uart_.push_rx({HEADER, bat_line(1, -1), bat_line(1, 70000), BAT2}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +TEST_F(PylontechTest, InvalidAndUnknownLinesAreSkipped) { + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, "1 abc\r\n", BAT2, "$$\r\n"}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +TEST_F(PylontechTest, RepeatedBatteryLineOverwrites) { + this->uart_.push_rx({HEADER, bat_line(3, 49000), BAT1, bat_line(3, 49500)}); + for (int i = 0; i < 4; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + EXPECT_EQ(this->published_()[1].bat_num, 3); + EXPECT_EQ(this->published_()[1].volt, 49500); + + // The next reply fills the slot again + this->uart_.push_rx({HEADER, bat_line(3, 48000)}); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(this->published_().size(), 3u); + EXPECT_EQ(this->published_()[2].volt, 48000); +} + +TEST_F(PylontechTest, BadLineClearsTheBatteryPendingRecord) { + this->uart_.push_rx({HEADER, BAT1, "1 abc\r\n", BAT2}); + for (int i = 0; i < 3; i++) { + this->hub_.loop(); + } + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 2); +} + +} // namespace esphome::pylontech::testing diff --git a/tests/integration/fixtures/uart_mock_pylontech.yaml b/tests/integration/fixtures/uart_mock_pylontech.yaml new file mode 100644 index 00000000000..fcf87cc8246 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_pylontech.yaml @@ -0,0 +1,105 @@ +esphome: + name: uart-mock-pylontech-test + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy pylontech's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 115200 + rx_buffer_size: 1024 + auto_start: false + +pylontech: + id: pylontech0 + uart_id: mock_uart + +sensor: + - platform: pylontech + pylontech_id: pylontech0 + battery: 1 + voltage: + name: "Bat1 Voltage" + current: + name: "Bat1 Current" + temperature: + name: "Bat1 Temperature" + temperature_low: + name: "Bat1 Temperature Low" + temperature_high: + name: "Bat1 Temperature High" + voltage_low: + name: "Bat1 Voltage Low" + voltage_high: + name: "Bat1 Voltage High" + coulomb: + name: "Bat1 Coulomb" + mos_temperature: + name: "Bat1 MOS Temperature" + - platform: pylontech + pylontech_id: pylontech0 + battery: 2 + voltage: + name: "Bat2 Voltage" + current: + name: "Bat2 Current" + temperature_high: + name: "Bat2 Temperature High" + coulomb: + name: "Bat2 Coulomb" + +text_sensor: + - platform: pylontech + pylontech_id: pylontech0 + battery: 1 + base_state: + name: "Bat1 Base State" + - platform: pylontech + pylontech_id: pylontech0 + battery: 2 + base_state: + name: "Bat2 Base State" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + # Phase 1: older firmware header and two battery lines + - uart_mock.inject_rx: + id: mock_uart + data: "pwr\r\n@\r\nPower Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St\r\n1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% 2021-06-30 20:49:45 Normal Normal 22700 Normal\r\n2 46012 -1255 9100 5300 5500 3047 3091 SysError Low Normal Normal 4% 2025-11-28 17:56:33 Low Normal 7800 Normal\r\n$$\r\n" + - delay: 300ms + # Phase 2: garbage bytes, ending a line + - uart_mock.inject_rx: + id: mock_uart + data: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF, 0x0D, 0x0A] + - delay: 100ms + # Phase 3: truncated battery line; must not publish + - uart_mock.inject_rx: + id: mock_uart + data: "1 49000 0 17000\r\n" + - delay: 100ms + # Phase 4: line over the 199 character limit; dropped with a warning + - uart_mock.inject_rx: + id: mock_uart + data: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\r\n" + - delay: 100ms + # Phase 5 (recovery): newer firmware header, also over the limit, then its data lines + - uart_mock.inject_rx: + id: mock_uart + data: "Power Volt Curr Tempr Tlow Tlow.Id Thigh Thigh.Id Vlow Vlow.Id Vhigh Vhigh.Id Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St SysAlarm.St Extra.St\r\n1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal 60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n2 48100 -2500 16900 13200 3 14100 1 3201 4 3215 9 Dischg Normal Normal Normal 42% 2025-12-05 00:53:41 Normal Normal 15800 Normal Normal\r\n$$\r\n" diff --git a/tests/integration/test_uart_mock_pylontech.py b/tests/integration/test_uart_mock_pylontech.py new file mode 100644 index 00000000000..fffaa902072 --- /dev/null +++ b/tests/integration/test_uart_mock_pylontech.py @@ -0,0 +1,136 @@ +"""Integration test for the pylontech component with mock UART. + +Phases, all injected after the Start Scenario button press: + 1. Older firmware header and two battery lines publish exact values + 2. Garbage bytes are skipped + 3. A truncated battery line publishes nothing and is counted in a warning + 4. A line over the 199 character limit is dropped with a warning + 5. Recovery: a newer firmware header, itself over the limit, still selects the + layout with the extra Id columns, so its battery lines parse correctly +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ButtonInfo +import pytest + +from .state_utils import InitialStateHelper, SensorStateCollector, find_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +SENSORS = [ + "bat1_voltage", + "bat1_current", + "bat1_temperature", + "bat1_temperature_low", + "bat1_temperature_high", + "bat1_voltage_low", + "bat1_voltage_high", + "bat1_coulomb", + "bat1_mos_temperature", + "bat2_voltage", + "bat2_current", + "bat2_temperature_high", + "bat2_coulomb", +] +TEXT_SENSORS = ["bat1_base_state", "bat2_base_state"] + +# Values per entity: phase 1 reply, then the phase 5 recovery reply +EXPECTED = { + "bat1_voltage": [50.548, 49.405], + "bat1_current": [8.91, 0.0], + "bat1_temperature": [25.0, 17.6], + "bat1_temperature_low": [24.2, 13.7], + "bat1_temperature_high": [25.0, 14.5], + "bat1_voltage_low": [3.368, 3.293], + "bat1_voltage_high": [3.371, 3.294], + "bat1_coulomb": [97.0, 60.0], + "bat1_mos_temperature": [22.7, 16.6], + "bat2_voltage": [46.012, 48.1], + "bat2_current": [-1.255, -2.5], + "bat2_temperature_high": [5.5, 14.1], + "bat2_coulomb": [4.0, 42.0], +} +EXPECTED_TEXT = { + "bat1_base_state": ["Charge", "Idle"], + "bat2_base_state": ["SysError", "Dischg"], +} + + +@pytest.mark.asyncio +async def test_uart_mock_pylontech( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test pylontech parsing with happy path, garbage, overflow and recovery.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + overflow_lines: list[str] = [] + rejected_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "Line over 199 chars dropped" in line: + overflow_lines.append(line) + if "malformed battery lines" in line: + rejected_lines.append(line) + + collector = SensorStateCollector( + sensor_names=SENSORS, text_sensor_names=TEXT_SENSORS + ) + recovery_received = collector.add_waiter( + lambda: ( + all(len(collector.sensor_states[name]) >= 2 for name in SENSORS) + and all( + len(collector.text_sensor_states[name]) >= 2 for name in TEXT_SENSORS + ) + ) + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(collector.on_state) + ) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + + try: + await asyncio.wait_for(recovery_received, timeout=5.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for the recovery reply. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" text_states: {collector.text_sensor_states}" + ) + # Let any stray publish from the bad phases arrive before the exact checks + await asyncio.sleep(0.2) + + for name, values in EXPECTED.items(): + assert collector.sensor_states[name] == pytest.approx(values), name + for name, values in EXPECTED_TEXT.items(): + assert collector.text_sensor_states[name] == values, name + + # Phase 4 and the phase 5 header both overflow + assert len(overflow_lines) == 2, overflow_lines + # The phase 3 cut line is counted and reported once the UART is idle + assert rejected_lines, "no rejected-line summary logged" From 052a6096e65cb520a299f8d6de253342e30bb7d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:46:16 -1000 Subject: [PATCH 037/179] [components] Log hex dumps with the ESP_LOG*_HEX macros (#20932) --- .../haier_water_heater/hon_water_heater.cpp | 12 +++------ esphome/components/hdmi_cec/hdmi_cec.cpp | 5 +--- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 7 ++---- esphome/components/i2c/i2c_bus_arduino.cpp | 5 +--- esphome/components/i2c/i2c_bus_esp_idf.cpp | 5 +--- .../improv_ble/improv_ble_component.cpp | 7 ++---- esphome/components/ld2410/ld2410.cpp | 10 ++------ esphome/components/ld2412/ld2412.cpp | 10 ++------ esphome/components/ld2450/ld2450.cpp | 10 ++------ esphome/components/ld2460/ld2460.cpp | 5 +--- .../midea_portasplit/midea_portasplit.cpp | 10 ++------ esphome/components/mitsubishi/mitsubishi.cpp | 5 +--- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 5 +--- esphome/components/modbus/modbus.cpp | 6 +---- .../output/modbus_output.cpp | 6 +---- .../switch/modbus_switch.cpp | 6 +---- .../mopeka_std_check/mopeka_std_check.cpp | 7 ++---- esphome/components/pn532_spi/pn532_spi.cpp | 10 ++------ .../seeed_mr60bha2/seeed_mr60bha2.cpp | 10 ++------ .../seeed_mr60fda2/seeed_mr60fda2.cpp | 25 ++++--------------- esphome/components/sonoff_d1/sonoff_d1.cpp | 15 +++-------- .../usb_cdc_acm/usb_cdc_acm_esp32.cpp | 10 ++------ esphome/components/xiaomi_ble/xiaomi_ble.cpp | 5 +--- .../components/zwave_proxy/zwave_proxy.cpp | 10 ++------ 24 files changed, 43 insertions(+), 163 deletions(-) diff --git a/esphome/components/haier_water_heater/hon_water_heater.cpp b/esphome/components/haier_water_heater/hon_water_heater.cpp index 3f190de5b83..b2c35c4cca4 100644 --- a/esphome/components/haier_water_heater/hon_water_heater.cpp +++ b/esphome/components/haier_water_heater/hon_water_heater.cpp @@ -46,10 +46,7 @@ void HonWaterHeater::send_frame_(FrameType type, const uint8_t *payload, size_t const size_t length = encode_frame(type, payload, size, wire); if (length == 0) return; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex[format_hex_pretty_size(sizeof(wire))]; - ESP_LOGV(TAG, "TX type=%02X: %s", static_cast(type), format_hex_pretty_to(hex, wire, length)); -#endif + ESP_LOGV_HEX(TAG, wire, length, sizeof(wire), "TX type=%02X: %s", static_cast(type)); this->write_array(wire, length); } @@ -221,11 +218,8 @@ void HonWaterHeater::confirm_control_(const Status &status) { void HonWaterHeater::process_frame_() { const uint8_t *frame = this->parser_.data(); const auto type = static_cast(frame[FRAME_TYPE_OFFSET]); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex[format_hex_pretty_size(MAX_STORED_FRAME)]; - ESP_LOGV(TAG, "RX decoded type=%02X flags=%02X: %s", static_cast(type), frame[FRAME_FLAGS_OFFSET], - format_hex_pretty_to(hex, frame, this->parser_.stored_size())); -#endif + ESP_LOGV_HEX(TAG, frame, this->parser_.stored_size(), MAX_STORED_FRAME, "RX decoded type=%02X flags=%02X: %s", + static_cast(type), frame[FRAME_FLAGS_OFFSET]); this->handle_frame_type_(type); Status status{}; diff --git a/esphome/components/hdmi_cec/hdmi_cec.cpp b/esphome/components/hdmi_cec/hdmi_cec.cpp index 82e354dbae7..e0e9abde7b1 100644 --- a/esphome/components/hdmi_cec/hdmi_cec.cpp +++ b/esphome/components/hdmi_cec/hdmi_cec.cpp @@ -75,10 +75,7 @@ void HDMICEC::loop() { } // One inbound frame per pass keeps the time spent in the automations bounded if (const Frame *frame = this->frames_queue_.front()) { -#ifdef ESPHOME_LOG_HAS_DEBUG - char line[format_hex_pretty_size(Frame::MAX_LENGTH)]; - ESP_LOGD(TAG, "Received: %s", format_hex_pretty_to(line, frame->bytes().data(), frame->size())); -#endif + ESP_LOGD_HEX(TAG, frame->bytes().data(), frame->size(), Frame::MAX_LENGTH, "Received: %s"); #ifdef HDMI_CEC_MESSAGE_CALLBACK_COUNT this->message_callbacks_.call(frame->initiator_addr(), frame->destination_addr(), frame->payload()); #endif diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index e4845daa57b..8674a1de97c 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -161,11 +161,8 @@ void HlkFm22xComponent::recv_command_() { } } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(HLK_FM22X_MAX_RESPONSE_SIZE)]; - ESP_LOGV(TAG, "Recv type: 0x%.2X, data: %s", response_type, - format_hex_pretty_to(hex_buf, this->recv_buf_.data(), to_store)); -#endif + ESP_LOGV_HEX(TAG, this->recv_buf_.data(), to_store, HLK_FM22X_MAX_RESPONSE_SIZE, "Recv type: 0x%.2X, data: %s", + response_type); byte = this->read(); if (byte != checksum) { diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 215cc1364f8..799004b1c0b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -111,10 +111,7 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe return ERROR_NOT_INITIALIZED; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(I2C_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty_to(hex_buf, write_buffer, write_count)); -#endif + ESP_LOGV_HEX(TAG, write_buffer, write_count, I2C_MAX_LOG_BYTES, "0x%02X TX %s", address); uint8_t status = 0; if (write_count != 0 || read_count == 0) { diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 931bcfa0889..2b1b83ef6f5 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -167,10 +167,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s jobs[num_jobs++].write.total_bytes = 1; } else { if (write_count != 0) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(I2C_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "0x%02X TX %s", address, format_hex_pretty_to(hex_buf, write_buffer, write_count)); -#endif + ESP_LOGV_HEX(TAG, write_buffer, write_count, I2C_MAX_LOG_BYTES, "0x%02X TX %s", address); jobs[num_jobs++].command = I2C_MASTER_CMD_START; jobs[num_jobs].command = I2C_MASTER_CMD_WRITE; jobs[num_jobs].write.ack_check = true; diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 6e11975b6c3..0d441ee62dc 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -354,11 +354,8 @@ void ImprovBLEComponent::process_incoming_data_() { return; uint8_t length = this->incoming_data_[1]; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(IMPROV_MAX_LOG_BYTES)]; - ESP_LOGV(TAG, "Processing bytes - %s", - format_hex_pretty_to(hex_buf, this->incoming_data_.data(), this->incoming_data_.size())); -#endif + ESP_LOGV_HEX(TAG, this->incoming_data_.data(), this->incoming_data_.size(), IMPROV_MAX_LOG_BYTES, + "Processing bytes - %s"); if (this->incoming_data_.size() - 3 == length) { this->set_error_(improv::ERROR_NONE); improv::ImprovCommand command = improv::parse_improv_data(this->incoming_data_); diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 22bd20d1e5a..7461a2ade40 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -607,17 +607,11 @@ void LD2410Component::readline_(int readch) { return; // Not enough data to process yet } if (ld2410::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next message } else if (ld2410::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index c6137f29f7c..704e3845e73 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -702,17 +702,11 @@ void LD2412Component::readline_(int readch) { return; // Not enough data to process yet } if (ld2412::validate_header_footer(DATA_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next message } else if (ld2412::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index cdf542ee8b8..52327d309e3 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -829,17 +829,11 @@ void LD2450Component::readline_(int readch) { } if (this->buffer_data_[this->buffer_pos_ - 2] == DATA_FRAME_FOOTER[0] && this->buffer_data_[this->buffer_pos_ - 1] == DATA_FRAME_FOOTER[1]) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Periodic Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Periodic Data: %s"); this->handle_periodic_data_(); this->buffer_pos_ = 0; // Reset position index for next frame } else if (ld2450::validate_header_footer(CMD_FRAME_FOOTER, &this->buffer_data_[this->buffer_pos_ - 4])) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Handling Ack Data: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Handling Ack Data: %s"); if (this->handle_ack_data_()) { this->buffer_pos_ = 0; // Reset position index for next message } else { diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp index 2efd34a1d8c..abeb6779cf4 100644 --- a/esphome/components/ld2460/ld2460.cpp +++ b/esphome/components/ld2460/ld2460.cpp @@ -168,10 +168,7 @@ void LD2460Component::readline_(uint8_t readch) { return; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; - ESP_LOGV(TAG, "Frame: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); -#endif + ESP_LOGV_HEX(TAG, this->buffer_data_, this->buffer_pos_, MAX_LINE_LENGTH, "Frame: %s"); if (is_data) { this->handle_periodic_data_(); diff --git a/esphome/components/midea_portasplit/midea_portasplit.cpp b/esphome/components/midea_portasplit/midea_portasplit.cpp index a1f22226d9c..309e1f880f4 100644 --- a/esphome/components/midea_portasplit/midea_portasplit.cpp +++ b/esphome/components/midea_portasplit/midea_portasplit.cpp @@ -202,10 +202,7 @@ void PortaSplitClimate::send_frame_(uint8_t proto, uint8_t type, const uint8_t * f[10 + len] = crc8(&f[10], (uint8_t) len); f[11 + len] = checksum(f, 11 + len); this->write_array(f, total); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(sizeof(f))]; - ESP_LOGV(TAG, "TX: %s", format_hex_pretty_to(hex_buf, f, total)); -#endif + ESP_LOGV_HEX(TAG, f, total, sizeof(f), "TX: %s"); } void PortaSplitClimate::send_set_command_() { @@ -276,10 +273,7 @@ void PortaSplitClimate::rx_feed_(uint8_t b) { ESP_LOGW(TAG, "RX checksum mismatch, dropping frame"); return; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MAX_FRAME)]; - ESP_LOGV(TAG, "RX: %s", format_hex_pretty_to(hex_buf, this->rx_.data(), total)); -#endif + ESP_LOGV_HEX(TAG, this->rx_.data(), total, MAX_FRAME, "RX: %s"); this->handle_frame_(this->rx_.data(), total); } diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 9fba222d635..19b8bb3fad3 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -387,10 +387,7 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { break; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MITSUBISHI_FRAME_SIZE)]; -#endif - ESP_LOGV(TAG, "Receiving: %s", format_hex_pretty_to(hex_buf, state_frame, MITSUBISHI_FRAME_SIZE)); + ESP_LOGV_HEX(TAG, state_frame, MITSUBISHI_FRAME_SIZE, MITSUBISHI_FRAME_SIZE, "Receiving: %s"); this->publish_state(); return true; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 3261d9d5694..75e8d547c05 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -458,10 +458,7 @@ void MitsubishiCN105::FrameParser::reset_and_dump_buffer_(const char *prefix) { } void MitsubishiCN105::FrameParser::dump_buffer_vv(const char *prefix, const uint8_t *data, size_t len) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char buf[format_hex_pretty_size(READ_BUFFER_SIZE)]; - ESP_LOGVV(TAG, "%s (%zu): %s", prefix, len, format_hex_pretty_to(buf, data, len)); -#endif + ESP_LOGVV_HEX(TAG, data, len, READ_BUFFER_SIZE, "%s (%zu): %s", prefix, len); } } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index a79a7c3bd8c..d135ae58e63 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -1161,11 +1161,7 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); return false; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Adding frame to tx queue: %" PRIu8 ":%s", address, - format_hex_pretty_to(hex_buf, pdu.data(), pdu.size())); + ESP_LOGV_HEX(TAG, pdu.data(), pdu.size(), MODBUS_MAX_LOG_BYTES, "Adding frame to tx queue: %" PRIu8 ":%s", address); this->tx_buffer_.emplace_back(device, address, pdu, options, this->next_seq_++); return true; } diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index 9fb40f90053..f49429b1ece 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -100,11 +100,7 @@ void ModbusBinaryOutput::write_state(bool state) { } if (!data.empty()) { this->warn_write_buffer_deprecated_(LOG_STR("binary output"), this->start_address); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Modbus binary output write raw: %s", - format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), MODBUS_OUTPUT_MAX_LOG_BYTES, "Modbus binary output write raw: %s"); // The lambda filled a legacy raw frame (device address + function code + data). if (!this->send_raw_frame_deprecated_(data)) { ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address()); diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index fe2b2762313..c29378c5436 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -70,11 +70,7 @@ void ModbusSwitch::write_state(bool state) { } if (!data.empty()) { this->warn_write_buffer_deprecated_(LOG_STR("switch"), this->start_address); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Modbus Switch write raw: %s", - format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), MODBUS_SWITCH_MAX_LOG_BYTES, "Modbus Switch write raw: %s"); // The lambda filled a legacy raw frame (device address + function code + data). if (!this->send_raw_frame_deprecated_(data)) { ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index f63f2bc190b..831c264d24e 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -64,11 +64,8 @@ bool MopekaStdCheck::parse_device(const ble_device_base::ESPBTDevice &device) { const auto &manu_data = manu_datas[0]; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(MOPEKA_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "[%s] Manufacturer data: %s", addr_str, - format_hex_pretty_to(hex_buf, manu_data.data.data(), manu_data.data.size())); + ESP_LOGVV_HEX(TAG, manu_data.data.data(), manu_data.data.size(), MOPEKA_MAX_LOG_BYTES, "[%s] Manufacturer data: %s", + addr_str); if (manu_data.data.size() != MANUFACTURER_DATA_LENGTH) { ESP_LOGE(TAG, "[%s] Unexpected manu_data size (%d)", addr_str, manu_data.data.size()); diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 34c6a3e44f7..eb0ce241f39 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -38,10 +38,7 @@ bool PN532Spi::write_data(const std::span data) { delay(2); // First byte, communication mode: Write data this->write_byte(0x01); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Writing data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), PN532_MAX_LOG_BYTES, "Writing data: %s"); this->write_array(data.data(), data.size()); this->disable(); @@ -65,10 +62,7 @@ bool PN532Spi::read_data(pn532::PN532Frame &data, size_t len) { data[0] = 0x01; this->read_array(data.data() + 1, len); this->disable(); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Read data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + ESP_LOGV_HEX(TAG, data.data(), data.size(), PN532_MAX_LOG_BYTES, "Read data: %s"); return true; } diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index e86909d0491..eec4e7afa6b 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -119,10 +119,7 @@ bool MR60BHA2Component::validate_message_() { if (at == 7) { if (!validate_checksum(data, 7, header_checksum)) { ESP_LOGE(TAG, "HEAD_CKSUM_FRAME ERROR: 0x%02x", header_checksum); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MR60BHA2_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "GET FRAME: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data, 8)); + ESP_LOGV_HEX(TAG, data, 8, MR60BHA2_MAX_LOG_BYTES, "GET FRAME: %s"); return false; } return true; @@ -137,10 +134,7 @@ bool MR60BHA2Component::validate_message_() { if (at == 8 + length) { if (!validate_checksum(data + 8, length, data_checksum)) { ESP_LOGE(TAG, "DATA_CKSUM_FRAME ERROR: 0x%02x", data_checksum); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MR60BHA2_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "GET FRAME: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data, 8 + length)); + ESP_LOGV_HEX(TAG, data, 8 + length, MR60BHA2_MAX_LOG_BYTES, "GET FRAME: %s"); return false; } } diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index d0e4c635eb2..5a0143071c6 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -329,10 +329,7 @@ void MR60FDA2Component::set_install_height(uint8_t index) { float_to_bytes(INSTALL_HEIGHT[index], &send_data[8]); send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND INSTALL HEIGHT FRAME: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND INSTALL HEIGHT FRAME: %s"); } void MR60FDA2Component::set_height_threshold(uint8_t index) { @@ -342,10 +339,7 @@ void MR60FDA2Component::set_height_threshold(uint8_t index) { float_to_bytes(HEIGHT_THRESHOLD[index], &send_data[8]); send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND HEIGHT THRESHOLD: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND HEIGHT THRESHOLD: %s"); } void MR60FDA2Component::set_sensitivity(uint8_t index) { @@ -357,28 +351,19 @@ void MR60FDA2Component::set_sensitivity(uint8_t index) { send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(13)]; -#endif - ESP_LOGV(TAG, "SEND SET SENSITIVITY: %s", format_hex_pretty_to(hex_buf, send_data, 13)); + ESP_LOGV_HEX(TAG, send_data, 13, 13, "SEND SET SENSITIVITY: %s"); } void MR60FDA2Component::get_radar_parameters() { uint8_t send_data[8] = {0x01, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x06, 0xF6}; this->write_array(send_data, 8); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(8)]; -#endif - ESP_LOGV(TAG, "SEND GET PARAMETERS: %s", format_hex_pretty_to(hex_buf, send_data, 8)); + ESP_LOGV_HEX(TAG, send_data, 8, 8, "SEND GET PARAMETERS: %s"); } void MR60FDA2Component::factory_reset() { uint8_t send_data[8] = {0x01, 0x00, 0x00, 0x00, 0x00, 0x21, 0x10, 0xCF}; this->write_array(send_data, 8); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(8)]; -#endif - ESP_LOGV(TAG, "SEND RESET: %s", format_hex_pretty_to(hex_buf, send_data, 8)); + ESP_LOGV_HEX(TAG, send_data, 8, 8, "SEND RESET: %s"); this->get_radar_parameters(); } diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index c72210e932f..ca3b4267e84 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -84,10 +84,7 @@ bool SonoffD1Output::read_command_(uint8_t *cmd, size_t &len) { // Read a minimal packet if (this->read_array(cmd, 6)) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(6)]; - ESP_LOGV(TAG, "[%04d] Reading from dimmer: %s", this->write_count_, format_hex_pretty_to(hex_buf, cmd, 6)); -#endif + ESP_LOGV_HEX(TAG, cmd, 6, 6, "[%04d] Reading from dimmer: %s", this->write_count_); if (cmd[0] != 0xAA || cmd[1] != 0x55) { ESP_LOGW(TAG, "[%04d] RX: wrong header (%x%x, must be AA55)", this->write_count_, cmd[0], cmd[1]); @@ -101,10 +98,7 @@ bool SonoffD1Output::read_command_(uint8_t *cmd, size_t &len) { return false; } if (this->read_array(&cmd[6], cmd[5] + 1 /*checksum suffix*/)) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf2[format_hex_pretty_size(SONOFF_D1_MAX_CMD_SIZE)]; - ESP_LOGV(TAG, "[%04d] %s", this->write_count_, format_hex_pretty_to(hex_buf2, &cmd[6], cmd[5] + 1)); -#endif + ESP_LOGV_HEX(TAG, &cmd[6], cmd[5] + 1, SONOFF_D1_MAX_CMD_SIZE, "[%04d] %s", this->write_count_); // Check the checksum uint8_t valid_checksum = this->calc_checksum_(cmd, cmd[5] + 7); @@ -178,10 +172,7 @@ bool SonoffD1Output::write_command_(uint8_t *cmd, const size_t len, bool needs_a // 2. UART command initiated by this component can clash with a command initiated by RF uint32_t retries = 10; do { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(SONOFF_D1_MAX_CMD_SIZE)]; - ESP_LOGV(TAG, "[%04d] Writing to the dimmer: %s", this->write_count_, format_hex_pretty_to(hex_buf, cmd, len)); -#endif + ESP_LOGV_HEX(TAG, cmd, len, SONOFF_D1_MAX_CMD_SIZE, "[%04d] Writing to the dimmer: %s", this->write_count_); this->write_array(cmd, len); this->write_count_++; if (!needs_ack) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 0f02ddf3c7a..ebf44e41438 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -50,10 +50,7 @@ static void tinyusb_cdc_rx_callback(int itf, cdcacm_event_t *event) { esp_err_t ret = tinyusb_cdcacm_read(static_cast(itf), rx_buf, CONFIG_TINYUSB_CDC_RX_BUFSIZE, &rx_size); ESP_LOGV(TAG, "tinyusb_cdc_rx_callback itf=%d (size: %u)", itf, rx_size); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char rx_hex_buf[format_hex_pretty_size(USB_CDC_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "rx_buf = %s", format_hex_pretty_to(rx_hex_buf, rx_buf, rx_size)); + ESP_LOGVV_HEX(TAG, rx_buf, rx_size, USB_CDC_MAX_LOG_BYTES, "rx_buf = %s"); if (ret == ESP_OK && rx_size > 0) { RingbufHandle_t rx_ringbuf = instance->get_rx_ringbuf(); @@ -194,10 +191,7 @@ void USBCDCACMInstance::usb_tx_task() { } ESP_LOGV(TAG, "USB TX itf=%d: Read %d bytes from buffer", this->itf_, tx_data_size); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char tx_hex_buf[format_hex_pretty_size(USB_CDC_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "data = %s", format_hex_pretty_to(tx_hex_buf, data, tx_data_size)); + ESP_LOGVV_HEX(TAG, data, tx_data_size, USB_CDC_MAX_LOG_BYTES, "data = %s"); // Serial data will be split up into 64 byte chunks to be sent over USB so this // usually will take multiple iterations diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 57f26b6acc7..90ea5c32094 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -286,10 +286,7 @@ optional parse_xiaomi_header(const ble_device_base::ServiceDa bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address) { if ((raw.size() != 19) && ((raw.size() < 22) || (raw.size() > 24))) { ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): data packet has wrong size (%d)!", raw.size()); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(XIAOMI_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, " Packet : %s", format_hex_pretty_to(hex_buf, raw.data(), raw.size())); + ESP_LOGVV_HEX(TAG, raw.data(), raw.size(), XIAOMI_MAX_LOG_BYTES, " Packet : %s"); return false; } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index a7b1fa0ced0..7134e223351 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -311,10 +311,7 @@ void ZWaveProxy::send_frame_(const uint8_t *data, size_t length) { return; } -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(ZWAVE_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "Sending: %s", format_hex_pretty_to(hex_buf, data, length)); + ESP_LOGVV_HEX(TAG, data, length, ZWAVE_MAX_LOG_BYTES, "Sending: %s"); this->write_array(data, length); } @@ -395,10 +392,7 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; } else { this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_ACK; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(ZWAVE_MAX_LOG_BYTES)]; -#endif - ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty_to(hex_buf, this->buffer_.data(), this->buffer_index_)); + ESP_LOGVV_HEX(TAG, this->buffer_.data(), this->buffer_index_, ZWAVE_MAX_LOG_BYTES, "Received frame: %s"); frame_completed = true; } this->response_handler_(); From 7c6fac91fffb63b9f7617311ded8badfc7a824f9 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:49:15 -0400 Subject: [PATCH 038/179] [esp32] Bump recommended ESP-IDF to 5.5.6 (#20987) --- esphome/components/esp32/__init__.py | 9 +++++---- platformio.ini | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index aae1a4c41f6..cd602e6c64b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1183,7 +1183,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), - cv.Version(3, 3, 12): cv.Version(5, 5, 5), + cv.Version(3, 3, 12): cv.Version(5, 5, 6), cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), @@ -1207,9 +1207,9 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 5), - "latest": cv.Version(5, 5, 5), - "dev": cv.Version(5, 5, 5), + "recommended": cv.Version(5, 5, 6), + "latest": cv.Version(5, 5, 6), + "dev": cv.Version(5, 5, 6), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { @@ -1219,6 +1219,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(5, 5, 6): cv.Version(55, 3, 312), cv.Version(5, 5, 5): cv.Version(55, 3, 312), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), diff --git a/platformio.ini b/platformio.ini index fe61470a358..e5d0200ea7c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -147,7 +147,7 @@ extends = common:arduino platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -183,7 +183,7 @@ extra_scripts = extends = common:idf platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = espidf lib_deps = From bd06d37d1a0a47c10f1862092ad5e4b4d6513931 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:49:46 -1000 Subject: [PATCH 039/179] [components] Check I2C chip IDs with check_chip_id (#20943) --- esphome/components/apds9306/apds9306.cpp | 11 ++-------- esphome/components/apds9930/apds9930.cpp | 10 +--------- esphome/components/as7341/as7341.cpp | 8 +------- esphome/components/bh1745/bh1745.cpp | 9 +-------- esphome/components/bmi270/bmi270.cpp | 11 +--------- esphome/components/bq25186/bq25186.cpp | 12 +---------- .../es9038q2m_katana/es9038q2m_katana.cpp | 10 +--------- esphome/components/lis3dh/lis3dh.cpp | 10 +--------- esphome/components/mmc5603/mmc5603.cpp | 11 ++-------- esphome/components/mmc5983/mmc5983.cpp | 17 +++------------- esphome/components/mpl3115a2/mpl3115a2.cpp | 9 +-------- esphome/components/msa3xx/msa3xx.cpp | 10 +--------- esphome/components/qmi8658/qmi8658.cpp | 10 +--------- esphome/components/qwiic_pir/qwiic_pir.cpp | 11 +--------- esphome/components/stts22h/stts22h.cpp | 20 +------------------ esphome/components/stts22h/stts22h.h | 1 - esphome/components/tsl2591/tsl2591.cpp | 12 +---------- 17 files changed, 20 insertions(+), 162 deletions(-) diff --git a/esphome/components/apds9306/apds9306.cpp b/esphome/components/apds9306/apds9306.cpp index af123d8c4f3..bcfcce1f3b4 100644 --- a/esphome/components/apds9306/apds9306.cpp +++ b/esphome/components/apds9306/apds9306.cpp @@ -52,16 +52,9 @@ enum { // APDS9306 registers } void APDS9306::setup() { - uint8_t id; - if (!this->read_byte(APDS9306_PART_ID, &id)) { // Part ID register - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + // 0xB1 for APDS9306, 0xB3 for APDS9306-065; they differ only in bit 1 + if (!this->check_chip_id(this, APDS9306_PART_ID, 0xB1, 0xFD)) return; - } - - if (id != 0xB1 && id != 0xB3) { // 0xB1 for APDS9306 0xB3 for APDS9306-065 - this->mark_failed(LOG_STR("APDS9306 has invalid id")); - return; - } // ALS resolution and measurement, see datasheet or init.py for options uint8_t als_meas_rate = ((this->bit_width_ & 0x07) << 4) | (this->measurement_rate_ & 0x07); diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp index 6debc0d9c7b..a4b836beaa1 100644 --- a/esphome/components/apds9930/apds9930.cpp +++ b/esphome/components/apds9930/apds9930.cpp @@ -64,16 +64,8 @@ static constexpr uint16_t ALS_SATURATION = 1024 * (256 - APDS9930_ATIME_VALUE); bool APDS9930Component::write_reg_(uint8_t reg, uint8_t value) { return this->write_byte(reg | APDS9930_CMD, value); } void APDS9930Component::setup() { - uint8_t id; - if (!this->read_byte(APDS9930_ID | APDS9930_CMD, &id)) { - this->mark_failed(LOG_STR("Communication failed")); + if (!this->check_chip_id(this, APDS9930_ID | APDS9930_CMD, APDS9930_CHIP_ID)) return; - } - if (id != APDS9930_CHIP_ID) { - ESP_LOGE(TAG, "Wrong ID 0x%02X (expected 0x%02X)", id, APDS9930_CHIP_ID); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } const uint8_t control = (this->led_drive_ & 0b11) << CONTROL_PDRIVE_SHIFT | CONTROL_PDIODE_CH1 << CONTROL_PDIODE_SHIFT | diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index 60f576f7bb9..cf02884c5e4 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -9,14 +9,8 @@ ESPHOME_LOG_TAG(TAG, "as7341"); void AS7341Component::setup() { LOG_I2C_DEVICE(this); - // Verify device ID - uint8_t id; - this->read_byte(AS7341_ID, &id); - ESP_LOGCONFIG(TAG, " Read ID: 0x%X", id); - if ((id & 0xFC) != (AS7341_CHIP_ID << 2)) { - this->mark_failed(LOG_STR("Wrong chip ID")); + if (!this->check_chip_id(this, AS7341_ID, AS7341_CHIP_ID << 2, 0xFC)) return; - } // Power on (enter IDLE state) if (!this->enable_power(true)) { diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp index 24744c3330d..ee7b9935f4c 100644 --- a/esphome/components/bh1745/bh1745.cpp +++ b/esphome/components/bh1745/bh1745.cpp @@ -63,15 +63,8 @@ static float calculate_cct(uint16_t red, uint16_t green, uint16_t blue, bool low } void BH1745Component::setup() { - uint8_t manufacturer_id; - if (!this->read_byte(REG_MANUFACTURER_ID, &manufacturer_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, REG_MANUFACTURER_ID, MANUFACTURER_ID)) return; - } - if (manufacturer_id != MANUFACTURER_ID) { - this->mark_failed(LOG_STR("Wrong manufacturer ID")); - return; - } uint8_t system_control; if (!this->read_byte(REG_SYSTEM_CONTROL, &system_control) || (system_control & SYSTEM_CONTROL_PART_ID_MASK) != PART_ID) { diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index e3c6d83a722..0e010b03118 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -78,17 +78,8 @@ bool BMI270Component::load_config_file_() { void BMI270Component::setup() { MotionComponent::setup(); // 1. Verify chip ID - uint8_t chip_id = 0; - if (!this->read_byte(BMI270_REG_CHIP_ID, &chip_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, BMI270_REG_CHIP_ID, BMI270_CHIP_ID_VALUE)) return; - } - if (chip_id != BMI270_CHIP_ID_VALUE) { - ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", chip_id, BMI270_CHIP_ID_VALUE); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } - ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); // 2. Soft-reset via CMD register (0x7E = 0xB6) if (!this->write_byte(BMI270_REG_CMD, BMI270_CMD_SOFT_RESET)) { diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp index edea50d1115..91bbacba467 100644 --- a/esphome/components/bq25186/bq25186.cpp +++ b/esphome/components/bq25186/bq25186.cpp @@ -79,19 +79,9 @@ bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { } void BQ25186Component::setup() { - uint8_t mask_id; - if (!this->read_byte(BQ25186_REG_MASK_ID, &mask_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return; - } - // Another charger at this address could lay its registers out differently, so do not write into it - const uint8_t device_id = mask_id & 0x0F; - if (device_id != 0x01) { - ESP_LOGE(TAG, "Unexpected device id 0x%X (expected 0x1)", device_id); - this->mark_failed(LOG_STR("Not a BQ25186")); + if (!this->check_chip_id(this, BQ25186_REG_MASK_ID, 0x01, 0x0F)) return; - } // A failed write only sets the warning status: the chip has identified itself, so a later // apply_configuration may retry diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp index 464f971acef..e3cd3820d7e 100644 --- a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -69,16 +69,8 @@ const LogString *deemphasis_to_str(DeemphasisMode mode) { } // namespace void ES9038Q2MKatana::setup() { - uint8_t chip_id = 0; - if (!this->read_byte(KATANA_REG_CHIP_ID, &chip_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, KATANA_REG_CHIP_ID, KATANA_CHIP_ID)) return; - } - if (chip_id != KATANA_CHIP_ID) { - ESP_LOGE(TAG, "Unexpected chip ID 0x%02X", chip_id); - this->mark_failed(LOG_STR("Unexpected chip ID")); - return; - } if (!this->write_byte(KATANA_REG_RESET, 0x01)) { this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp index f12c00852cd..64e6da4ef95 100644 --- a/esphome/components/lis3dh/lis3dh.cpp +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -27,16 +27,8 @@ static const uint16_t RATE_HZ[] PROGMEM = {1, 10, 25, 50, 100, 200, 400, 1620, 1 void LIS3DHComponent::setup() { MotionComponent::setup(); - uint8_t who_am_i = 0; - if (!this->read_byte(LIS3DH_REG_WHO_AM_I, &who_am_i)) { - this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); + if (!this->check_chip_id(this, LIS3DH_REG_WHO_AM_I, LIS3DH_WHO_AM_I_VALUE)) return; - } - if (who_am_i != LIS3DH_WHO_AM_I_VALUE) { - ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, LIS3DH_WHO_AM_I_VALUE); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) { this->mark_failed(LOG_STR("Failed to configure sensor")); diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index fa5ba5985d7..bb7ea34cbdf 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -32,16 +32,9 @@ static const uint8_t MMC56X3_CTRL2_REG = 0x1D; static const uint8_t MMC5603_ODR_REG = 0x1A; void MMC5603Component::setup() { - uint8_t id = 0; - if (!this->read_byte(MMC56X3_PRODUCT_ID, &id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + // Some chips report 0 instead of MMC56X3_CHIP_ID (0x10), so bit 4 is ignored + if (!this->check_chip_id(this, MMC56X3_PRODUCT_ID, 0x00, static_cast(~MMC56X3_CHIP_ID))) return; - } - - if (id != 0 && id != MMC56X3_CHIP_ID) { // ID is not reported correctly by all chips, 0 on some chips - this->mark_failed(LOG_STR("The ID registers don't match - Is this really an MMC5603?")); - return; - } if (!this->write_byte(MMC56X3_CTRL1_REG, 0x80)) { // turn on set bit this->mark_failed(LOG_STR("Control 1 failed for set bit")); diff --git a/esphome/components/mmc5983/mmc5983.cpp b/esphome/components/mmc5983/mmc5983.cpp index f7840aafbe9..8a636dacb5d 100644 --- a/esphome/components/mmc5983/mmc5983.cpp +++ b/esphome/components/mmc5983/mmc5983.cpp @@ -14,6 +14,7 @@ constexpr uint8_t IC1_ADDR = 0x0a; constexpr uint8_t IC2_ADDR = 0x0b; constexpr uint8_t IC3_ADDR = 0x0c; constexpr uint8_t PRODUCT_ID_ADDR = 0x2f; +constexpr uint8_t MMC5983_PRODUCT_ID = 0x30; float convert_data_to_millitesla(uint8_t data_17_10, uint8_t data_9_2, uint8_t data_1_0) { int32_t counts = (data_17_10 << 10) | (data_9_2 << 2) | data_1_0; @@ -66,25 +67,13 @@ void MMC5983Component::update() { } void MMC5983Component::setup() { - // Verify product id. - const uint8_t mmc5983_product_id = 0x30; - uint8_t id; - i2c::ErrorCode err = this->read_register(PRODUCT_ID_ADDR, &id, 1); - if (err != i2c::ErrorCode::ERROR_OK) { - ESP_LOGE(TAG, "Reading product id failed with i2c error %d", err); - this->mark_failed(); + if (!this->check_chip_id(this, PRODUCT_ID_ADDR, MMC5983_PRODUCT_ID)) return; - } - if (id != mmc5983_product_id) { - ESP_LOGE(TAG, "Product id 0x%02x does not match expected value 0x%02x", id, mmc5983_product_id); - this->mark_failed(); - return; - } // Initialize Internal Control registers to 0. // Internal Control 0. const uint8_t zero = 0; - err = this->write_register(IC0_ADDR, &zero, 1); + i2c::ErrorCode err = this->write_register(IC0_ADDR, &zero, 1); if (err != i2c::ErrorCode::ERROR_OK) { ESP_LOGE(TAG, "Initializing Internal Control 0 failed with i2c error %d", err); this->mark_failed(); diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index 4a206b30dfe..58f346ab77a 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -8,15 +8,8 @@ namespace esphome::mpl3115a2 { ESPHOME_LOG_TAG(TAG, "mpl3115a2"); void MPL3115A2Component::setup() { - uint8_t whoami = 0xFF; - if (!this->read_byte(MPL3115A2_WHOAMI, &whoami)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, MPL3115A2_WHOAMI, 0xC4)) return; - } - if (whoami != 0xC4) { - this->mark_failed(LOG_STR("MPL3115A2 has invalid id")); - return; - } // reset this->write_byte(MPL3115A2_CTRL_REG1, MPL3115A2_CTRL_REG1_RST); diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index 19a4b067bc7..ed713cb30e1 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -117,16 +117,8 @@ const char *orientation_xy_to_string(OrientationXY orientation) { const char *orientation_z_to_string(bool orientation) { return orientation ? "Downwards looking" : "Upwards looking"; } void MSA3xxComponent::setup() { - uint8_t part_id{0xff}; - if (!this->read_byte(static_cast(RegisterMap::PART_ID), &part_id)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->check_chip_id(this, static_cast(RegisterMap::PART_ID), MSA_3XX_PART_ID)) return; - } - if (part_id != MSA_3XX_PART_ID) { - ESP_LOGE(TAG, "Part ID is wrong. Got 0x%02X", part_id); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } // Resolution LSB/g // Range : MSA301 : MSA311 diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index da1f735fb9e..f03aac9a80c 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -30,16 +30,8 @@ void QMI8658Component::setup() { MotionComponent::setup(); // 1. Verify chip ID - uint8_t who_am_i = 0; - if (!this->read_byte(QMI8658_REG_WHO_AM_I, &who_am_i)) { - this->mark_failed(LOG_STR("Failed to read chip ID - check wiring / address")); + if (!this->check_chip_id(this, QMI8658_REG_WHO_AM_I, QMI8658_WHO_AM_I_VALUE)) return; - } - if (who_am_i != QMI8658_WHO_AM_I_VALUE) { - ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, QMI8658_WHO_AM_I_VALUE); - this->mark_failed(LOG_STR("Wrong chip ID")); - return; - } // 2. Soft reset if (!this->write_byte(QMI8658_REG_RESET, QMI8658_RESET_CMD)) { diff --git a/esphome/components/qwiic_pir/qwiic_pir.cpp b/esphome/components/qwiic_pir/qwiic_pir.cpp index de9be3c8f0c..4b30a21a831 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.cpp +++ b/esphome/components/qwiic_pir/qwiic_pir.cpp @@ -6,17 +6,8 @@ namespace esphome::qwiic_pir { ESPHOME_LOG_TAG(TAG, "qwiic_pir"); void QwiicPIRComponent::setup() { - // Verify I2C communcation by reading and verifying the chip ID - uint8_t chip_id; - if (!this->read_byte(QWIIC_PIR_CHIP_ID, &chip_id)) { - this->mark_failed(LOG_STR("Failed to read chip ID")); + if (!this->check_chip_id(this, QWIIC_PIR_CHIP_ID, QWIIC_PIR_DEVICE_ID)) return; - } - - if (chip_id != QWIIC_PIR_DEVICE_ID) { - this->mark_failed(LOG_STR("Unknown chip ID")); - return; - } if (!this->write_byte_16(QWIIC_PIR_DEBOUNCE_TIME, this->debounce_time_)) { this->mark_failed(LOG_STR("Failed to configure debounce time")); diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index 7d66dd0a183..5132b2a3aa5 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -19,11 +19,8 @@ static const uint8_t WHOAMI_STTS22H_IDENTIFICATION = 0xA0; // ID value of STTS2 static const float SENSOR_SCALE = 0.01f; // Sensor resolution in degrees Celsius void STTS22HComponent::setup() { - // Check if device is a STTS22H - if (!this->is_stts22h_sensor_()) { - this->mark_failed(LOG_STR("Device is not a STTS22H sensor")); + if (!this->check_chip_id(this, WHOAMI_REG, WHOAMI_STTS22H_IDENTIFICATION)) return; - } this->initialize_sensor_(); } @@ -58,21 +55,6 @@ float STTS22HComponent::read_temperature_() { return temp_raw_value * SENSOR_SCALE; // Apply sensor resolution } -bool STTS22HComponent::is_stts22h_sensor_() { - uint8_t whoami_value; - if (this->read_register(WHOAMI_REG, &whoami_value, 1) != i2c::NO_ERROR) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return false; - } - - if (whoami_value != WHOAMI_STTS22H_IDENTIFICATION) { - this->mark_failed(LOG_STR("Unexpected WHOAMI identifier. Sensor is not a STTS22H")); - return false; - } - - return true; -} - void STTS22HComponent::initialize_sensor_() { // Read current CTRL_REG configuration uint8_t ctrl_value; diff --git a/esphome/components/stts22h/stts22h.h b/esphome/components/stts22h/stts22h.h index d8d7a485cf6..e1becb4b507 100644 --- a/esphome/components/stts22h/stts22h.h +++ b/esphome/components/stts22h/stts22h.h @@ -14,7 +14,6 @@ class STTS22HComponent final : public sensor::Sensor, public PollingComponent, p protected: void initialize_sensor_(); - bool is_stts22h_sensor_(); float read_temperature_(); }; diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index f1f9ae9cfdb..493f4ad3627 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -62,18 +62,8 @@ void TSL2591Component::setup() { break; } - uint8_t id; - if (!this->read_byte(TSL2591_COMMAND_BIT | TSL2591_REGISTER_DEVICE_ID, &id)) { - ESP_LOGE(TAG, "I2C read failed"); - this->mark_failed(); + if (!this->check_chip_id(this, TSL2591_COMMAND_BIT | TSL2591_REGISTER_DEVICE_ID, 0x50)) return; - } - - if (id != 0x50) { - ESP_LOGE(TAG, "Unknown chip ID"); - this->mark_failed(); - return; - } this->set_integration_time_and_gain(this->integration_time_, this->gain_); this->disable_if_power_saving_(); From 1ead61f11f3349fc7cd41dede4891b5de96cfd1e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:50:11 -0400 Subject: [PATCH 040/179] [esp32] Remove trailing comment from ECDSA test signing key (#20988) --- tests/components/esp32/dummy_signing_key_v1_ecdsa.pem | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem index bd092056069..eb27f9b2c48 100644 --- a/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem +++ b/tests/components/esp32/dummy_signing_key_v1_ecdsa.pem @@ -4,4 +4,3 @@ MHcCAQEEIEZIp96p7Z7QN6vxOFE5FdRNm535vW81Ax07KnGxVjiMoAoGCCqGSM49 AwEHoUQDQgAEK+fBQDn1Q+r5lGwcDoMUgeg2Aq16LLrLUz7xWI6mS0PUClzolDIo eaV/Pfjl7zAvkbQQsZq3rTNnr1eGAk5P+A== -----END EC PRIVATE KEY----- -*** DO NOT USE THIS KEY...EVER *** From d16d8c111ebf992f3d9054295298d030533ef1f2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:50:54 -0400 Subject: [PATCH 041/179] [debug] Log uptime in dump_config (#20989) --- esphome/components/debug/debug_component.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 97f8550c5bc..b2cb1910f68 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -34,6 +34,15 @@ void DebugComponent::dump_config() { this->free_heap_ = get_free_heap_(); ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); + uint32_t uptime = static_cast(millis_64() / 1000); + uint32_t days = uptime / 86400; + uptime -= days * 86400; + uint32_t hours = uptime / 3600; + uptime -= hours * 3600; + uint32_t minutes = uptime / 60; + uptime -= minutes * 60; + ESP_LOGD(TAG, "Uptime: %" PRIu32 "d %02" PRIu32 "h %02" PRIu32 "m %02" PRIu32 "s", days, hours, minutes, uptime); + pos = get_device_info_(std::span(device_info_buffer), pos); #ifdef USE_TEXT_SENSOR From 635df9c6ada2d7eb88ce69a60b635dbd45ae8eda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 06:51:29 -1000 Subject: [PATCH 042/179] [socket] Share the allow-list reject log and config lines through Ipv4Allow (#20983) --- esphome/components/socket/__init__.py | 1 + esphome/components/socket/ipv4_allow.cpp | 36 +++++++++++++++++ esphome/components/socket/ipv4_allow.h | 9 ++++- esphome/components/socket/tcp_listener.cpp | 22 ++-------- esphome/components/socket/tcp_listener.h | 1 - .../socket/test_ipv4_allow_host.cpp | 40 ++++++++++++++++++- 6 files changed, 87 insertions(+), 22 deletions(-) create mode 100644 esphome/components/socket/ipv4_allow.cpp diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index effa7d864fc..09d58d22e9f 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -274,6 +274,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines( "lwip_raw_udp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "bsd_sockets_impl.cpp": "USE_SOCKET_IMPL_BSD_SOCKETS", "lwip_sockets_impl.cpp": "USE_SOCKET_IMPL_LWIP_SOCKETS", + "ipv4_allow.cpp": "USE_SOCKET_IPV4_ALLOW", "ipv4_resolve.cpp": "USE_SOCKET_IPV4_RESOLVE", "tcp_client_link.cpp": "USE_SOCKET_TCP_CLIENT_LINK", "tcp_listener.cpp": "USE_SOCKET_TCP_LISTENER", diff --git a/esphome/components/socket/ipv4_allow.cpp b/esphome/components/socket/ipv4_allow.cpp new file mode 100644 index 00000000000..4791eeda369 --- /dev/null +++ b/esphome/components/socket/ipv4_allow.cpp @@ -0,0 +1,36 @@ +#include "ipv4_allow.h" + +#ifdef USE_SOCKET_IPV4_ALLOW + +#include "esphome/core/log.h" + +#include + +namespace esphome::socket { + +bool Ipv4Allow::admit(const struct sockaddr *peer, socklen_t len, const char *tag, uint32_t now) { + if (this->allows(peer)) { + return true; + } + if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { + this->last_reject_log_ms_ = now; + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(peer, len, std::span(text)); + ESP_LOGW(tag, "Rejected %s", text); + } + return false; +} + +void Ipv4Allow::dump_config(const char *tag) const { + for (size_t i = 0; i < this->count_; i++) { + const Ipv4AllowEntry e = this->entry(i); + // Network order is dotted order, and the contiguous mask's popcount is the prefix. + const auto *b = reinterpret_cast(&e.addr); + ESP_LOGCONFIG(tag, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], + static_cast(__builtin_popcount(e.mask))); + } +} + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/socket/ipv4_allow.h b/esphome/components/socket/ipv4_allow.h index f2f66edc05f..64116691812 100644 --- a/esphome/components/socket/ipv4_allow.h +++ b/esphome/components/socket/ipv4_allow.h @@ -47,6 +47,10 @@ class Ipv4Allow { return false; } + /// allows(peer), logging a refused peer at most once every REJECT_LOG_INTERVAL_MS. + bool admit(const struct sockaddr *peer, socklen_t len, const char *tag, uint32_t now); + /// One config line per allowed network. + void dump_config(const char *tag) const; size_t size() const { return this->count_; } /// A copy of entry i, read from flash. Ipv4AllowEntry entry(size_t i) const { @@ -55,9 +59,12 @@ class Ipv4Allow { return e; } - private: + protected: + static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; + const Ipv4AllowEntry *entries_{nullptr}; size_t count_{0}; + uint32_t last_reject_log_ms_{0}; }; } // namespace esphome::socket diff --git a/esphome/components/socket/tcp_listener.cpp b/esphome/components/socket/tcp_listener.cpp index e1f00e29277..cfd53b9e83f 100644 --- a/esphome/components/socket/tcp_listener.cpp +++ b/esphome/components/socket/tcp_listener.cpp @@ -12,9 +12,6 @@ namespace esphome::socket { // One client at a time; a second connection waits in the stack until the first drops. static constexpr int LISTEN_BACKLOG = 1; -#ifdef USE_SOCKET_IPV4_ALLOW -static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; -#endif void TcpListener::try_listen_(TcpClientLink &link) { this->listen_ = socket_ip_loop_monitored(SOCK_STREAM, IPPROTO_TCP); @@ -57,31 +54,20 @@ void TcpListener::accept_(TcpClientLink &link) { return; } const auto *sa = reinterpret_cast(&peer); - char text[SOCKADDR_STR_LEN]; - format_sockaddr_to(sa, peer_len, std::span(text)); #ifdef USE_SOCKET_IPV4_ALLOW - if (!this->allow_.allows(sa)) { - uint32_t now = App.get_loop_component_start_time(); - if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { - this->last_reject_log_ms_ = now; - ESP_LOGW(this->tag_, "Rejected %s", text); - } + if (!this->allow_.admit(sa, peer_len, this->tag_, App.get_loop_component_start_time())) { return; } #endif + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(sa, peer_len, std::span(text)); link.adopt(std::move(client)); ESP_LOGI(this->tag_, "Client connected from %s", text); } void TcpListener::dump_config() const { #ifdef USE_SOCKET_IPV4_ALLOW - for (size_t i = 0; i < this->allow_.size(); i++) { - Ipv4AllowEntry e = this->allow_.entry(i); - // Network order is dotted order, and the contiguous mask's popcount is the prefix. - const auto *b = reinterpret_cast(&e.addr); - ESP_LOGCONFIG(this->tag_, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], - static_cast(__builtin_popcount(e.mask))); - } + this->allow_.dump_config(this->tag_); #endif } diff --git a/esphome/components/socket/tcp_listener.h b/esphome/components/socket/tcp_listener.h index a42080e641f..6448a969bdd 100644 --- a/esphome/components/socket/tcp_listener.h +++ b/esphome/components/socket/tcp_listener.h @@ -50,7 +50,6 @@ class TcpListener { std::unique_ptr listen_; const char *tag_{nullptr}; #ifdef USE_SOCKET_IPV4_ALLOW - uint32_t last_reject_log_ms_{0}; Ipv4Allow allow_; #endif }; diff --git a/tests/components/socket/test_ipv4_allow_host.cpp b/tests/components/socket/test_ipv4_allow_host.cpp index a8924de3713..ab89a59b78a 100644 --- a/tests/components/socket/test_ipv4_allow_host.cpp +++ b/tests/components/socket/test_ipv4_allow_host.cpp @@ -9,8 +9,8 @@ namespace esphome::socket::testing { -// The size_t count packs into the pointer's padding; no RAM over a uint8_t. -static_assert(sizeof(Ipv4Allow) == 2 * sizeof(void *), "unexpected padding in Ipv4Allow"); +// Entries pointer, count and the reject-log timestamp; nothing else may creep in. +static_assert(sizeof(Ipv4Allow) <= 3 * sizeof(void *), "unexpected padding in Ipv4Allow"); // 192.168.175.20/32 and 192.168.175.0/24, network order, host bits cleared, // mirroring what add_ipv4_allow emits. @@ -26,6 +26,42 @@ static bool allows_peer(const Ipv4Allow &list, const char *ip) { return list.allows(reinterpret_cast(&peer)); } +// Exposes the reject-log timestamp, the only trace the throttle leaves besides the log line. +struct ThrottledAllow : public Ipv4Allow { + using Ipv4Allow::last_reject_log_ms_; +}; + +// admit() with the same production parser. +static bool admits_peer(Ipv4Allow &list, const char *ip, uint32_t now) { + struct sockaddr_storage peer {}; + socklen_t len = set_sockaddr(reinterpret_cast(&peer), sizeof(peer), ip, 0); + EXPECT_NE(len, 0); + return list.admit(reinterpret_cast(&peer), len, "test", now); +} + +TEST(Ipv4Allow, AdmitLogsARejectAtMostOncePerInterval) { + ThrottledAllow list; + list.set(ENTRIES, std::size(ENTRIES)); + EXPECT_TRUE(admits_peer(list, "192.168.175.66", 1000)); + EXPECT_TRUE(admits_peer(list, "::ffff:192.168.175.20", 1000)); + EXPECT_EQ(list.last_reject_log_ms_, 0u); + + EXPECT_FALSE(admits_peer(list, "10.0.0.1", 1000)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + // Inside the interval the peer is still refused, but the log line is skipped. + EXPECT_FALSE(admits_peer(list, "10.0.0.2", 1001)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + EXPECT_FALSE(admits_peer(list, "10.0.0.3", 5999)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + // Exactly one interval later it logs again. + EXPECT_FALSE(admits_peer(list, "10.0.0.4", 6000)); + EXPECT_EQ(list.last_reject_log_ms_, 6000u); + // An admitted peer leaves the throttle alone. + EXPECT_TRUE(admits_peer(list, "192.168.175.1", 6001)); + EXPECT_EQ(list.last_reject_log_ms_, 6000u); + list.dump_config("test"); +} + TEST(Ipv4Allow, EmptyAllowsEveryPeer) { Ipv4Allow list; EXPECT_TRUE(list.allows(htonl(0xC0A8AF01))); From 35276a38bc7e769f8e9c2f32ec1757338a354e34 Mon Sep 17 00:00:00 2001 From: Christian Date: Sat, 17 Oct 2026 18:18:52 +0100 Subject: [PATCH 043/179] [rtsp_server] Add an RTSP server that streams the esp32_camera picture (#17990) Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/camera/camera.h | 3 +- esphome/components/rtsp_server/__init__.py | 85 +++ .../components/rtsp_server/rtsp_server.cpp | 144 +++++ esphome/components/rtsp_server/rtsp_server.h | 50 ++ .../components/rtsp_server/rtsp_session.cpp | 545 ++++++++++++++++++ esphome/components/rtsp_server/rtsp_session.h | 119 ++++ esphome/core/defines.h | 1 + tests/components/rtsp_server/__init__.py | 13 + tests/components/rtsp_server/common.yaml | 10 + .../rtsp_server/test.esp32-idf.yaml | 3 + .../rtsp_server/test_rtsp_session_host.cpp | 367 ++++++++++++ 12 files changed, 1340 insertions(+), 1 deletion(-) create mode 100644 esphome/components/rtsp_server/__init__.py create mode 100644 esphome/components/rtsp_server/rtsp_server.cpp create mode 100644 esphome/components/rtsp_server/rtsp_server.h create mode 100644 esphome/components/rtsp_server/rtsp_session.cpp create mode 100644 esphome/components/rtsp_server/rtsp_session.h create mode 100644 tests/components/rtsp_server/__init__.py create mode 100644 tests/components/rtsp_server/common.yaml create mode 100644 tests/components/rtsp_server/test.esp32-idf.yaml create mode 100644 tests/components/rtsp_server/test_rtsp_session_host.cpp diff --git a/CODEOWNERS b/CODEOWNERS index c9ff7c6bb32..f23f3613032 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -512,6 +512,7 @@ esphome/components/rp2040_pwm/* @jesserockz esphome/components/rp2_ble_tracker/* @bdraco esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 +esphome/components/rtsp_server/* @max246 esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index 433361d2985..9e3a95220fa 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -11,8 +11,9 @@ namespace esphome::camera { * IDLE: Camera requests to send an image to the API. * API_REQUESTER: API requests a new image. * WEB_REQUESTER: ESP32 web server request an image. Ignored by API. + * RTSP_REQUESTER: RTSP server requests a stream of images. Ignored by API and web server. */ -enum CameraRequester : uint8_t { IDLE, API_REQUESTER, WEB_REQUESTER }; +enum CameraRequester : uint8_t { IDLE, API_REQUESTER, WEB_REQUESTER, RTSP_REQUESTER }; /// Enumeration of different pixel formats. enum PixelFormat : uint8_t { diff --git a/esphome/components/rtsp_server/__init__.py b/esphome/components/rtsp_server/__init__.py new file mode 100644 index 00000000000..86b1d380267 --- /dev/null +++ b/esphome/components/rtsp_server/__init__.py @@ -0,0 +1,85 @@ +import esphome.codegen as cg +from esphome.components import network, socket +from esphome.components.const import CONF_ALLOWED_IPS +from esphome.components.esp32_camera import ( + CONF_JPEG_QUALITY, + CONF_PIXEL_FORMAT, + DOMAIN as ESP32_CAMERA_DOMAIN, + FRAME_SIZES, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MAX_CONNECTIONS, CONF_PORT, CONF_RESOLUTION +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@max246"] +AUTO_LOAD = ["camera", "socket"] +DEPENDENCIES = ["network"] +DOMAIN = "rtsp_server" + +# RFC 2435 stores width and height in units of 8 pixels in one byte each. +RTP_JPEG_MAX_DIMENSION = 255 * 8 + +rtsp_server_ns = cg.esphome_ns.namespace("rtsp_server") +RTSPServer = rtsp_server_ns.class_("RTSPServer", cg.Component) + + +def _consume_rtsp_server_sockets(config: ConfigType) -> ConfigType: + socket.consume_sockets(config[CONF_MAX_CONNECTIONS], DOMAIN)(config) + socket.consume_sockets(1, DOMAIN, socket.SocketType.TCP_LISTEN)(config) + # A 13 KB frame fills the default 5.7 KB TCP send buffer four segments at a time; over Wi-Fi + # that is about 1 fps, so ask for the larger buffers (network: enable_high_performance: false undoes it) + network.require_high_performance_networking() + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RTSPServer), + cv.Optional(CONF_PORT, default=554): cv.port, + cv.Optional(CONF_MAX_CONNECTIONS, default=2): cv.int_range(min=1, max=4), + cv.Optional(CONF_ALLOWED_IPS): socket.IPV4_ALLOW_SCHEMA, + } + ).extend(cv.COMPONENT_SCHEMA), + _consume_rtsp_server_sockets, +) + + +def _final_validate(config: ConfigType) -> None: + camera = fv.full_config.get().get(ESP32_CAMERA_DOMAIN) + if camera is None: + raise cv.Invalid( + f"{DOMAIN} requires a camera, add an '{ESP32_CAMERA_DOMAIN}:' block" + ) + pixel_format = camera[CONF_PIXEL_FORMAT] + # A GRAYSCALE frame converts to a one-component JPEG, which RTP/JPEG cannot carry. + if pixel_format == "GRAYSCALE": + raise cv.Invalid( + f"{DOMAIN} cannot stream '{CONF_PIXEL_FORMAT}: GRAYSCALE', use a color " + f"format in '{ESP32_CAMERA_DOMAIN}:'" + ) + # Other formats are converted to JPEG only when jpeg_quality is set. + if pixel_format != "JPEG" and camera[CONF_JPEG_QUALITY] == 0: + raise cv.Invalid( + f"{DOMAIN} streams JPEG frames, set '{CONF_JPEG_QUALITY}' in " + f"'{ESP32_CAMERA_DOMAIN}:' to convert '{CONF_PIXEL_FORMAT}: {pixel_format}'" + ) + resolution = camera[CONF_RESOLUTION] + # The enum names end in the frame size, e.g. ESP32_CAMERA_SIZE_2560X1440 + width, height = map(int, str(FRAME_SIZES[resolution]).rsplit("_", 1)[1].split("X")) + if max(width, height) > RTP_JPEG_MAX_DIMENSION: + raise cv.Invalid( + f"{DOMAIN} supports frames up to {RTP_JPEG_MAX_DIMENSION} pixels per side, " + f"lower '{CONF_RESOLUTION}' in '{ESP32_CAMERA_DOMAIN}:' (got {width}x{height})" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_PORT]) + await cg.register_component(var, config) + cg.add_define("MAX_RTSP_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) + socket.add_ipv4_allow(var.set_allow, config.get(CONF_ALLOWED_IPS), config[CONF_ID]) diff --git a/esphome/components/rtsp_server/rtsp_server.cpp b/esphome/components/rtsp_server/rtsp_server.cpp new file mode 100644 index 00000000000..3c344e354d7 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_server.cpp @@ -0,0 +1,144 @@ +#include "rtsp_server.h" + +#ifdef USE_CAMERA + +#include +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::rtsp_server { + +ESPHOME_LOG_TAG(TAG, "rtsp_server"); + +void RTSPServer::socket_failed_(const char *what) { + ESP_LOGE(TAG, "Socket %s failed: errno %d", what, errno); + delete this->socket_; + this->socket_ = nullptr; + this->mark_failed(); +} + +void RTSPServer::setup() { + this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); + if (this->socket_ == nullptr) { + ESP_LOGE(TAG, "Socket creation failed: errno %d", errno); + this->mark_failed(); + return; + } + + int enable = 1; + this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable)); + + if (this->socket_->setblocking(false) != 0) { + this->socket_failed_("nonblocking"); + return; + } + + struct sockaddr_storage server {}; + socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); + if (sl == 0 || this->socket_->bind((struct sockaddr *) &server, sl) != 0) { + this->socket_failed_("bind"); + return; + } + + if (this->socket_->listen(4) != 0) { + this->socket_failed_("listen"); + return; + } + + camera::Camera::instance()->add_listener(this); +} + +void RTSPServer::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (this->socket_ != nullptr && this->socket_->ready()) { + this->accept_new_connections_(now); + } + + bool playing = false; + for (auto &session : this->sessions_) { + session.loop(now, this->port_); + playing |= session.is_playing(); + } + + if (playing != this->streaming_) { + this->streaming_ = playing; + if (playing) { + camera::Camera::instance()->start_stream(camera::RTSP_REQUESTER); + } else { + camera::Camera::instance()->stop_stream(camera::RTSP_REQUESTER); + } + } +} + +void RTSPServer::accept_new_connections_(uint32_t now) { + while (true) { + struct sockaddr_storage source_addr; + socklen_t addr_len = sizeof(source_addr); + + auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); + if (!sock) + break; +#ifdef USE_SOCKET_IPV4_ALLOW + if (!this->allow_.admit(reinterpret_cast(&source_addr), addr_len, TAG, now)) + continue; +#endif + + RTSPSession *free_slot = nullptr; + for (auto &session : this->sessions_) { + if (!session.is_active()) { + free_slot = &session; + break; + } + } + if (free_slot == nullptr) { + ESP_LOGW(TAG, "Max connections (%d) reached, rejecting connection", MAX_RTSP_CONNECTIONS); + continue; + } + + ESP_LOGD(TAG, "Accepted RTSP connection"); + free_slot->open(std::move(sock), now); + } +} + +void RTSPServer::on_camera_image(const std::shared_ptr &image) { + if (!this->streaming_ || !image->was_requested_by(camera::RTSP_REQUESTER)) + return; + + JpegInfo jpeg; + if (!parse_jpeg_info(image->get_data_buffer(), image->get_data_length(), jpeg) || + jpeg.width > RTP_JPEG_MAX_DIMENSION || jpeg.height > RTP_JPEG_MAX_DIMENSION) { + if (!this->frame_warned_) { + this->frame_warned_ = true; + ESP_LOGW(TAG, + "Dropping frame %" PRIu16 "x%" PRIu16 ": not a baseline 4:2:2/4:2:0 JPEG without restart markers" + " of at most %" PRIu16 " px per side", + jpeg.width, jpeg.height, RTP_JPEG_MAX_DIMENSION); + } + return; + } + + const uint32_t now = App.get_loop_component_start_time(); + for (auto &session : this->sessions_) { + session.on_new_frame(image, jpeg, now); + } +} + +void RTSPServer::dump_config() { + ESP_LOGCONFIG(TAG, + "RTSP Server:\n" + " Port: %u\n" + " Max connections: %u", + this->port_, MAX_RTSP_CONNECTIONS); +#ifdef USE_SOCKET_IPV4_ALLOW + this->allow_.dump_config(TAG); +#endif +} + +float RTSPServer::get_setup_priority() const { return setup_priority::LATE; } + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_server.h b/esphome/components/rtsp_server/rtsp_server.h new file mode 100644 index 00000000000..48b7fd77ed5 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_server.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_CAMERA + +#include +#include + +#include "esphome/components/camera/camera.h" +#ifdef USE_SOCKET_IPV4_ALLOW +#include "esphome/components/socket/ipv4_allow.h" +#endif +#include "esphome/components/socket/socket.h" +#include "esphome/core/component.h" +#include "rtsp_session.h" + +namespace esphome::rtsp_server { + +class RTSPServer : public Component, public camera::CameraListener { + public: + explicit RTSPServer(uint16_t port) : port_(port) {} + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + void on_camera_image(const std::shared_ptr &image) override; +#ifdef USE_SOCKET_IPV4_ALLOW + void set_allow(const socket::Ipv4AllowEntry *entries, size_t count) { this->allow_.set(entries, count); } +#endif + + protected: + void accept_new_connections_(uint32_t now); + void socket_failed_(const char *what); + + socket::ListenSocket *socket_{nullptr}; + std::array sessions_{}; +#ifdef USE_SOCKET_IPV4_ALLOW + socket::Ipv4Allow allow_; +#endif + uint16_t port_; + bool streaming_{false}; + bool frame_warned_{false}; +}; + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_session.cpp b/esphome/components/rtsp_server/rtsp_session.cpp new file mode 100644 index 00000000000..ee188f07b20 --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_session.cpp @@ -0,0 +1,545 @@ +#include "rtsp_session.h" + +#ifdef USE_CAMERA + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::rtsp_server { + +ESPHOME_LOG_TAG(TAG, "rtsp_server"); + +static constexpr uint8_t RTP_PAYLOAD_TYPE_JPEG = 26; +static constexpr uint32_t RTP_CLOCK_RATE = 90000; +static constexpr uint32_t SESSION_TIMEOUT_MS = 60000; +static constexpr char REQUEST_END[] = "\r\n\r\n"; +static constexpr size_t REQUEST_END_LEN = sizeof(REQUEST_END) - 1; +static constexpr uint8_t ZERO_FILL[64]{}; +static constexpr char SDP_BODY[] = "v=0\r\n" + "o=- 0 0 IN IP4 0.0.0.0\r\n" + "s=ESPHome RTSP\r\n" + "t=0 0\r\n" + "m=video 0 RTP/AVP 26\r\n" + "a=control:trackID=0\r\n"; + +namespace { + +StringRef trim(const StringRef &s) { + const char *start = s.c_str(); + size_t len = s.size(); + while (len > 0 && (*start == ' ' || *start == '\t')) { + start++; + len--; + } + while (len > 0 && (start[len - 1] == ' ' || start[len - 1] == '\t' || start[len - 1] == '\r')) { + len--; + } + return {start, len}; +} + +const char *find_request_end(const char *buf, size_t len) { + for (size_t i = 0; i + REQUEST_END_LEN <= len; i++) { + if (std::memcmp(buf + i, REQUEST_END, REQUEST_END_LEN) == 0) + return buf + i; + } + return nullptr; +} + +/// Bounded substring search; StringRef::find() uses strstr and could run past the header value. +bool contains(const StringRef &hay, const StringRef &needle) { + if (needle.size() > hay.size()) + return false; + for (size_t i = 0; i + needle.size() <= hay.size(); i++) { + if (std::memcmp(hay.c_str() + i, needle.c_str(), needle.size()) == 0) + return true; + } + return false; +} + +} // namespace + +/// Scans JPEG marker segments (SOF0/1, DQT, SOS) for the dimensions, quantization tables and the start of +/// the entropy-coded scan data. Assumes the standard Huffman tables (true for esp32-camera), so DHT segments +/// are not parsed; receivers rebuild them per RFC 2435. Only three-component 4:2:2 or 4:2:0 frames without +/// restart markers (DRI) can be carried as RFC 2435 types 0 and 1; anything else returns false. +bool parse_jpeg_info(const uint8_t *data, size_t len, JpegInfo &out) { + if (len < 4 || data[0] != 0xFF || data[1] != 0xD8) + return false; // no SOI + + size_t pos = 2; + while (pos + 2 <= len) { + if (data[pos] != 0xFF) { + pos++; // resync on a stray non-marker byte + continue; + } + uint8_t marker = data[pos + 1]; + if (marker == 0xFF) { + pos++; // fill byte before the real marker + continue; + } + if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD9)) { + pos += 2; // standalone marker, no length field (TEM, RSTn, SOI, EOI) + continue; + } + if (pos + 4 > len) + break; + + uint16_t seg_len = encode_uint16(data[pos + 2], data[pos + 3]); + size_t seg_start = pos + 4; + if (seg_len < 2 || seg_start + (seg_len - 2) > len) + return false; + size_t seg_end = seg_start + (seg_len - 2); + + if (marker == 0xC0 || marker == 0xC1) { // SOF0 / SOF1 + if (seg_len < 8 + 3 * 3 || data[seg_start + 5] != 3) + return false; // not three components + out.height = encode_uint16(data[seg_start + 1], data[seg_start + 2]); + out.width = encode_uint16(data[seg_start + 3], data[seg_start + 4]); + const uint8_t sampling = data[seg_start + 7]; // Y component H/V sampling factors + if (sampling == 0x21) { + out.type = 0; // 4:2:2 + } else if (sampling == 0x22) { + out.type = 1; // 4:2:0 + } else { + return false; + } + } else if (marker == 0xDD) { // DRI + return false; + } else if (marker == 0xDB) { // DQT, one or more tables back to back + size_t p = seg_start; + while (p < seg_end) { + uint8_t precision = data[p] >> 4; + uint8_t table_id = data[p] & 0x0F; + size_t table_len = precision == 0 ? QTABLE_LEN : 2 * QTABLE_LEN; + if (p + 1 + table_len > seg_end) + break; + if (precision == 0 && table_id == 0) { + out.qtable0 = &data[p + 1]; + } else if (precision == 0 && table_id == 1) { + out.qtable1 = &data[p + 1]; + } + p += 1 + table_len; + } + } else if (marker == 0xDA) { // SOS, scan data follows + if (out.width == 0 || out.height == 0 || out.qtable0 == nullptr) + return false; + if (out.qtable1 == nullptr) + out.qtable1 = out.qtable0; + out.scan_data = data + seg_end; + out.scan_len = len - seg_end; + if (out.scan_len >= 2 && out.scan_data[out.scan_len - 2] == 0xFF && out.scan_data[out.scan_len - 1] == 0xD9) + out.scan_len -= 2; // the RFC 2435 payload is the entropy-coded data only + return true; + } + + pos = seg_end; + } + return false; +} + +void RTSPSession::open(std::unique_ptr socket, uint32_t now) { + this->socket_ = std::move(socket); + this->socket_->setblocking(false); + int enable = 1; + this->socket_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(enable)); + this->should_close_ = false; + this->close_after_send_ = false; + this->read_pending_ = false; + this->request_len_ = 0; + this->skip_len_ = 0; + this->filler_len_ = 0; + this->cseq_ = 0; + this->last_frame_ms_ = 0; + this->last_activity_ms_ = now; + this->ssrc_ = random_uint32(); + this->sequence_number_ = static_cast(random_uint32()); + this->rtp_timestamp_ = random_uint32(); + format_hex_to(this->session_id_, random_uint32()); +} + +void RTSPSession::close_() { + this->frame_.reset(); + this->socket_.reset(); + this->state_ = State::INIT; + this->iov_pos_ = 0; + this->iov_count_ = 0; + this->filler_len_ = 0; +} + +void RTSPSession::loop(uint32_t now, uint16_t port) { + if (this->socket_ == nullptr) + return; + if (now - this->last_activity_ms_ > SESSION_TIMEOUT_MS) { + ESP_LOGW(TAG, "Session %s timed out", this->session_id_); + this->should_close_ = true; + } else { + this->service_(now, port); + } + if (this->should_close_) + this->close_(); +} + +void RTSPSession::service_(uint32_t now, uint16_t port) { + if (this->frame_ != nullptr && now - this->frame_progress_ms_ > FRAME_STALL_MS) { + ESP_LOGV(TAG, "Session %s stalled, dropping frame", this->session_id_); + this->drop_frame_(); + } + if (!this->flush_(now)) + return; + if (!this->close_after_send_) + this->read_requests_(now, port); + // Drain the current frame until done or the socket backs up; one packet per loop would cap the frame rate. + while (this->flush_(now)) { + if (this->close_after_send_) { + this->should_close_ = true; + return; + } + if (!this->queue_next_packet_()) + return; + } +} + +void RTSPSession::read_requests_(uint32_t now, uint16_t port) { + // A pipelined request may already be buffered; it is handled once the previous response is written. + if (this->process_input_(port)) + return; + if (!this->read_pending_ && !this->socket_->ready()) + return; + this->read_pending_ = false; + + while (true) { + if (this->request_len_ >= this->request_buf_.size()) { + ESP_LOGW(TAG, "Request too large, closing session"); + this->should_close_ = true; + return; + } + ssize_t n = this->socket_->read(this->request_buf_.data() + this->request_len_, + this->request_buf_.size() - this->request_len_); + if (n > 0) { + this->request_len_ += static_cast(n); + this->last_activity_ms_ = now; + if (this->process_input_(port)) { + this->read_pending_ = true; // stopped before EAGAIN, so ready() may not report what is left + return; + } + continue; + } + if (n == 0) { + this->should_close_ = true; + return; + } + if (errno != EAGAIN && errno != EWOULDBLOCK) { + ESP_LOGW(TAG, "Read failed: errno %d", errno); + this->should_close_ = true; + } + return; + } +} + +bool RTSPSession::process_input_(uint16_t port) { + while (this->request_len_ > 0) { + if (this->skip_len_ > 0) { + const size_t len = std::min(this->skip_len_, this->request_len_); + this->consume_input_(len); + this->skip_len_ -= len; + continue; + } + char *buf = this->request_buf_.data(); + if (buf[0] == '$') { // interleaved packet: '$', channel, 16-bit length, payload + if (this->request_len_ < INTERLEAVED_HEADER_LEN) + return false; + this->skip_len_ = + INTERLEAVED_HEADER_LEN + encode_uint16(static_cast(buf[2]), static_cast(buf[3])); + continue; + } + const char *end = find_request_end(buf, this->request_len_); + if (end == nullptr) + return false; // wait for the rest of the request + const size_t len = end - buf; + buf[len] = '\0'; // over the terminator's first CR + this->handle_request_(StringRef(buf, len), port); + this->consume_input_(len + REQUEST_END_LEN); + return true; + } + return false; +} + +void RTSPSession::consume_input_(size_t len) { + this->request_len_ -= len; + std::memmove(this->request_buf_.data(), this->request_buf_.data() + len, this->request_len_); +} + +void RTSPSession::handle_request_(StringRef request, uint16_t port) { + size_t line_end = request.find("\r\n"); + StringRef first_line = line_end == std::string::npos ? request : StringRef(request.c_str(), line_end); + + this->cseq_ = 0; + StringRef transport; + int64_t content_length = 0; + + // Headers first: even a 400 reply has to echo the request's CSeq + size_t pos = line_end == std::string::npos ? request.size() : line_end + 2; + while (pos < request.size()) { + size_t next = request.find("\r\n", pos); + size_t header_end = next == std::string::npos ? request.size() : next; + StringRef header(request.c_str() + pos, header_end - pos); + + size_t colon = header.find(':'); + if (colon != std::string::npos) { + StringRef key = trim(StringRef(header.c_str(), colon)); + StringRef value = trim(StringRef(header.c_str() + colon + 1, header.size() - colon - 1)); + if (str_equals_case_insensitive(key, StringRef::from_lit("CSeq"))) { + this->cseq_ = static_cast(stol(value)); + } else if (str_equals_case_insensitive(key, StringRef::from_lit("Transport"))) { + transport = value; + } else if (str_equals_case_insensitive(key, StringRef::from_lit("Content-Length"))) { + content_length = stol(value); + } + } + + if (next == std::string::npos) + break; + pos = next + 2; + } + + if (content_length < 0 || content_length > static_cast(this->request_buf_.size())) { + this->queue_response_(400, "Bad Request"); + this->close_after_send_ = true; + return; + } + // The body is ignored; it is discarded as it arrives, like an interleaved packet. + this->skip_len_ = static_cast(content_length); + + size_t sp1 = first_line.find(' '); + size_t sp2 = sp1 == std::string::npos ? std::string::npos : first_line.find(' ', sp1 + 1); + if (sp1 == std::string::npos || sp2 == std::string::npos) { + this->queue_response_(400, "Bad Request"); + return; + } + StringRef method(first_line.c_str(), sp1); + + if (str_equals_case_insensitive(method, StringRef::from_lit("OPTIONS"))) { + this->queue_response_(200, "OK", "Public: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN, GET_PARAMETER\r\n"); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("DESCRIBE"))) { + char addr_buf[socket::SOCKADDR_STR_LEN]; + this->socket_->getsockname_to(addr_buf); + const bool ipv6 = std::strchr(addr_buf, ':') != nullptr; + this->queue_response_(200, "OK", + "Content-Base: rtsp://%s%s%s:%u/\r\n" + "Content-Type: application/sdp\r\n" + "Content-Length: %u\r\n", + ipv6 ? "[" : "", addr_buf, ipv6 ? "]" : "", port, + static_cast(sizeof(SDP_BODY) - 1)); + this->iov_[this->iov_count_++] = {const_cast(SDP_BODY), sizeof(SDP_BODY) - 1}; + } else if (str_equals_case_insensitive(method, StringRef::from_lit("SETUP"))) { + this->handle_setup_(transport); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("PLAY"))) { + this->handle_play_(); + } else if (str_equals_case_insensitive(method, StringRef::from_lit("TEARDOWN"))) { + this->stop_playing_(); + this->queue_response_(200, "OK"); + this->close_after_send_ = true; + } else if (str_equals_case_insensitive(method, StringRef::from_lit("GET_PARAMETER"))) { + this->queue_response_(200, "OK"); + } else { + this->queue_response_(501, "Not Implemented"); + } +} + +void RTSPSession::queue_response_(uint16_t code, const char *reason, const char *extra_headers_fmt, ...) { + char *buf = this->send_buf_.data(); + const size_t size = this->send_buf_.size(); + size_t pos = buf_append_printf(buf, size, 0, "RTSP/1.0 %u %s\r\nCSeq: %" PRIu32 "\r\n", code, reason, this->cseq_); + if (this->state_ != State::INIT) + pos = buf_append_printf(buf, size, pos, "Session: %s;timeout=60\r\n", this->session_id_); + if (extra_headers_fmt != nullptr && pos < size) { + va_list args; + va_start(args, extra_headers_fmt); + int written = vsnprintf(buf + pos, size - pos, extra_headers_fmt, args); + va_end(args); + if (written > 0) + pos = std::min(pos + static_cast(written), size); + } + pos = buf_append_str(buf, size, pos, "\r\n"); + this->iov_[0] = {buf, std::min(pos, size - 1)}; + this->iov_pos_ = 0; + this->iov_count_ = 1; + this->sending_packet_ = false; +} + +void RTSPSession::handle_setup_(StringRef transport_header) { + if (!contains(transport_header, StringRef::from_lit("RTP/AVP/TCP"))) { + this->queue_response_(461, "Unsupported Transport"); + return; + } + // A SETUP while streaming stops playback, ending in READY like a first SETUP. + this->stop_playing_(); + // The single video track always uses interleaved channels 0 (RTP) and 1 (RTCP, unused). + this->queue_response_(200, "OK", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n"); +} + +void RTSPSession::handle_play_() { + if (this->state_ == State::INIT) { + this->queue_response_(455, "Method Not Valid In This State"); + return; + } + this->state_ = State::PLAYING; + this->frame_.reset(); // a repeated PLAY may arrive mid-frame + this->queue_response_(200, "OK", "Range: npt=0.000-\r\n"); +} + +void RTSPSession::stop_playing_() { + this->state_ = State::READY; + this->frame_.reset(); +} + +void RTSPSession::on_new_frame(const std::shared_ptr &image, const JpegInfo &jpeg, uint32_t now) { + // Pending output means a frame is in flight or a stalled one is still being padded out. + if (this->state_ != State::PLAYING || this->frame_ != nullptr || this->iov_pos_ < this->iov_count_ || + this->filler_len_ > 0) + return; + this->frame_ = image; + this->jpeg_ = jpeg; + this->frame_sent_ = 0; + this->frame_progress_ms_ = now; + + uint32_t elapsed_ms = this->last_frame_ms_ == 0 ? 0 : now - this->last_frame_ms_; + this->rtp_timestamp_ += static_cast((static_cast(elapsed_ms) * RTP_CLOCK_RATE) / 1000); + this->last_frame_ms_ = now; +} + +bool RTSPSession::queue_next_packet_() { + if (this->frame_ == nullptr) + return false; + if (this->frame_sent_ >= this->jpeg_.scan_len) { + this->frame_.reset(); // its last packet is written + return false; + } + + const bool first_fragment = this->frame_sent_ == 0; + const size_t header_len = + INTERLEAVED_HEADER_LEN + RTP_HEADER_LEN + JPEG_HEADER_LEN + (first_fragment ? QUANT_HEADER_LEN : 0); + const size_t qtables_len = first_fragment ? 2 * QTABLE_LEN : 0; + const size_t payload_len = + std::min(this->jpeg_.scan_len - this->frame_sent_, MAX_PACKET_SIZE - header_len - qtables_len); + const bool last_fragment = this->frame_sent_ + payload_len >= this->jpeg_.scan_len; + const size_t rtp_len = header_len - INTERLEAVED_HEADER_LEN + qtables_len + payload_len; + + auto *buf = reinterpret_cast(this->send_buf_.data()); + size_t pos = 0; + buf[pos++] = '$'; + buf[pos++] = 0; // interleaved channel 0 + buf[pos++] = static_cast(rtp_len >> 8); + buf[pos++] = static_cast(rtp_len); + + buf[pos++] = 0x80; // V=2, P=0, X=0, CC=0 + buf[pos++] = static_cast((last_fragment ? 0x80 : 0x00) | RTP_PAYLOAD_TYPE_JPEG); + buf[pos++] = static_cast(this->sequence_number_ >> 8); + buf[pos++] = static_cast(this->sequence_number_); + this->sequence_number_++; + buf[pos++] = static_cast(this->rtp_timestamp_ >> 24); + buf[pos++] = static_cast(this->rtp_timestamp_ >> 16); + buf[pos++] = static_cast(this->rtp_timestamp_ >> 8); + buf[pos++] = static_cast(this->rtp_timestamp_); + buf[pos++] = static_cast(this->ssrc_ >> 24); + buf[pos++] = static_cast(this->ssrc_ >> 16); + buf[pos++] = static_cast(this->ssrc_ >> 8); + buf[pos++] = static_cast(this->ssrc_); + + // RFC 2435 main JPEG header + buf[pos++] = 0; // type-specific + buf[pos++] = static_cast(this->frame_sent_ >> 16); + buf[pos++] = static_cast(this->frame_sent_ >> 8); + buf[pos++] = static_cast(this->frame_sent_); + buf[pos++] = this->jpeg_.type; + buf[pos++] = 255; // Q >= 128: the quantization tables follow in the first packet + buf[pos++] = static_cast(this->jpeg_.width / 8); + buf[pos++] = static_cast(this->jpeg_.height / 8); + + uint8_t count = 0; + if (first_fragment) { + buf[pos++] = 0; // MBZ + buf[pos++] = 0; // 8-bit precision + buf[pos++] = static_cast(qtables_len >> 8); + buf[pos++] = static_cast(qtables_len); + this->iov_[0] = {buf, header_len}; + this->iov_[1] = {const_cast(this->jpeg_.qtable0), QTABLE_LEN}; + this->iov_[2] = {const_cast(this->jpeg_.qtable1), QTABLE_LEN}; + count = 3; + } else { + this->iov_[0] = {buf, header_len}; + count = 1; + } + this->iov_[count++] = {const_cast(this->jpeg_.scan_data + this->frame_sent_), payload_len}; + this->iov_pos_ = 0; + this->iov_count_ = count; + this->sending_packet_ = true; + this->frame_sent_ += payload_len; + return true; +} + +void RTSPSession::drop_frame_() { + this->frame_.reset(); + if (!this->sending_packet_ || this->iov_pos_ >= this->iov_count_) + return; + // Even an unwritten packet is kept, so output stays pending and on_new_frame() takes nothing until the + // socket drains. Entries after the header point into the frame. + for (uint8_t i = std::max(this->iov_pos_, 1); i < this->iov_count_; i++) + this->filler_len_ += this->iov_[i].iov_len; + this->iov_count_ = std::max(this->iov_pos_, 1); + this->sending_packet_ = false; +} + +bool RTSPSession::flush_(uint32_t now) { + if (this->should_close_) + return false; + while (true) { + if (this->iov_pos_ >= this->iov_count_) { + if (this->filler_len_ == 0) + return true; + this->iov_pos_ = 0; + this->iov_count_ = 0; + while (this->filler_len_ > 0 && this->iov_count_ < this->iov_.size()) { + const size_t len = std::min(this->filler_len_, sizeof(ZERO_FILL)); + this->iov_[this->iov_count_++] = {const_cast(ZERO_FILL), len}; + this->filler_len_ -= len; + } + } + ssize_t written = this->socket_->writev(&this->iov_[this->iov_pos_], this->iov_count_ - this->iov_pos_); + if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return false; // retry next loop + if (written <= 0) { + if (written < 0) { + ESP_LOGW(TAG, "Write failed: errno %d", errno); + } + this->should_close_ = true; + return false; + } + this->last_activity_ms_ = now; + this->frame_progress_ms_ = now; + // Step over what was written; a partial write leaves the rest of the current entry pending. + auto remaining = static_cast(written); + while (remaining > 0) { + struct iovec &entry = this->iov_[this->iov_pos_]; + if (remaining < entry.iov_len) { + entry.iov_base = static_cast(entry.iov_base) + remaining; + entry.iov_len -= remaining; + break; + } + remaining -= entry.iov_len; + this->iov_pos_++; + } + } +} + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/components/rtsp_server/rtsp_session.h b/esphome/components/rtsp_server/rtsp_session.h new file mode 100644 index 00000000000..b34b7faecba --- /dev/null +++ b/esphome/components/rtsp_server/rtsp_session.h @@ -0,0 +1,119 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_CAMERA + +#include +#include + +#include "esphome/components/camera/camera.h" +#include "esphome/components/socket/socket.h" +#include "esphome/core/string_ref.h" + +namespace esphome::rtsp_server { + +static constexpr size_t INTERLEAVED_HEADER_LEN = 4; +static constexpr size_t RTP_HEADER_LEN = 12; +static constexpr size_t JPEG_HEADER_LEN = 8; +static constexpr size_t QUANT_HEADER_LEN = 4; +static constexpr size_t QTABLE_LEN = 64; // one 8-bit precision quantization table +static constexpr size_t RTP_JPEG_PAYLOAD_SIZE = 1024; +static constexpr size_t MAX_PACKET_SIZE = INTERLEAVED_HEADER_LEN + RTP_HEADER_LEN + JPEG_HEADER_LEN + QUANT_HEADER_LEN + + 2 * QTABLE_LEN + RTP_JPEG_PAYLOAD_SIZE; +// RFC 2435 stores width and height in units of 8 pixels in one byte each. +static constexpr uint16_t RTP_JPEG_MAX_DIMENSION = 255 * 8; +// A frame whose packets make no write progress for this long is dropped, so a stalled client cannot hold +// the camera image (esp32_camera captures nothing while any copy is held). +static constexpr uint32_t FRAME_STALL_MS = 500; + +/// The fields of a JPEG frame that an RFC 2435 RTP/JPEG payload needs. Pointers refer into the frame. +struct JpegInfo { + const uint8_t *qtable0{nullptr}; + const uint8_t *qtable1{nullptr}; // same as qtable0 when the frame has one table + const uint8_t *scan_data{nullptr}; + size_t scan_len{0}; // entropy-coded data, without the trailing EOI marker + uint16_t width{0}; + uint16_t height{0}; + uint8_t type{0}; // RFC 2435 type: 0 = 4:2:2 subsampling, 1 = 4:2:0 +}; + +bool parse_jpeg_info(const uint8_t *data, size_t len, JpegInfo &out); + +/** A single RTSP client: one TCP connection carrying both the RTSP control channel and, once playing, + * RTP/JPEG media interleaved on the same socket (RFC 2326 §10.12). No UDP transport is supported. + * The object is a reusable slot: open() starts a connection, and it closes itself when the connection ends. + */ +class RTSPSession { + public: + bool is_active() const { return this->socket_ != nullptr; } + bool is_playing() const { return this->state_ == State::PLAYING; } + + void open(std::unique_ptr socket, uint32_t now); + void loop(uint32_t now, uint16_t port); + /// Ignored unless playing with no output pending; packets of the frame being sent point into that frame. + void on_new_frame(const std::shared_ptr &image, const JpegInfo &jpeg, uint32_t now); + + protected: + enum class State : uint8_t { INIT, READY, PLAYING }; + + void service_(uint32_t now, uint16_t port); + void close_(); + void read_requests_(uint32_t now, uint16_t port); + /// Consumes buffered input up to and including the next request; true once a request was handled. + bool process_input_(uint16_t port); + void consume_input_(size_t len); + void handle_request_(StringRef request, uint16_t port); + void handle_setup_(StringRef transport_header); + void handle_play_(); + /// Queues "RTSP/1.0 ", CSeq, Session once set up, the extra headers and the blank line. + void queue_response_(uint16_t code, const char *reason, const char *extra_headers_fmt = nullptr, ...) + __attribute__((format(printf, 4, 5))); + void stop_playing_(); + bool queue_next_packet_(); + /// Releases the frame; the frame-backed rest of the current packet is sent as zeros to keep the framing. + void drop_frame_(); + /// Writes what is queued; true once all of it is written, false while backlogged or after a failure. + bool flush_(uint32_t now); + + std::unique_ptr socket_; + // Held until its last packet is written; esp32_camera captures nothing new while any copy is held. + std::shared_ptr frame_; + JpegInfo jpeg_; + size_t frame_sent_{0}; + + uint32_t cseq_{0}; + uint32_t rtp_timestamp_{0}; + uint32_t ssrc_{0}; + uint32_t last_activity_ms_{0}; + uint32_t last_frame_ms_{0}; + uint32_t frame_progress_ms_{0}; + // Bytes of an interleaved packet (RTCP) or request body from the client still to discard. + uint32_t skip_len_{0}; + uint16_t sequence_number_{0}; + uint16_t request_len_{0}; + // Zeros still owed for a packet whose frame was dropped part way through. + uint16_t filler_len_{0}; + + // Pending output: a response or one RTP packet. Packets point at the frame's tables and scan data. + std::array iov_{}; + uint8_t iov_pos_{0}; + uint8_t iov_count_{0}; + + State state_{State::INIT}; + bool should_close_{false}; + bool close_after_send_{false}; + bool sending_packet_{false}; // iov_ holds an RTP packet rather than a response + bool read_pending_{false}; // reading stopped before EAGAIN; read again without waiting for ready() + char session_id_[9]{}; + + // Requests and interleaved packets from the client. A request is NUL terminated in place before it is + // handled, for StringRef::find(), which uses strstr. + std::array request_buf_{}; + // Response text, or the header of the RTP packet being sent. + std::array send_buf_{}; +}; + +} // namespace esphome::rtsp_server + +#endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8d17dc3f514..dbd6c8d8538 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -268,6 +268,7 @@ #define USE_SAFE_MODE_BOOT_IS_GOOD_ON_SHUTDOWN #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 +#define MAX_RTSP_CONNECTIONS 2 #define USE_SCRIPT_ON_STOP #define USE_SELECT #define USE_SELEC_METER_EM4M diff --git a/tests/components/rtsp_server/__init__.py b/tests/components/rtsp_server/__init__.py new file mode 100644 index 00000000000..25a08c171d9 --- /dev/null +++ b/tests/components/rtsp_server/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # No host camera platform emits USE_CAMERA, and the real to_code needs a configured server. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_CAMERA") + cg.add_define("MAX_RTSP_CONNECTIONS", 1) + cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + + manifest.to_code = to_code_testing diff --git a/tests/components/rtsp_server/common.yaml b/tests/components/rtsp_server/common.yaml new file mode 100644 index 00000000000..6f1e0dd078a --- /dev/null +++ b/tests/components/rtsp_server/common.yaml @@ -0,0 +1,10 @@ +rtsp_server: + port: 554 + max_connections: 2 + allowed_ips: + - 192.168.1.0/24 + - 10.0.0.5 + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/rtsp_server/test.esp32-idf.yaml b/tests/components/rtsp_server/test.esp32-idf.yaml new file mode 100644 index 00000000000..d186203c80d --- /dev/null +++ b/tests/components/rtsp_server/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml + rtsp_server: !include common.yaml diff --git a/tests/components/rtsp_server/test_rtsp_session_host.cpp b/tests/components/rtsp_server/test_rtsp_session_host.cpp new file mode 100644 index 00000000000..9d9154f587e --- /dev/null +++ b/tests/components/rtsp_server/test_rtsp_session_host.cpp @@ -0,0 +1,367 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/rtsp_server/rtsp_session.h" + +#if defined(USE_HOST) && defined(USE_CAMERA) + +namespace esphome::rtsp_server::testing { + +static constexpr uint16_t PORT = 554; +static constexpr size_t REQUEST_BUF_SIZE = 512; + +class StubImage : public camera::CameraImage { + public: + explicit StubImage(size_t len) : data_(len, 0x55) {} + uint8_t *get_data_buffer() override { return this->data_.data(); } + size_t get_data_length() override { return this->data_.size(); } + bool was_requested_by(camera::CameraRequester requester) const override { return true; } + + protected: + std::vector data_; +}; + +class RTSPSessionUnderTest : public RTSPSession { + public: + bool has_frame() const { return this->frame_ != nullptr; } +}; + +class RTSPSessionTest : public ::testing::Test { + protected: + void SetUp() override { + signal(SIGPIPE, SIG_IGN); + int fds[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + this->peer_fd_ = fds[1]; + this->session_.open(std::make_unique(fds[0]), this->now_); + } + void TearDown() override { ::close(this->peer_fd_); } + + // Reopens the session on a loopback TCP connection, which (unlike AF_UNIX) accepts partial writes. + void reopen_over_tcp() { + int listener = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(listener, 0); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(addr); + ASSERT_EQ(::bind(listener, reinterpret_cast(&addr), sizeof(addr)), 0); + ASSERT_EQ(::listen(listener, 1), 0); + ASSERT_EQ(::getsockname(listener, reinterpret_cast(&addr), &len), 0); + int client = ::socket(AF_INET, SOCK_STREAM, 0); + int size = 8192; + ::setsockopt(client, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size)); + ASSERT_EQ(::connect(client, reinterpret_cast(&addr), sizeof(addr)), 0); + int server = ::accept(listener, nullptr, nullptr); + ::close(listener); + ASSERT_GE(server, 0); + ::setsockopt(server, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)); +#ifdef SO_SNDLOWAT + int lowat = 1; // macOS otherwise refuses writes smaller than its 2 KiB low-water mark + ::setsockopt(server, SOL_SOCKET, SO_SNDLOWAT, &lowat, sizeof(lowat)); +#endif + ::close(this->peer_fd_); + this->peer_fd_ = client; + this->session_.open(std::make_unique(server), this->now_); + } + + void send(const std::string &data) { + ASSERT_EQ(::write(this->peer_fd_, data.data(), data.size()), static_cast(data.size())); + } + void loops(int count) { + for (int i = 0; i < count; i++) { + usleep(1000); // let loopback TCP deliver + this->session_.loop(this->now_, PORT); + } + } + // Everything the session has written so far. + std::string receive() { + usleep(1000); + std::string out; + char buf[4096]; + while (true) { + ssize_t n = ::recv(this->peer_fd_, buf, sizeof(buf), MSG_DONTWAIT); + if (n <= 0) + break; + out.append(buf, n); + } + return out; + } + static std::string request(const char *method, int cseq, const std::string &extra = "") { + char line[48]; + snprintf(line, sizeof(line), "%s rtsp://host/ RTSP/1.0\r\nCSeq: %d\r\n", method, cseq); + return line + extra + "\r\n"; + } + static size_t count(const std::string &haystack, const std::string &needle) { + size_t n = 0; + for (size_t pos = haystack.find(needle); pos != std::string::npos; pos = haystack.find(needle, pos + 1)) + n++; + return n; + } + void start_playing() { + this->send(request("SETUP", 1, "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n")); + this->loops(2); + this->send(request("PLAY", 2)); + this->loops(2); + std::string out = this->receive(); + ASSERT_NE(out.find("CSeq: 2\r\n"), std::string::npos); + ASSERT_TRUE(this->session_.is_playing()); + } + + RTSPSessionUnderTest session_; + int peer_fd_{-1}; + uint32_t now_{1000}; +}; + +TEST_F(RTSPSessionTest, InterleavedRtcpBeforeRequestIsSkipped) { + std::string rtcp("$\x01\x00\x08", 4); + rtcp.append("\x81\xc9\x00\x01\x00\x00\x00\x00", 8); // contains NUL bytes + this->send(rtcp + request("GET_PARAMETER", 7)); + this->loops(1); + std::string out = this->receive(); + EXPECT_EQ(out.rfind("RTSP/1.0 200 OK\r\nCSeq: 7\r\n", 0), 0u) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, InterleavedPacketSplitAcrossReads) { + this->send(std::string("$\x01\x01", 3)); + this->loops(1); + std::string rest("\x00", 1); + rest.append(256, '\0'); + this->send(rest + request("GET_PARAMETER", 8)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 8\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, MalformedRequestLineEchoesCSeq) { + this->send("OPTIONS\r\nCSeq: 9\r\n\r\n"); + this->loops(1); + std::string out = this->receive(); + EXPECT_EQ(out.rfind("RTSP/1.0 400 Bad Request\r\nCSeq: 9\r\n", 0), 0u) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, PipelinedRequestsBothAnswered) { + this->send(request("OPTIONS", 1) + request("GET_PARAMETER", 2)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + size_t first = out.find("CSeq: 1\r\n"); + size_t second = out.find("CSeq: 2\r\n"); + ASSERT_NE(first, std::string::npos); + ASSERT_NE(second, std::string::npos); + EXPECT_LT(first, second); +} + +TEST_F(RTSPSessionTest, RequestBodyIsConsumed) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 6\r\n") + "ping\r\n" + request("OPTIONS", 4)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + EXPECT_EQ(out.find("400"), std::string::npos) << out; + size_t first = out.find("CSeq: 3\r\n"); + size_t second = out.find("CSeq: 4\r\n"); + ASSERT_NE(first, std::string::npos); + ASSERT_NE(second, std::string::npos); + EXPECT_LT(first, second); +} + +TEST_F(RTSPSessionTest, RequestBodySplitAcrossReads) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 6\r\n") + "pi"); + this->loops(2); + this->send("ng\r\n" + request("OPTIONS", 4)); + this->loops(3); + std::string out = this->receive(); + EXPECT_EQ(count(out, "RTSP/1.0 200 OK\r\n"), 2u) << out; + EXPECT_EQ(out.find("400"), std::string::npos) << out; + EXPECT_NE(out.find("CSeq: 4\r\n"), std::string::npos) << out; + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, OversizedContentLengthClosesSession) { + this->send(request("GET_PARAMETER", 3, "Content-Length: 100000\r\n")); + this->loops(2); + EXPECT_EQ(this->receive().rfind("RTSP/1.0 400 Bad Request\r\nCSeq: 3\r\n", 0), 0u); + EXPECT_FALSE(this->session_.is_active()); +} + +static std::string padded_request(size_t total_len) { + std::string head = "GET_PARAMETER rtsp://host/ RTSP/1.0\r\nCSeq: 9\r\nX-Pad: "; + std::string tail = "\r\n\r\n"; + return head + std::string(total_len - head.size() - tail.size(), 'a') + tail; +} + +TEST_F(RTSPSessionTest, RequestOfBufferSizeMinusOneIsHandled) { + this->send(padded_request(REQUEST_BUF_SIZE - 1)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 9\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, RequestFillingTheBufferIsHandled) { + this->send(padded_request(REQUEST_BUF_SIZE)); + this->loops(1); + EXPECT_NE(this->receive().find("CSeq: 9\r\n"), std::string::npos); + EXPECT_TRUE(this->session_.is_active()); +} + +TEST_F(RTSPSessionTest, OversizedRequestClosesSession) { + this->send(std::string(REQUEST_BUF_SIZE + 1, 'a')); + this->loops(1); + EXPECT_FALSE(this->session_.is_active()); +} + +// Parses '$' channel length packets after the RTSP responses; false if the stream is not well framed. +static bool parse_interleaved(const std::string &stream, size_t start, std::vector &sequences) { + size_t pos = start; + while (pos < stream.size()) { + if (pos + 4 > stream.size() || stream[pos] != '$') + return false; + size_t len = (static_cast(stream[pos + 2]) << 8) | static_cast(stream[pos + 3]); + if (pos + 4 + len > stream.size() || len < 12) + return false; + sequences.push_back((static_cast(stream[pos + 6]) << 8) | static_cast(stream[pos + 7])); + pos += 4 + len; + } + return true; +} + +static JpegInfo stub_jpeg(StubImage &image) { + JpegInfo jpeg; + jpeg.qtable0 = image.get_data_buffer(); + jpeg.qtable1 = image.get_data_buffer() + QTABLE_LEN; + jpeg.scan_data = image.get_data_buffer() + 2 * QTABLE_LEN; + jpeg.scan_len = image.get_data_length() - 2 * QTABLE_LEN; + jpeg.width = 640; + jpeg.height = 480; + return jpeg; +} + +TEST_F(RTSPSessionTest, StalledWriterDropsFrame) { + this->reopen_over_tcp(); + this->start_playing(); + auto image = std::make_shared(8 * 1024 * 1024); + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(100); // the peer does not read, so the socket buffers fill up + ASSERT_TRUE(this->session_.has_frame()); + EXPECT_GT(image.use_count(), 1); + + this->now_ += FRAME_STALL_MS; + this->loops(1); + EXPECT_TRUE(this->session_.has_frame()) << "dropped before the stall limit"; + + this->now_ += 1; + this->loops(1); + EXPECT_FALSE(this->session_.has_frame()); + EXPECT_EQ(image.use_count(), 1); + EXPECT_TRUE(this->session_.is_active()); + + // Until the socket drains, new frames are refused so the camera can keep capturing for other consumers. + for (int i = 0; i < 5; i++) { + this->now_ += 100; + auto next = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(next, stub_jpeg(*next), this->now_); + this->loops(1); + EXPECT_FALSE(this->session_.has_frame()); + EXPECT_EQ(next.use_count(), 1); + } + + // The packet in flight is completed with zeros, so the interleaved stream stays well framed. + std::string stream; + for (int i = 0; i < 100; i++) { + stream += this->receive(); + this->loops(1); + } + stream += this->receive(); + std::vector sequences; + ASSERT_TRUE(parse_interleaved(stream, 0, sequences)); + ASSERT_FALSE(sequences.empty()); + + // Once drained, the next frame is taken and follows on in sequence. + auto next = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(next, stub_jpeg(*next), this->now_); + EXPECT_TRUE(this->session_.has_frame()); + this->loops(2); + std::vector after; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, after)); + ASSERT_EQ(after.size(), 1u); + EXPECT_EQ(after[0], static_cast(sequences.back() + 1)); + EXPECT_EQ(next.use_count(), 1); +} + +TEST_F(RTSPSessionTest, RepeatPlayContinuesSequence) { + this->start_playing(); + auto image = std::make_shared(2 * QTABLE_LEN + 100); + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(1); + std::vector sequences; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, sequences)); + ASSERT_EQ(sequences.size(), 1u); + + this->send(request("PLAY", 3)); + this->loops(1); + std::string reply = this->receive(); + ASSERT_NE(reply.find("CSeq: 3\r\n"), std::string::npos); + this->now_ += 100; + this->session_.on_new_frame(image, stub_jpeg(*image), this->now_); + this->loops(1); + std::vector after; + ASSERT_TRUE(parse_interleaved(this->receive(), 0, after)); + ASSERT_EQ(after.size(), 1u); + EXPECT_EQ(after[0], static_cast(sequences[0] + 1)); +} + +// Minimal JPEG: SOI, DQT (two tables), optional DRI, SOF0, SOS, scan byte, EOI. +static std::vector make_jpeg(uint8_t components, uint8_t y_sampling, bool dri) { + std::vector j = {0xFF, 0xD8, 0xFF, 0xDB, 0x00, 2 + 2 * 65}; + for (uint8_t id = 0; id < 2; id++) { + j.push_back(id); + j.insert(j.end(), 64, 1); + } + if (dri) + j.insert(j.end(), {0xFF, 0xDD, 0x00, 0x04, 0x00, 0x10}); + j.insert(j.end(), + {0xFF, 0xC0, 0x00, static_cast(8 + 3 * components), 8, 0x01, 0xE0, 0x02, 0x80, components}); + for (uint8_t c = 0; c < components; c++) + j.insert(j.end(), {static_cast(c + 1), c == 0 ? y_sampling : static_cast(0x11), + static_cast(c == 0 ? 0 : 1)}); + j.insert(j.end(), {0xFF, 0xDA, 0x00, 0x02, 0x12, 0xFF, 0xD9}); + return j; +} + +TEST(RTSPParseJpeg, AcceptsBaselineColor) { + JpegInfo info; + auto j420 = make_jpeg(3, 0x22, false); + ASSERT_TRUE(parse_jpeg_info(j420.data(), j420.size(), info)); + EXPECT_EQ(info.type, 1); + EXPECT_EQ(info.width, 640); + EXPECT_EQ(info.height, 480); + EXPECT_EQ(info.scan_len, 1u); + JpegInfo info422; + auto j422 = make_jpeg(3, 0x21, false); + ASSERT_TRUE(parse_jpeg_info(j422.data(), j422.size(), info422)); + EXPECT_EQ(info422.type, 0); +} + +TEST(RTSPParseJpeg, RejectsUnsupportedFrames) { + JpegInfo info; + auto gray = make_jpeg(1, 0x11, false); + EXPECT_FALSE(parse_jpeg_info(gray.data(), gray.size(), info)); + auto j444 = make_jpeg(3, 0x11, false); + EXPECT_FALSE(parse_jpeg_info(j444.data(), j444.size(), info)); + auto restart = make_jpeg(3, 0x22, true); + EXPECT_FALSE(parse_jpeg_info(restart.data(), restart.size(), info)); +} + +} // namespace esphome::rtsp_server::testing + +#endif From 4ef17cebdb8c024ee27f73c4ac7f5c2b380675cd Mon Sep 17 00:00:00 2001 From: Hasan Khalil Date: Sat, 17 Oct 2026 14:01:01 -0400 Subject: [PATCH 044/179] [api] Pre-reserve Home Assistant state subscriptions vector (#20986) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/api/api_server.cpp | 3 + esphome/components/homeassistant/__init__.py | 31 ++++++++- .../homeassistant/button/__init__.py | 3 +- .../homeassistant/number/__init__.py | 3 +- .../homeassistant/select/__init__.py | 3 +- .../components/homeassistant/text/__init__.py | 3 +- esphome/core/defines.h | 1 + .../homeassistant/mixed_entities.yaml | 65 +++++++++++++++++++ .../test_state_subscription_count.py | 51 +++++++++++++++ 9 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/homeassistant/mixed_entities.yaml create mode 100644 tests/component_tests/homeassistant/test_state_subscription_count.py diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index a8d14256dcb..99ce39670f2 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -65,6 +65,9 @@ void APIServer::socket_failed_(const LogString *msg) { } void APIServer::setup() { +#if defined(USE_API_HOMEASSISTANT_STATES) && defined(HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT) + this->state_subs_.reserve(HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT); +#endif #ifdef USE_API_NOISE // Always reserve the slot: flash preferences are positional on esp8266, so // a yaml key build must keep the layout of a runtime key build diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 3851bc3c0bc..6f5360bfed1 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -1,8 +1,11 @@ from collections.abc import Callable, Iterable +from dataclasses import dataclass import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -59,7 +62,28 @@ def final_validate_entity_id(config: ConfigType) -> ConfigType: return config -def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: +@dataclass +class HomeAssistantData: + state_subscription_count: int = 0 + + +def _get_data() -> HomeAssistantData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = HomeAssistantData() + return CORE.data[DOMAIN] + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _emit_state_subscription_count_define() -> None: + """Emit the HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT define with the total count.""" + count = _get_data().state_subscription_count + if count > 0: + cg.add_define("HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT", count) + + +def setup_home_assistant_entity( + var: MockObj, config: ConfigType, state_subscription_count: int = 1 +) -> None: from esphome.components.api import wizard if (buffer := wizard.wizard_input_buffer(config[CONF_ID])) is not None: @@ -70,3 +94,8 @@ def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) cg.add_define("USE_API_HOMEASSISTANT_STATES") + if state_subscription_count > 0: + data = _get_data() + if data.state_subscription_count == 0: + CORE.add_job(_emit_state_subscription_count_define) + data.state_subscription_count += state_subscription_count diff --git a/esphome/components/homeassistant/button/__init__.py b/esphome/components/homeassistant/button/__init__.py index f486a841263..7b4ad004e18 100644 --- a/esphome/components/homeassistant/button/__init__.py +++ b/esphome/components/homeassistant/button/__init__.py @@ -37,4 +37,5 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await button.new_button(config) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # Button only calls services, does not subscribe to state + setup_home_assistant_entity(var, config, state_subscription_count=0) diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index 6288db68805..658cb8762c9 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -39,4 +39,5 @@ async def to_code(config: ConfigType) -> None: step=0, ) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + min/max/step fetches in HomeassistantNumber::setup() + setup_home_assistant_entity(var, config, state_subscription_count=4) diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py index 1bdf0f2cade..e6a129cda0d 100644 --- a/esphome/components/homeassistant/select/__init__.py +++ b/esphome/components/homeassistant/select/__init__.py @@ -55,4 +55,5 @@ async def to_code(config: ConfigType) -> None: options=[], ) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + options fetch in HomeassistantSelect::setup() + setup_home_assistant_entity(var, config, state_subscription_count=2) diff --git a/esphome/components/homeassistant/text/__init__.py b/esphome/components/homeassistant/text/__init__.py index 67377e02d05..eb8613f76d5 100644 --- a/esphome/components/homeassistant/text/__init__.py +++ b/esphome/components/homeassistant/text/__init__.py @@ -37,4 +37,5 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await text.new_text(config) await cg.register_component(var, config) - setup_home_assistant_entity(var, config) + # 1 state + min/max/mode fetches in HomeassistantText::setup() + setup_home_assistant_entity(var, config, state_subscription_count=4) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index dbd6c8d8538..de4dfd2607b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -319,6 +319,7 @@ #define USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON #define USE_API_HOMEASSISTANT_SERVICES #define USE_API_HOMEASSISTANT_STATES +#define HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT 1 #define USE_API_NOISE #define USE_API_OUTGOING_CONNECTION #define API_OUTGOING_CONNECTION_PORT 6054 diff --git a/tests/component_tests/homeassistant/mixed_entities.yaml b/tests/component_tests/homeassistant/mixed_entities.yaml new file mode 100644 index 00000000000..8c96e1ad521 --- /dev/null +++ b/tests/component_tests/homeassistant/mixed_entities.yaml @@ -0,0 +1,65 @@ +esphome: + name: test-mixed-ha + +esp32: + variant: esp32 + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + +sensor: + - platform: homeassistant + id: ha_sensor_1 + name: HA Sensor 1 + entity_id: sensor.temp_1 + - platform: homeassistant + id: ha_sensor_2 + name: HA Sensor 2 + entity_id: sensor.temp_2 + +binary_sensor: + - platform: homeassistant + id: ha_binary_sensor + name: HA Binary Sensor + entity_id: binary_sensor.motion + +text_sensor: + - platform: homeassistant + id: ha_text_sensor + name: HA Text Sensor + entity_id: sensor.status_text + +switch: + - platform: homeassistant + id: ha_switch + name: HA Switch + entity_id: switch.relay + +select: + - platform: homeassistant + id: ha_select + name: HA Select + entity_id: select.mode + +number: + - platform: homeassistant + id: ha_number + name: HA Number + entity_id: number.target_temp + +text: + - platform: homeassistant + id: ha_text + name: HA Text + entity_id: text.custom_note + +button: + - platform: homeassistant + id: ha_button + name: HA Button + entity_id: button.restart diff --git a/tests/component_tests/homeassistant/test_state_subscription_count.py b/tests/component_tests/homeassistant/test_state_subscription_count.py new file mode 100644 index 00000000000..c3832369117 --- /dev/null +++ b/tests/component_tests/homeassistant/test_state_subscription_count.py @@ -0,0 +1,51 @@ +"""Tests for Home Assistant state subscription count calculation and pre-reservation.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +DIR = Path(__file__).parent + + +def test_mixed_entities_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with mixed entities computes the exact weighted subscription count.""" + generate_main(DIR / "mixed_entities.yaml") + + # sensor (2*1) + binary_sensor (1) + text_sensor (1) + switch (1) + # + select (2) + number (4) + text (4) + button (0) = 15 + define_match = [ + d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" + ] + assert len(define_match) == 1 + assert str(define_match[0].value) == "15" + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) + + +def test_button_only_no_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with only button entities has no state subscriptions.""" + generate_main(DIR / "button_valid.yaml") + + assert not any( + d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" for d in CORE.defines + ) + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) + assert any(d.name == "USE_API_HOMEASSISTANT_SERVICES" for d in CORE.defines) + + +def test_select_only_state_subscription_count( + generate_main: Callable[[str | Path], str], +) -> None: + """A configuration with 2 select entities calculates 2 * 2 = 4 subscriptions.""" + generate_main(DIR / "select_valid.yaml") + + define_match = [ + d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" + ] + assert len(define_match) == 1 + assert str(define_match[0].value) == "4" + assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) From 4f192130f80ccb70813891d99d0a771c5f063fa0 Mon Sep 17 00:00:00 2001 From: Douwe <61123717+dhoeben@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:51:06 +0200 Subject: [PATCH 045/179] [water_heater] Hand the component to the core team (#20999) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- esphome/components/water_heater/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f23f3613032..65ed44ee30b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -688,7 +688,7 @@ esphome/components/version/* @esphome/core esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher -esphome/components/water_heater/* @dhoeben +esphome/components/water_heater/* @esphome/core esphome/components/waveshare_epaper/* @clydebarrow esphome/components/waveshare_io_ch32v003/* @latonita esphome/components/web_server/ota/* @esphome/core diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index a3a7f047069..9277f0ecbf6 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -17,7 +17,7 @@ from esphome.core.entity_helpers import ( from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType -CODEOWNERS = ["@dhoeben"] +CODEOWNERS = ["@esphome/core"] DOMAIN = "water_heater" IS_PLATFORM_COMPONENT = True From 37d6140193c7b215852834b54d05a50f0fe60d8f Mon Sep 17 00:00:00 2001 From: Dane Powell Date: Sat, 17 Oct 2026 11:55:37 -0700 Subject: [PATCH 046/179] [epaper_spi] Fix partial refresh and port waveshare 2.90inv2 (#18959) Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: luar123 <49960470+luar123@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/epaper_spi/epaper_spi.cpp | 4 +- esphome/components/epaper_spi/epaper_spi.h | 2 + .../components/epaper_spi/epaper_spi_mono.cpp | 2 +- .../components/epaper_spi/epaper_spi_mono.h | 2 + .../epaper_spi/epaper_waveshare.cpp | 15 ++- .../components/epaper_spi/epaper_waveshare.h | 26 +++- .../components/epaper_spi/models/waveshare.py | 125 +++++++++++++++--- .../epaper_spi/test.esp32-s3-idf.yaml | 17 +++ 8 files changed, 167 insertions(+), 26 deletions(-) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index a0c90a0b686..e95d2fd2869 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -90,7 +90,9 @@ bool EPaperBase::is_idle_() const { return !this->busy_pin_->digital_read(); } -bool EPaperBase::reset() { +bool EPaperBase::reset() { return this->hardware_reset_(); } + +bool EPaperBase::hardware_reset_() { if (this->reset_pin_ != nullptr) { if (this->state_ == EPaperState::RESET) { this->reset_pin_->digital_write(false); diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index dccff3cbef1..046df97cccd 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -129,6 +129,8 @@ class EPaperBase : public Display, bool is_idle_() const; void setup_pins_() const; virtual bool reset(); + /// The reset pin part of reset(), for a driver that must skip what its parent adds to it + bool hardware_reset_(); virtual bool initialise(bool partial); void send_init_sequence_(const uint8_t *sequence, size_t length); void wait_for_idle_(bool should_wait); diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index b6653038c33..a00104edeae 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -68,7 +68,7 @@ bool HOT EPaperMono::transfer_data() { while (this->current_data_index_ != this->y_high_) { size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_ / 8; for (size_t i = 0; i != row_length; i++) { - bytes_to_send[i] = this->send_red_ ? 0 : this->buffer_[data_idx++]; + bytes_to_send[i] = this->send_red_ && !this->send_red_as_image_ ? 0 : this->buffer_[data_idx++]; } ++this->current_data_index_; this->write_array(&bytes_to_send.front(), row_length); // NOLINT diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index 4dc54ae60ba..c7de7a309af 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -21,6 +21,8 @@ class EPaperMono : public EPaperBase { virtual void set_window(); bool transfer_data() override; bool send_red_{true}; + // When set, the red RAM pass writes the image rather than blank data. + bool send_red_as_image_{false}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare.cpp b/esphome/components/epaper_spi/epaper_waveshare.cpp index d4c7ba5fdb5..17808001aa6 100644 --- a/esphome/components/epaper_spi/epaper_waveshare.cpp +++ b/esphome/components/epaper_spi/epaper_waveshare.cpp @@ -8,15 +8,19 @@ bool EpaperWaveshare::initialise(bool partial) { EPaperBase::initialise(partial); if (partial) { this->cmd_data(0x32, this->partial_lut_, this->partial_lut_length_); + this->send_init_sequence_(this->partial_sequence_, this->partial_sequence_length_); this->cmd_data(0x3C, {0x80}); this->cmd_data(0x22, {0xC0}); this->command(0x20); this->next_delay_ = 100; } else { this->cmd_data(0x32, this->lut_, this->lut_length_); + this->send_init_sequence_(this->full_sequence_, this->full_sequence_length_); this->cmd_data(0x3C, {0x05}); } - this->send_red_ = true; + // The previous image must stay in the base RAM for partial updates to compare against, + // so models needing it write the base RAM on full updates only. + this->send_red_ = !(this->base_image_required_ && partial); return true; } @@ -45,4 +49,13 @@ void EpaperWaveshare::refresh_screen(bool partial) { } void EpaperWaveshare::deep_sleep() { this->cmd_data(0x10, {0x01}); } + +bool EpaperWaveshare::reset() { + // Skip the software reset (0x12) EPaperMono adds: it restarts the alternation between the two RAM + // banks, so a partial update would compare against the wrong bank. The RST pulse only wakes the + // controller from sleep. + if (this->must_keep_base_image_()) + return this->hardware_reset_(); + return EPaperMono::reset(); +} } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare.h b/esphome/components/epaper_spi/epaper_waveshare.h index d3ad313d925..b12aeb93822 100644 --- a/esphome/components/epaper_spi/epaper_waveshare.h +++ b/esphome/components/epaper_spi/epaper_waveshare.h @@ -10,21 +10,39 @@ class EpaperWaveshare final : public EPaperMono { public: EpaperWaveshare(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length, const uint8_t *lut, size_t lut_length, const uint8_t *partial_lut, - uint16_t partial_lut_length) + uint16_t partial_lut_length, const uint8_t *full_sequence = nullptr, size_t full_sequence_length = 0, + const uint8_t *partial_sequence = nullptr, size_t partial_sequence_length = 0, + bool base_image_required = false) : EPaperMono(name, width, height, init_sequence, init_sequence_length), lut_(lut), - lut_length_(lut_length), partial_lut_(partial_lut), - partial_lut_length_(partial_lut_length) {} + full_sequence_(full_sequence), + partial_sequence_(partial_sequence), + lut_length_(lut_length), + full_sequence_length_(full_sequence_length), + partial_sequence_length_(partial_sequence_length), + partial_lut_length_(partial_lut_length), + base_image_required_(base_image_required) { + this->send_red_as_image_ = base_image_required; + } protected: bool initialise(bool partial) override; void set_window() override; void refresh_screen(bool partial) override; void deep_sleep() override; + bool reset() override; + // True while the base image in the controller RAM must be preserved for the next update. + bool must_keep_base_image_() const { return this->base_image_required_ && this->update_count_ != 0; } const uint8_t *lut_; - size_t lut_length_; const uint8_t *partial_lut_; + // Commands sent after the LUT, e.g. voltage settings that differ between full and partial updates. + const uint8_t *full_sequence_; + const uint8_t *partial_sequence_; + size_t lut_length_; + size_t full_sequence_length_; + size_t partial_sequence_length_; uint16_t partial_lut_length_; + bool base_image_required_; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/waveshare.py b/esphome/components/epaper_spi/models/waveshare.py index aecda723643..7594c9d0d0d 100644 --- a/esphome/components/epaper_spi/models/waveshare.py +++ b/esphome/components/epaper_spi/models/waveshare.py @@ -1,5 +1,10 @@ +from collections.abc import Sequence +from typing import Any + import esphome.codegen as cg +from esphome.components.mipi import flatten_sequence from esphome.core import ID +from esphome.types import ConfigType from ..display import CONF_INIT_SEQUENCE_ID from . import EpaperModel @@ -7,34 +12,49 @@ from . import EpaperModel class WaveshareModel(EpaperModel): def __init__( - self, name, lut, lut_partial=None, class_name="EpaperWaveshare", **defaults - ): + self, + name: str, + lut: tuple[int, ...], + lut_partial: tuple[int, ...] | None = None, + sequence_full: tuple[tuple[int, ...], ...] = (), + sequence_partial: tuple[tuple[int, ...], ...] = (), + base_image_required: bool = False, + class_name: str = "EpaperWaveshare", + **defaults: Any, + ) -> None: # A partial LUT is what lets EpaperWaveshare do partial refresh defaults.setdefault("partial_update", lut_partial is not None) super().__init__(name, class_name=class_name, **defaults) self.lut = lut self.lut_partial = lut_partial + self.sequence_full = sequence_full + self.sequence_partial = sequence_partial + self.base_image_required = base_image_required - def get_constructor_args(self, config) -> tuple: - lut = ( + def _array_arg( + self, config: ConfigType, suffix: str, data: Sequence[int] | None + ) -> tuple: + if not data: + return cg.nullptr, 0 + return ( cg.static_const_array( - ID(config[CONF_INIT_SEQUENCE_ID].id + "_lut", type=cg.uint8), self.lut + ID(config[CONF_INIT_SEQUENCE_ID].id + suffix, type=cg.uint8), data ), - len(self.lut), + len(data), + ) + + def get_constructor_args(self, config: ConfigType) -> tuple: + return ( + *self._array_arg(config, "_lut", self.lut), + *self._array_arg(config, "_lut_partial", self.lut_partial), + *self._array_arg( + config, "_sequence_full", flatten_sequence(self.sequence_full) + ), + *self._array_arg( + config, "_sequence_partial", flatten_sequence(self.sequence_partial) + ), + self.base_image_required, ) - if self.lut_partial is None: - lut_partial = cg.nullptr, 0 - else: - lut_partial = ( - cg.static_const_array( - ID( - config[CONF_INIT_SEQUENCE_ID].id + "_lut_partial", type=cg.uint8 - ), - self.lut_partial, - ), - len(self.lut_partial), - ) - return *lut, *lut_partial # fmt: off @@ -90,3 +110,70 @@ WaveshareModel( 0x0, 0x0, 0x0, ), ) + +WaveshareModel( + "waveshare-2.9in-v2", + width=128, + height=296, + initsequence=( + (0x01, 0x27, 0x01, 0x00), # driver output control + (0x0C, 0xD7, 0xD6, 0x9D), # booster soft start control + (0x3A, 0x1A), # dummy line period + (0x3B, 0x08), # gate line width + (0x11, 0x03), # data entry mode + (0x21, 0x00, 0x80), # display update control + (0x18, 0x80), # select internal temperature sensor + ), + # The last 6 bytes of the LUTs in the Waveshare sample code are voltage settings + # rather than waveform data, and are sent as separate commands below. + lut=( + 0x80, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, + 0x00, 0x00, 0x10, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x80, 0x66, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x10, 0x66, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x14, 0x08, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0A, 0x0A, 0x00, + 0x0A, 0x0A, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x08, 0x00, 0x01, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, + 0x00, 0x00, 0x00, + ), + lut_partial=( + 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x00, 0x00, 0x00, + ), + sequence_full=( + (0x3F, 0x22), # option for LUT end + (0x03, 0x17), # gate voltage + (0x04, 0x41, 0x00, 0x32), # source voltage: VSH1, VSH2, VSL + (0x2C, 0x36), # VCOM + ), + sequence_partial=( + (0x3F, 0x22), # option for LUT end + (0x03, 0x17), # gate voltage + (0x04, 0x41, 0xB0, 0x32), # source voltage: VSH1, VSH2, VSL + (0x2C, 0x36), # VCOM + (0x37, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00), + ), + base_image_required=True, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index f6091e5715c..6a487064cbc 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -70,6 +70,23 @@ display: allow_other_uses: true number: GPIO4 + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.9in-v2 + full_update_every: 10 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + - platform: epaper_spi spi_id: spi_bus model: waveshare-7.5in-H From 4324b7d084c69d3f6c4657ee3779217e8ee3ce67 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:58:49 +0200 Subject: [PATCH 047/179] [zigbee] bump esp-zigbee-sdk to 2.0.5 (#20998) --- esphome/components/zigbee/zigbee_esp32.cpp | 26 ----------------- esphome/components/zigbee/zigbee_esp32.h | 33 +--------------------- esphome/components/zigbee/zigbee_esp32.py | 12 ++------ esphome/idf_component.yml | 2 +- tests/component_tests/esp32/test_esp32.py | 16 ++++++----- 5 files changed, 14 insertions(+), 75 deletions(-) diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 29bbdc2b0b5..35f12b3a4e3 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -388,15 +388,6 @@ void ZigbeeComponent::setup() { }; ezb_af_set_node_power_desc(&desc); - // Finish zigbee data model - for (auto &attr_value : this->attr_values_) { - ezb_zcl_attr_desc_t attr_desc = attr_value.attr_desc; - void *value_p = &attr_value.value; - ezb_zcl_attr_desc_set_value(attr_desc, value_p); - } - // free memory - std::vector().swap(this->attr_values_); - // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network @@ -470,23 +461,6 @@ void ZigbeeComponent::dump_config() { esp_zigbee_lock_release(); } } - -bool ZigbeeComponent::string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id) { - ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); - if (ep_desc == NULL) { - return false; - } - ezb_zcl_cluster_desc_t cluster_desc = ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role); - if (cluster_desc == NULL) { - return false; - } - if (ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE) == NULL) { - return false; - } - ESP_LOGW(TAG, "Attribute 0x%04X already exists in endpoint %u cluster 0x%04X. Can't add new value", attr_id, - endpoint_id, cluster_id); - return true; -} } // namespace esphome::zigbee #endif diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index d5ed7439bd2..93a4f1ba780 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -29,11 +29,6 @@ static constexpr uint8_t MAX_ZB_QUEUE_SIZE = 32; uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); -struct AttrValue { - ezb_zcl_attr_desc_t attr_desc; - uint8_t value[4]; -}; - class ZigbeeAttribute; class ZigbeeComponent final : public Component { @@ -101,7 +96,6 @@ class ZigbeeComponent final : public Component { // automations // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; - std::vector attr_values_; ezb_af_device_desc_t dev_desc_ = ezb_af_create_device_desc(); CallbackManager join_cb_{}; template friend void enqueue_zb_event(Args... args); @@ -119,8 +113,6 @@ class ZigbeeComponent final : public Component { std::atomic joined_ = false; std::atomic join_pending_ = false; std::atomic factory_new_ = false; - // TODO: remove when esp-zigbee-lib fixes set_value before init - bool string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id); }; template @@ -135,22 +127,10 @@ void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint1 // The size byte of the zcl_str must be set to the maximum value, // even though the initial string may be shorter. if constexpr (std::is_same::value) { - if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { - if (attr != nullptr) { - this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; - } - return; - } auto zcl_str = get_zcl_string(value.c_str(), max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; } else if constexpr (std::is_convertible::value) { - if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { - if (attr != nullptr) { - this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; - } - return; - } auto zcl_str = get_zcl_string(value, max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; @@ -170,18 +150,7 @@ void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint if (cluster_desc == NULL) { return; } - // TODO: revert when esp-zigbee-lib fixes set_value before init - ezb_zcl_attr_desc_t attr_desc = ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE); - - if (attr_desc != NULL) { - static_assert(sizeof(*value_p) <= 4); - AttrValue attr_value; - attr_value.attr_desc = attr_desc; - memcpy(&attr_value.value, value_p, sizeof(*value_p)); - attr_values_.push_back(attr_value); - } else { - esphome_zb_cluster_add_attr(cluster_id, cluster_desc, attr_id, value_p); - } + esphome_zb_cluster_add_or_update_attr(cluster_id, cluster_desc, attr_id, value_p); if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 99f075ecb2f..30c94cde6cb 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -13,7 +13,6 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, add_partition, include_builtin_idf_component, - require_mbedtls_ecp, require_mbedtls_tls_extras, require_vfs_select, ) @@ -395,17 +394,12 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": add_idf_component( name="espressif/esp-zigbee-lib", - ref="2.0.4", + ref="2.0.5", ) - # The esp-zigbee-lib blobs reference mbedtls_ecp_* (Zigbee Direct, install - # code ECDH); keep ECP without relying on esp_wifi's Kconfig select. - require_mbedtls_ecp() - # Zigbee's crypto platform uses AES-CCM and deterministic ECDSA directly. + # Zigbee's crypto platform uses AES-CCM directly. # Keep the esp32 component from trimming them out of mbedTLS. - require_mbedtls_tls_extras( - ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") - ) + require_mbedtls_tls_extras({"CONFIG_MBEDTLS_CCM_C"}) if CONF_WIFI in CORE.config: # zigbee_esp32.cpp uses esp_coexist.h when WiFi is present diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5ed84fa2a3a..dd837d61632 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -54,7 +54,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: - version: 2.0.4 + version: 2.0.5 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 3cde877f7ad..86b27776dc6 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -1016,8 +1016,8 @@ def _tls_compiled_out(sdkconfig: dict[str, Any]) -> bool: "exclusion_reincludes_nextion.yaml", False, False, False, id="nextion" ), pytest.param("mbedtls_tls_wifi_eap.yaml", False, False, True, id="wifi_eap"), - # zigbee requests ECP for the esp-zigbee-lib blobs, without TLS. - pytest.param("tls_zigbee_c6.yaml", True, False, True, id="zigbee"), + # zigbee needs neither TLS nor ECP + pytest.param("tls_zigbee_c6.yaml", True, True, True, id="zigbee"), # A raw bundle keeps the TLS role but no longer compiles esp-tls. pytest.param( "certificate_bundle_sdkconfig.yaml", False, False, True, id="raw_bundle" @@ -2188,19 +2188,21 @@ def test_mbedtls_tls_openthread_keeps_only_what_it_uses( assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) +_CCM_EXTRAS = {"CONFIG_MBEDTLS_CCM_C"} + + def test_mbedtls_tls_zigbee_keeps_only_what_it_uses( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: """Nothing in the Zigbee config links TLS, so the stack is compiled out and - no role is written; the extras trim still runs and Zigbee keeps CCM and - deterministic ECDSA.""" + no role is written; the extras trim still runs and Zigbee keeps CCM.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert _tls_compiled_out(sdkconfig) assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None) for name in MBEDTLS_TLS_EXTRA_OPTIONS: - assert sdkconfig.get(name) is (None if name in _CCM_ECDSA_EXTRAS else False) + assert sdkconfig.get(name) is (None if name in _CCM_EXTRAS else False) def test_mbedtls_tls_opt_out_keeps_stack_and_trims_role( @@ -2252,10 +2254,10 @@ def test_mbedtls_tls_zigbee_requires_extras( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], ) -> None: - """The Zigbee hooks mark the CCM/deterministic ECDSA as required.""" + """The Zigbee hooks mark the CCM as required.""" generate_main(component_config_path("tls_zigbee_c6.yaml")) mbedtls = CORE.data[KEY_ESP32][KEY_MBEDTLS_SDKCONFIG] - assert mbedtls.tls_extras_required == _CCM_ECDSA_EXTRAS + assert mbedtls.tls_extras_required == _CCM_EXTRAS _VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"} From e110613ef1120a20b0a6d730f7c4c6bbf96de709 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:00:37 +0200 Subject: [PATCH 048/179] [core] Construct App once in the C++ unit test main (#21001) Co-authored-by: J. Nick Koston --- tests/components/daikin/daikin_regression_tests.cpp | 3 --- tests/components/daikin/daikin_tests.cpp | 4 ---- tests/components/display/test_test_card.cpp | 5 ----- tests/components/emontx/common.h | 6 ------ tests/components/emontx/emontx_test.cpp | 1 - tests/components/emontx/text_sensor/emontx_version_test.cpp | 1 - tests/components/filter_lifetime/test_filter_lifetime.cpp | 5 ++++- .../haier_water_heater/water_heater/test_hon_controller.cpp | 4 ---- tests/components/i2c/test_i2c_chip_id.cpp | 4 +++- tests/components/main.cpp | 5 +++++ tests/components/mistral_ir/mistral_ir_tests.cpp | 3 --- tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp | 3 --- tests/components/snapshot/snapshot_test.cpp | 3 --- tests/components/tmp102/test_tmp102.cpp | 5 ++++- tests/components/xiaomi_body_scale/common.h | 3 --- 15 files changed, 16 insertions(+), 39 deletions(-) diff --git a/tests/components/daikin/daikin_regression_tests.cpp b/tests/components/daikin/daikin_regression_tests.cpp index fc642a84e9d..6bb41d30f49 100644 --- a/tests/components/daikin/daikin_regression_tests.cpp +++ b/tests/components/daikin/daikin_regression_tests.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include "esphome/components/daikin/daikin.h" @@ -124,8 +123,6 @@ struct Rig { // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_daikin_regression"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/daikin/daikin_tests.cpp b/tests/components/daikin/daikin_tests.cpp index 0664a82571c..3cea96cce4b 100644 --- a/tests/components/daikin/daikin_tests.cpp +++ b/tests/components/daikin/daikin_tests.cpp @@ -2,7 +2,6 @@ #include #include #include -#include #include #include @@ -146,9 +145,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved if (!keep_preferences && global_preferences != nullptr) global_preferences->reset(); - // The test main does not construct App as generated code does; the boost timer needs its scheduler - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_daikin"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/display/test_test_card.cpp b/tests/components/display/test_test_card.cpp index 555611bc093..a97301ac1e9 100644 --- a/tests/components/display/test_test_card.cpp +++ b/tests/components/display/test_test_card.cpp @@ -31,11 +31,6 @@ class TestCardDisplay : public Display { namespace { class TestCard : public ::testing::Test { protected: - void SetUp() override { - // test_card() stops the poller, which needs the scheduler; the test main does not construct App. - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; - } int writer_calls_{0}; display_writer_t count_writer_() { return [this](Display &) { this->writer_calls_++; }; diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h index 678d582f62f..932fac91cd2 100644 --- a/tests/components/emontx/common.h +++ b/tests/components/emontx/common.h @@ -44,12 +44,6 @@ class MockUARTComponent : public uart::UARTComponent { #endif // defined(USE_ESP8266) || defined(USE_ESP32) }; -// The test main does not construct App as generated code does -inline void construct_app() { - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; -} - inline void cancel_timers(EmonTx *emontx) { for (uint32_t id = 0; id < 3; id++) App.scheduler.cancel_timeout(emontx, id); diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 71bb6c70d36..227fa91a599 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -14,7 +14,6 @@ namespace { class EmonTxTest : public ::testing::Test { protected: void SetUp() override { - construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.add_on_data_callback([this](StringRef line) { this->lines_.emplace_back(line.c_str(), line.size()); }); this->sut_.add_on_json_callback([this](JsonObject, StringRef) { this->json_count_++; }); diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp index 34f6315f3ab..fa51db63e5f 100644 --- a/tests/components/emontx/text_sensor/emontx_version_test.cpp +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -12,7 +12,6 @@ namespace { class EmonTxVersionTest : public ::testing::Test { protected: void SetUp() override { - construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.set_version_text_sensor(&this->version_); } diff --git a/tests/components/filter_lifetime/test_filter_lifetime.cpp b/tests/components/filter_lifetime/test_filter_lifetime.cpp index a4c89edd856..4f33afa24ef 100644 --- a/tests/components/filter_lifetime/test_filter_lifetime.cpp +++ b/tests/components/filter_lifetime/test_filter_lifetime.cpp @@ -39,7 +39,9 @@ class FilterLifetimeTest : public ::testing::Test { unsigned publications_{0}; void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21); host::setup_preferences(); host::host_preferences->reset(); @@ -55,6 +57,7 @@ class FilterLifetimeTest : public ::testing::Test { void TearDown() override { host::host_preferences->reset(); App.~Application(); + new (&App) Application(); // later tests and the test listener use App } void attach(FilterLifetime &filter) { diff --git a/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp index 41b40687d48..0de87d7a7da 100644 --- a/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp +++ b/tests/components/haier_water_heater/water_heater/test_hon_controller.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include "esphome/components/haier_water_heater/hon_water_heater.h" @@ -53,9 +52,6 @@ class TestableHeater : public HonWaterHeater { class HonControllerTest : public ::testing::Test { protected: void SetUp() override { - // The test runner skips generated setup(), which normally constructs App and its scheduler. - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; this->heater_.set_uart_parent(&this->uart_); this->heater_.set_network_pending(false); this->set_clock_(100); diff --git a/tests/components/i2c/test_i2c_chip_id.cpp b/tests/components/i2c/test_i2c_chip_id.cpp index c3e3fdd4907..a3b567d917d 100644 --- a/tests/components/i2c/test_i2c_chip_id.cpp +++ b/tests/components/i2c/test_i2c_chip_id.cpp @@ -33,7 +33,9 @@ class ChipDevice : public Component, public I2CDevice {}; class I2CChipIdTest : public ::testing::Test { protected: void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); static char name[] = "i2c-chip-id-tests"; static char friendly_name[] = "I2C chip ID tests"; App.pre_setup(name, sizeof(name) - 1, friendly_name, sizeof(friendly_name) - 1); diff --git a/tests/components/main.cpp b/tests/components/main.cpp index 4020dd9a505..cbf19f31ba4 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -32,6 +32,11 @@ class ResetAppListener : public ::testing::EmptyTestEventListener { }; void setup() { + // Construct App once, as generated code does, before the logger, the listener or any test touches it. + // A fixture that destroys App must construct it again before returning. + // Placement new needs , which the generated include block already provides + new (&esphome::App) esphome::Application; + // Log functions call global_logger->log_vprintf_() without a null check, // so we must set up a Logger before any test that triggers logging. static esphome::logger::Logger test_logger(0); diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp index 9b489154d02..b7260f95d88 100644 --- a/tests/components/mistral_ir/mistral_ir_tests.cpp +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -84,9 +84,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_mistral_ir"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp index 293c363d5d6..76ffd5a70d4 100644 --- a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp +++ b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp @@ -72,9 +72,6 @@ struct Fixture { // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; static char name[] = "test_pd_pioneer_ir"; static char friendly_name[] = ""; App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); diff --git a/tests/components/snapshot/snapshot_test.cpp b/tests/components/snapshot/snapshot_test.cpp index d63101357a2..92200814181 100644 --- a/tests/components/snapshot/snapshot_test.cpp +++ b/tests/components/snapshot/snapshot_test.cpp @@ -46,9 +46,6 @@ class SnapshotAnimationTest : public ::testing::Test { fs::remove_all(this->dir_); fs::create_directories(this->dir_); setenv("ESPHOME_SNAPSHOT_DIR", this->dir_.c_str(), 1); - // The test main does not construct App as generated code does, and recording needs its scheduler. - static const bool app_constructed = (new (&App) Application(), true); - (void) app_constructed; App.pre_setup("test_snapshot", 10, "", 0); } void TearDown() override { diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index a5b65b060d4..081dfba8d2c 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -71,7 +71,9 @@ class TMP102Test : public ::testing::Test { unsigned publications_{0}; void SetUp() override { - new (&App) Application; + // The test main keeps App alive; this test replaces it with a fresh one it owns + App.~Application(); + new (&App) Application(); App.pre_setup("tmp102-regression-tests", 23, "TMP102 tests", 12); host::setup_preferences(); host::host_preferences->reset(); @@ -93,6 +95,7 @@ class TMP102Test : public ::testing::Test { App.scheduler.call(millis()); host::host_preferences->reset(); App.~Application(); + new (&App) Application(); // later tests and the test listener use App } static void run_timers(uint32_t duration) { diff --git a/tests/components/xiaomi_body_scale/common.h b/tests/components/xiaomi_body_scale/common.h index 67f1eaae91a..e21b014efff 100644 --- a/tests/components/xiaomi_body_scale/common.h +++ b/tests/components/xiaomi_body_scale/common.h @@ -52,9 +52,6 @@ inline ble_device_base::ESPBTDevice advert(uint64_t address, const Frame &frame) struct Harness { Harness(uint64_t address, const char *key) : scale(address, key) { - // The test main does not construct App as generated code does; the stabilized reset needs its scheduler - static const bool app_constructed = (new (&App) Application(), true); - (void) app_constructed; App.pre_setup("test_scale", 10, "", 0); this->scale.set_weight(&this->weight); this->scale.set_impedance_low(&this->impedance_low); From 5c2b05d0eb5fe08cb1cbb4b7d133e30308c49835 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 09:00:51 -1000 Subject: [PATCH 049/179] [ci] Run the C++ unit tests on macOS without a sanitizer whose runtime aborts (#21000) --- script/cpp_unit_test.py | 57 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index c5179a1b1e9..c2b34e1b409 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -3,7 +3,9 @@ import argparse from functools import partial import os from pathlib import Path +import subprocess import sys +import tempfile from build_helpers import ( PLATFORMIO_GOOGLE_TEST_LIB, @@ -12,6 +14,9 @@ from build_helpers import ( ) from helpers import get_all_components, root_path +from esphome.core import EsphomeError +from esphome.host.toolchain import find_compilers + # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" @@ -20,11 +25,55 @@ BUILD_FLAGS = [ "-g3", # max debug info "-ggdb3", "-DESPHOME_DEBUG", # enable debug assertions - # Enable the address and undefined behavior sanitizers - "-fsanitize=address", - "-fsanitize=undefined", "-fno-omit-frame-pointer", ] +SANITIZER_FLAGS = ["-fsanitize=address", "-fsanitize=undefined"] + + +def sanitizer_flags() -> list[str]: + """The sanitizer flags to build with: all of them, except on macOS the ones whose runtime + does not start. Apple clang 15's AddressSanitizer aborts before main() on macOS 26, which + would fail every test for a toolchain problem, so each flag is tried on an empty program + first. A compiler that cannot build the empty program is an error, not a dropped flag. + CI keeps every flag. + """ + if sys.platform != "darwin": + return SANITIZER_FLAGS + try: + cxx = find_compilers().cxx + except EsphomeError as err: + sys.exit(str(err)) + flags: list[str] = [] + with tempfile.TemporaryDirectory() as tmp: + program = Path(tmp) / "probe" + for flag in SANITIZER_FLAGS: + compiled = subprocess.run( + [*cxx, flag, "-x", "c++", "-", "-o", program], + check=False, + input=b"int main() { return 0; }", + capture_output=True, + ) + if compiled.returncode != 0: + sys.exit( + f"{cxx[0]} cannot build an empty program with {flag}:\n" + f"{compiled.stderr.decode(errors='replace').strip()}" + ) + ran = subprocess.run([program], check=False, capture_output=True) + if ran.returncode == 0: + flags.append(flag) + continue + lines = [ + line + for line in ran.stderr.decode(errors="replace").splitlines() + if line.strip() + ] + print( + f"{flag} aborts at start with {cxx[0]}: " + f"{lines[0] if lines else f'exit code {ran.returncode}'}\n" + " building the tests without it; set CXX to a compiler whose runtime works to keep it", + file=sys.stderr, + ) + return flags def run_tests(selected_components: list[str]) -> int: @@ -39,7 +88,7 @@ def run_tests(selected_components: list[str]) -> int: config_prefix="cpptests", friendly_name="CPP Unit Tests", libraries=PLATFORMIO_GOOGLE_TEST_LIB, - build_flags=BUILD_FLAGS, + build_flags=BUILD_FLAGS + sanitizer_flags(), main_entry="main.cpp", label="unit tests", ) From c2c8cb5eb37eb244a82d65cfb319a92fd44756fa Mon Sep 17 00:00:00 2001 From: Matias Fernandez <7898112+MatiasFernandez@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:12:35 -0300 Subject: [PATCH 050/179] [climate_ir_lg] Fix swing command to not include climate state (#17417) Co-authored-by: J. Nick Koston --- .../climate_ir_lg/climate_ir_lg.cpp | 8 +- .../climate/lg_ir_climate_tests.cpp | 90 +++++++++++++++++++ tests/components/climate_ir_lg/common.h | 33 +++++++ 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp create mode 100644 tests/components/climate_ir_lg/common.h diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.cpp b/esphome/components/climate_ir_lg/climate_ir_lg.cpp index 042d1dcb21f..16ed9831fd7 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.cpp +++ b/esphome/components/climate_ir_lg/climate_ir_lg.cpp @@ -135,13 +135,14 @@ void LgIrClimate::transmit_state() { default: return; } - this->transmit_(remote_state); - this->publish_state(); - return; } else { // just toggle swing when advanced_commands_support is not set remote_state |= HEADER_BASIC; remote_state |= BASIC_SWING_TOGGLE; } + // The remote's swing button sends a fixed code with no fan speed or temperature bits, so the + // frame goes out as is instead of falling through to them + this->transmit_(remote_state); + return; } else { // Mode commands const bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF); switch (this->mode) { @@ -208,7 +209,6 @@ void LgIrClimate::transmit_state() { } this->transmit_(remote_state); - this->publish_state(); } bool LgIrClimate::on_receive(remote_base::RemoteReceiveData data) { diff --git a/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp b/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp new file mode 100644 index 00000000000..c8c8e79db65 --- /dev/null +++ b/tests/components/climate_ir_lg/climate/lg_ir_climate_tests.cpp @@ -0,0 +1,90 @@ +#include +#include "../common.h" + +namespace esphome::climate_ir_lg::testing { + +namespace { + +constexpr uint32_t BIT_ONE_LOW = 1600; +// A complete LG frame is header (2) + 28 bit pairs (56) + trailing mark (1) +constexpr size_t FRAME_SIZE = 2 + 28 * 2 + 1; + +// A climate with timings set, so transmit_() produces a frame decode_lg_frame() can read back +void configure(LgIrClimate &sut, MockRemoteTransmitter &transmitter) { + sut.set_transmitter(&transmitter); + sut.set_header_high(8000); + sut.set_header_low(4000); + sut.set_bit_high(600); + sut.set_bit_one_low(BIT_ONE_LOW); + sut.set_bit_zero_low(550); +} + +// COOL at 20 °C with the fan on HIGH: a distinctive state the swing tests must not leak into their frame +void turn_on(LgIrClimate &sut) { + climate::ClimateCall on_call(&sut); + on_call.set_mode(climate::CLIMATE_MODE_COOL); + on_call.set_target_temperature(20); + on_call.set_fan_mode(climate::CLIMATE_FAN_HIGH); + sut.control(on_call); +} + +uint32_t last_frame(const MockRemoteTransmitter &transmitter) { + // Guard against out-of-bounds reads in decode_lg_frame() if the transmission failed + if (transmitter.last_data.size() < FRAME_SIZE) { + ADD_FAILURE() << "no complete frame was transmitted"; + return 0; + } + return decode_lg_frame(transmitter.last_data, BIT_ONE_LOW); +} + +} // namespace + +// A mode command carries the mode, the fan speed and the temperature, with the checksum nibble +TEST(LgIrClimateTests, ModeCommandCarriesModeFanSpeedAndTemperature) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + + turn_on(sut); + + // From OFF: COMMAND_ON_COOL, FAN_MAX, 20 °C encoded as (20 - 15) << 8, checksum 9 + EXPECT_EQ(last_frame(transmitter), 0x8800549u); +} + +// The swing command is a distinct, self-contained IR code: the real remote transmits it +// on its own, independent of the unit's current mode, target temperature, or fan speed. +TEST(LgIrClimateTests, SwingCommandIsIndependentOfClimateState) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + turn_on(sut); + + // Toggle swing -- this must be the fixed swing code below, unaffected by the state set above. + climate::ClimateCall swing_call(&sut); + swing_call.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + sut.control(swing_call); + + // The swing command is always this exact fixed code, regardless of climate state. + EXPECT_EQ(last_frame(transmitter), 0x8810001u); +} + +// With advanced commands the swing frames are fixed codes too, one to start and one to stop the swing +TEST(LgIrClimateTests, AdvancedSwingCommandsAreIndependentOfClimateState) { + LgIrClimate sut; + MockRemoteTransmitter transmitter; + configure(sut, transmitter); + sut.set_advanced_commands_support(true); + turn_on(sut); + + climate::ClimateCall swing_on(&sut); + swing_on.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + sut.control(swing_on); + EXPECT_EQ(last_frame(transmitter), 0x8813149u); // HEADER_ADV_SWING | VERT_SWING_ON + + climate::ClimateCall swing_off(&sut); + swing_off.set_swing_mode(climate::CLIMATE_SWING_OFF); + sut.control(swing_off); + EXPECT_EQ(last_frame(transmitter), 0x881315Au); // HEADER_ADV_SWING | VERT_SWING_OFF +} + +} // namespace esphome::climate_ir_lg::testing diff --git a/tests/components/climate_ir_lg/common.h b/tests/components/climate_ir_lg/common.h new file mode 100644 index 00000000000..7e9a79cc615 --- /dev/null +++ b/tests/components/climate_ir_lg/common.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include "esphome/components/climate_ir_lg/climate_ir_lg.h" +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::climate_ir_lg::testing { + +// Records the raw mark/space timings instead of driving real IR hardware. +class MockRemoteTransmitter : public remote_base::RemoteTransmitterBase { + public: + MockRemoteTransmitter() : RemoteTransmitterBase(nullptr) {} + + std::vector last_data; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { this->last_data = this->temp_.get_data(); } +}; + +// Decodes the raw mark/space timings produced by LgIrClimate::transmit_() back into the +// 28-bit value that was transmitted (header + bits, ignoring the trailing mark). +inline uint32_t decode_lg_frame(const std::vector &data, uint32_t bit_one_low) { + uint32_t value = 0; + // data[0]/data[1] are the header mark/space; each bit thereafter is a mark/space pair. + for (size_t i = 0; i < 28; i++) { + int32_t space = data[2 + 2 * i + 1]; + value = (value << 1) | (static_cast(-space) == bit_one_low ? 1 : 0); + } + return value; +} + +} // namespace esphome::climate_ir_lg::testing From 5f8f075e767a7b07e16323dc4645304b38e4b71a Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 17 Oct 2026 21:22:28 +0200 Subject: [PATCH 051/179] [zephyr_coredump] Store a coredump in flash on nRF52 and print it to the next log reader (#13122) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/logger/logger_zephyr.cpp | 38 +++- esphome/components/nrf52/clang_tidy.py | 1 + .../components/zephyr_coredump/__init__.py | 111 ++++++++++ .../components/zephyr_coredump/coredump.cpp | 191 ++++++++++++++++++ esphome/components/zephyr_coredump/coredump.h | 43 ++++ .../components/zephyr_mcumgr/ota/__init__.py | 26 ++- esphome/core/defines.h | 1 + .../zephyr_coredump/__init__.py | 0 .../config/mcumgr_adafruit.yaml | 15 ++ .../zephyr_coredump/test_zephyr_coredump.py | 104 ++++++++++ tests/components/zephyr_coredump/common.yaml | 1 + .../zephyr_coredump/test.nrf52-adafruit.yaml | 2 + .../zephyr_coredump/test.nrf52-mcumgr.yaml | 2 + .../test.nrf52-nrf52840dongle.yaml | 2 + .../zephyr_coredump/test.nrf52-xiao-ble.yaml | 2 + 16 files changed, 526 insertions(+), 14 deletions(-) create mode 100644 esphome/components/zephyr_coredump/__init__.py create mode 100644 esphome/components/zephyr_coredump/coredump.cpp create mode 100644 esphome/components/zephyr_coredump/coredump.h create mode 100644 tests/component_tests/zephyr_coredump/__init__.py create mode 100644 tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml create mode 100644 tests/component_tests/zephyr_coredump/test_zephyr_coredump.py create mode 100644 tests/components/zephyr_coredump/common.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-adafruit.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml create mode 100644 tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 65ed44ee30b..539730a4a67 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -727,6 +727,7 @@ esphome/components/xl9535/* @mreditor97 esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 +esphome/components/zephyr_coredump/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 esphome/components/zephyr_pwm/* @wiomoc esphome/components/zhlt01/* @cfeenstra1024 diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 95cc5d4a14b..c74943940c8 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -3,6 +3,9 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "logger.h" +#ifdef USE_CRASH_HANDLER +#include "esphome/components/zephyr_coredump/coredump.h" +#endif #include #include @@ -12,12 +15,6 @@ #include #endif -namespace esphome::zephyr_coredump { - -__attribute__((weak)) void print_coredump() {} - -} // namespace esphome::zephyr_coredump - namespace esphome::logger { // Zephyr 3.7 renamed z_arch_esf_t to struct arch_esf; the old name was later removed. @@ -56,6 +53,14 @@ void Logger::cdc_loop_() { if (!opened) { App.schedule_dump_config(); } +#ifdef USE_CRASH_HANDLER + // The new reader also gets a coredump stored by the last crash; a closed port keeps it for the next one + if (!opened) { + log_crash_to_client(); + } else { + zephyr_coredump::stop_coredump(); + } +#endif opened = !opened; } #endif @@ -103,7 +108,16 @@ void Logger::pre_setup() { const char *reset_reason = zephyr::get_reset_reason(std::span(reason_buffer)); ESP_LOGI(TAG, "Reset reason: %s", reset_reason); dump_crash_(); - zephyr_coredump::print_coredump(); +#endif +#ifdef USE_CRASH_HANDLER +#ifdef USE_LOGGER_UART_SELECTION_USB_CDC + // Nobody reads a USB port this early; cdc_loop_() reports the dump once the port is opened + if (this->uart_ == UART_SELECTION_USB_CDC) + return; +#endif + // Without a UART (baud_rate: 0, or one that did not come up) nothing reaches the console + if (this->uart_dev_ != nullptr) + log_crash_to_client(); #endif } @@ -195,6 +209,16 @@ void k_sys_fatal_error_handler(unsigned int reason, const FatalErrorEsf *esf) { arch_restart(); } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return zephyr_coredump::coredump_pending(); } +void log_crash_to_client() { + // A level that drops the lines would erase the dump unseen; keep it for a client that can see it + if (global_logger->get_log_level(zephyr_coredump::CRASH_LOG_TAG) < ESPHOME_LOG_LEVEL_ERROR) + return; + zephyr_coredump::print_coredump(); +} +#endif + } // namespace esphome::logger extern "C" { diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py index d1f95979767..f4afafa0e49 100644 --- a/esphome/components/nrf52/clang_tidy.py +++ b/esphome/components/nrf52/clang_tidy.py @@ -93,6 +93,7 @@ CONFIG_NVS=y CONFIG_SETTINGS=y """, ), + _TidySubsystem("coredump", "CONFIG_DEBUG_COREDUMP=y\n"), ) _TIDY_PRJ_CONF = "".join( diff --git a/esphome/components/zephyr_coredump/__init__.py b/esphome/components/zephyr_coredump/__init__.py new file mode 100644 index 00000000000..cc36ac7dc4c --- /dev/null +++ b/esphome/components/zephyr_coredump/__init__.py @@ -0,0 +1,111 @@ +import esphome.codegen as cg +from esphome.components.nrf52 import NRF52840_FLASH_SIZE, TESTING_FLASH_SIZE +from esphome.components.nrf52.boards import BOARDS_ZEPHYR, BOOTLOADER_CONFIG +from esphome.components.zephyr import ( + Section, + zephyr_add_overlay, + zephyr_add_pm_static, + zephyr_add_prj_conf, + zephyr_data, +) +from esphome.components.zephyr.const import ( + BOOTLOADER_MCUBOOT, + KEY_BOARD, + KEY_BOOTLOADER, + KEY_PM_STATIC, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType + +CODEOWNERS = ["@tomaszduda23"] +DEPENDENCIES = ["logger"] +DOMAIN = "zephyr_coredump" + +Coredump = cg.esphome_ns.namespace(DOMAIN).class_("Coredump", cg.Component) + +# SDK 2.9.2 and later can store every thread's stack; older ones only the faulting thread +DUMP_THREADS_MIN_VERSION = cv.Version(2, 9, 2) +# Zephyr 4.0 (from SDK 3.0) carries the fault stack pointer fix (zephyr PR 79622) +SP_FIX_MIN_VERSION = cv.Version(3, 0, 0) +# One size for every SDK, so enabling the component moves the OTA image slots the same way everywhere +PARTITION_SIZE = 0x8000 + +CONFIG_SCHEMA = cv.All( + cv.Schema({cv.GenerateID(): cv.declare_id(Coredump)}).extend(cv.COMPONENT_SCHEMA), + cv.only_on_nrf52, +) + + +def _framework_version() -> cv.Version: + return CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + + +def _partition_address() -> int: + """Place the partition directly below the lowest section reserved above the + SoftDevice (the bootloader), or at the end of flash with MCUboot.""" + data = zephyr_data() + # The SoftDevice (or MBR) is the only section that can start at 0 + reserved = [s.address for s in data[KEY_PM_STATIC] if s.address > 0] + if reserved: + return min(reserved) - PARTITION_SIZE + if (bootloader := data[KEY_BOOTLOADER]) != BOOTLOADER_MCUBOOT: + raise cv.Invalid( + f"{DOMAIN} does not know the flash layout of bootloader '{bootloader}', " + f"select one of: {', '.join(BOOTLOADER_CONFIG)}" + ) + # MCUboot reserves nothing, so the flash size has to come from the board + board = data[KEY_BOARD] + if "nrf52840" not in board and board not in BOARDS_ZEPHYR: + raise cv.Invalid( + f"{DOMAIN} does not know the flash size of board '{board}', " + "only nRF52840 boards are supported" + ) + flash_size = TESTING_FLASH_SIZE if CORE.testing_mode else NRF52840_FLASH_SIZE + return flash_size - PARTITION_SIZE + + +def _final_validate(config: ConfigType) -> ConfigType: + _partition_address() + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +# Runs before zephyr_mcumgr OTA (OTA_UPDATES), which sizes its image slots below this partition +@coroutine_with_priority(CoroPriority.DIAGNOSTICS) +async def to_code(config: ConfigType) -> None: + # The logger's crash handler hooks: the dump reaches serial, API and MQTT log clients + cg.add_define("USE_CRASH_HANDLER") + zephyr_add_prj_conf("DEBUG_COREDUMP", True) + zephyr_add_prj_conf("DEBUG_COREDUMP_BACKEND_FLASH_PARTITION", True) + framework_ver = _framework_version() + if framework_ver >= DUMP_THREADS_MIN_VERSION: + zephyr_add_prj_conf("DEBUG_COREDUMP_MEMORY_DUMP_THREADS", True) + else: + zephyr_add_prj_conf("DEBUG_COREDUMP_MEMORY_DUMP_MIN", True) + if framework_ver < SP_FIX_MIN_VERSION: + cg.add_define("USE_ZEPHYR_COREDUMP_SP_FIX") + cg.add_build_flag("-Wl,--wrap=z_arm_fatal_error") + cg.add_build_flag("-Wl,--wrap=z_arm_fault") + zephyr_add_prj_conf("PARTITION_MANAGER_ENABLED", True) + addr = _partition_address() + zephyr_add_pm_static( + [Section("coredump_partition", addr, PARTITION_SIZE, "flash_primary")] + ) + zephyr_add_overlay( + f""" + &flash0 {{ + partitions {{ + coredump_partition: partition@{addr:x} {{ + label = "coredump-partition"; + reg = <{addr:#x} {PARTITION_SIZE:#x}>; + }}; + }}; + }}; + """ + ) + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) diff --git a/esphome/components/zephyr_coredump/coredump.cpp b/esphome/components/zephyr_coredump/coredump.cpp new file mode 100644 index 00000000000..7bfbcc927a0 --- /dev/null +++ b/esphome/components/zephyr_coredump/coredump.cpp @@ -0,0 +1,191 @@ +#ifdef USE_ZEPHYR +#include "coredump.h" +#include "esphome/core/application.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include +#include +// The header has no C++ linkage guards of its own +extern "C" { +#include +} + +namespace esphome::zephyr_coredump { + +ESPHOME_LOG_TAG(TAG, "zephyr_coredump"); + +// Lines in the "#CD:" format that Zephyr's coredump_serial_log_parser.py reads: the log header (time, level, +// tag, line number and colour codes, under 64 characters), "#CD:" and two hex characters per byte must fit the +// logger's line buffer, so a small buffer gets shorter lines +static constexpr size_t LINE_OVERHEAD = 64; +static constexpr size_t CHUNK_SIZE = + std::min(64, (ESPHOME_LOGGER_TX_BUFFER_SIZE - LINE_OVERHEAD) / 2 / 16 * 16); +static_assert(CHUNK_SIZE >= 16, "logger line buffer too small for a coredump line"); +// A USB host starts reading the port only some 100 ms after opening it; until then the CDC driver discards output +static constexpr uint32_t START_DELAY_MS = 1000; +static constexpr uint32_t START_DELAY_ID = 0; +// Between lines, so a loop that runs faster than usual (a stepper moving) cannot overrun the USB CDC ring buffer +static constexpr uint32_t LINE_INTERVAL_MS = 10; + +// On a UART the logger reports the reader before this component exists, so the flag outlives the pointer +static Coredump *global_coredump = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool reader_present = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool coredump_pending() { return global_coredump != nullptr && global_coredump->stored_; } + +void print_coredump() { + reader_present = true; + if (global_coredump != nullptr) + global_coredump->start_print_(); +} + +void stop_coredump() { + reader_present = false; + if (global_coredump != nullptr) + global_coredump->stop_print_(); +} + +void Coredump::setup() { + global_coredump = this; + this->disable_loop(); + // The size comes from the header alone; "has stored dump" also checks the checksum and would hide a + // corrupt dump instead of letting it be reported and erased + const int size = coredump_query(COREDUMP_QUERY_GET_STORED_DUMP_SIZE, nullptr); + if (size <= 0) { + return; + } + this->stored_ = true; + this->total_ = size; + // A bad dump is reported and erased from loop() like a good one, once somebody can read the message + this->verify_ = coredump_cmd(COREDUMP_CMD_VERIFY_STORED_DUMP, nullptr); + if (reader_present) + this->start_print_(); +} + +// A new reader gets the whole dump: a print cut short by the port closing starts over +void Coredump::start_print_() { + if (!this->stored_ || this->printing_) + return; + this->printing_ = true; + this->offset_ = 0; + this->set_timeout(START_DELAY_ID, START_DELAY_MS, [this]() { this->enable_loop(); }); +} + +void Coredump::stop_print_() { + this->printing_ = false; + this->cancel_timeout(START_DELAY_ID); + this->disable_loop(); +} + +void Coredump::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_line_ms_ < LINE_INTERVAL_MS) + return; + this->last_line_ms_ = now; + if (this->verify_ != 1) { + ESP_LOGE(TAG, "Stored coredump verification failed: %d", this->verify_); + this->finish_(0); + return; + } + if (this->offset_ == 0) { + ESP_LOGE(TAG, "#CD:BEGIN#"); + } + uint8_t buf[CHUNK_SIZE]; + char hex[format_hex_size(CHUNK_SIZE)]; + coredump_cmd_copy_arg arg{static_cast(this->offset_), buf, + std::min(CHUNK_SIZE, this->total_ - this->offset_)}; + const int ret = coredump_cmd(COREDUMP_CMD_COPY_STORED_DUMP, &arg); + if (ret > 0) { + ESP_LOGE(TAG, "#CD:%s", format_hex_to(hex, buf, ret)); + this->offset_ += ret; + } + if (ret <= 0 || this->offset_ >= this->total_) { + ESP_LOGE(TAG, "#CD:END#"); + this->finish_(ret < 0 ? ret : 0); + } +} + +// Erase so the dump is not reported again; the next crash writes a new one. Erasing the partition halts the +// CPU for about 85 ms per 4 KB page, so this one call takes most of a second +void Coredump::finish_(int err) { + if (err < 0) { + ESP_LOGE(TAG, "Failed to copy coredump: %d", err); + } + const int erase = coredump_cmd(COREDUMP_CMD_ERASE_STORED_DUMP, nullptr); + if (erase < 0) { + ESP_LOGE(TAG, "Failed to erase coredump: %d", erase); + } + this->stored_ = false; + this->total_ = 0; + this->verify_ = 0; + this->printing_ = false; + this->disable_loop(); +} + +void Coredump::dump_config() { + ESP_LOGCONFIG(TAG, + "Coredump:\n" + " Stored dump: %s\n" + " Size: %" PRIu32 "\n" + " Verified: %s\n" + " Error: %d", + YESNO(this->stored_), this->total_, YESNO(this->verify_ == 1), + coredump_query(COREDUMP_QUERY_GET_ERROR, nullptr)); +} + +} // namespace esphome::zephyr_coredump + +// Backport of https://github.com/zephyrproject-rtos/zephyr/pull/79622 (Zephyr 4.0): give GDB the stack +// pointer from before the exception frame so it can unwind. The Python side links it in with --wrap. +#ifdef USE_ZEPHYR_COREDUMP_SP_FIX +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +#if KERNEL_VERSION_NUMBER >= 0x030700 +using esf_t = struct arch_esf; +#else +using esf_t = z_arch_esf_t; +#endif + +static constexpr uint8_t EXC_RETURN_STACK_FRAME_TYPE_POS = 4; +static constexpr uint32_t EXC_RETURN_STACK_FRAME_TYPE_EXTENDED = 0; + +static uint32_t global_exc_return; + +void __real_z_arm_fault(uint32_t msp, uint32_t psp, uint32_t exc_return, _callee_saved_t *callee_regs); +void __wrap_z_arm_fault(uint32_t msp, uint32_t psp, uint32_t exc_return, _callee_saved_t *callee_regs) { + global_exc_return = exc_return; + __real_z_arm_fault(msp, psp, exc_return, callee_regs); +} + +// Faults from z_arm_fault() come here with the frame z_arm_fault() saved; a spurious interrupt has none. +// Software fatal errors (k_panic, k_oops) reach z_arm_fatal_error() inside its own object, which --wrap cannot +// intercept, so their dumps keep Zephyr's stack pointer. +void __real_z_arm_fatal_error(unsigned int reason, const esf_t *esf); +void __wrap_z_arm_fatal_error(unsigned int reason, const esf_t *esf) { +#if defined(CONFIG_ARMV7_M_ARMV8_M_MAINLINE) || defined(CONFIG_ARMV6_M_ARMV8_M_BASELINE) + if (esf == nullptr) { + __real_z_arm_fatal_error(reason, esf); + return; + } + z_arm_coredump_fault_sp += sizeof(esf->basic); +#if defined(CONFIG_FPU) && defined(CONFIG_FPU_SHARING) + // The thread used the FP registers, so the frame also holds them + if ((global_exc_return & BIT(EXC_RETURN_STACK_FRAME_TYPE_POS)) == EXC_RETURN_STACK_FRAME_TYPE_EXTENDED) { + z_arm_coredump_fault_sp += sizeof(esf->fpu); + } +#endif +#ifndef CONFIG_ARMV8_M_MAINLINE + // PSR bit 9 set: the frame was padded for alignment + if ((esf->basic.xpsr & SCB_CCR_STKALIGN_Msk) == SCB_CCR_STKALIGN_Msk) { + z_arm_coredump_fault_sp |= 0x4; + } +#endif +#endif + __real_z_arm_fatal_error(reason, esf); +} +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif +#endif diff --git a/esphome/components/zephyr_coredump/coredump.h b/esphome/components/zephyr_coredump/coredump.h new file mode 100644 index 00000000000..f56568e3555 --- /dev/null +++ b/esphome/components/zephyr_coredump/coredump.h @@ -0,0 +1,43 @@ +#pragma once +#include "esphome/core/component.h" + +namespace esphome::zephyr_coredump { + +/// The tag the dump is logged under, so a client can check the level in force for it +inline constexpr const char *CRASH_LOG_TAG = "zephyr_coredump"; + +/// Whether a dump from before this boot is waiting to be printed. +bool coredump_pending(); +/// A log reader is present: serial at boot, USB CDC when the port opens, an API or MQTT client subscribing. +/// The stored dump is printed from the start and erased once it has gone out; a print already under way +/// carries on. +void print_coredump(); +/// The reader went away (the USB port was closed); the print stops and the dump stays for the next one. +/// Readers are not told apart: closing the USB port also stops a print an API or MQTT client is receiving, +/// and a client that disconnects does not stop one, the same as the other platforms' crash records. +void stop_coredump(); + +class Coredump : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + + protected: + friend bool coredump_pending(); + friend void print_coredump(); + friend void stop_coredump(); + /// Start printing after a short delay, one line per loop so the USB CDC transmit buffer and the main loop keep up + void start_print_(); + void stop_print_(); + void finish_(int err); + + uint32_t offset_{0}; + uint32_t total_{0}; + uint32_t last_line_ms_{0}; + int verify_{0}; // result of verifying the stored dump, 1 when it is good + bool stored_{false}; + bool printing_{false}; +}; + +} // namespace esphome::zephyr_coredump diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index 5b438bbda1c..b6ef932e4f8 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components.nrf52.boards import MCUMGR_OTA_BOOTLOADERS @@ -31,8 +33,11 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@tomaszduda23"] DEPENDENCIES = ["zephyr"] @@ -114,6 +119,12 @@ def _validate_bootloader(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> None: _validate_ble_server(config) _validate_bootloader(config) + if "zephyr_coredump" in fv.full_config.get(): + _LOGGER.warning( + "zephyr_coredump moves the OTA image slots. A device that already runs mcumgr OTA " + "needs one flash over USB after enabling or disabling it; an OTA update alone leaves " + "the installed MCUboot with the old slot layout" + ) if (uart := config[CONF_TRANSPORT].get(CONF_HARDWARE_UART)) is not None: cdc_id = UARTS[uart][1] if cdc_id >= 0: @@ -210,17 +221,18 @@ async def to_code(config: ConfigType) -> None: bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader != BOOTLOADER_MCUBOOT: - # Derive partition addresses from the SoftDevice and bootloader sections - # registered with the Partition Manager (moved in testing mode) so that - # the DTS flash map matches what it produces: - # MCUboot sits immediately after the SoftDevice, then slot0, then slot1. + # Derive partition addresses from the sections registered with the + # Partition Manager (moved in testing mode) so that the DTS flash map + # matches what it produces: + # MCUboot sits immediately after the SoftDevice, then slot0, then slot1, + # which ends at the lowest section reserved above (bootloader, coredump). sections = zephyr_data()[KEY_PM_STATIC] mcuboot_size = 0x9000 - sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name) - bl_start = next(s.address for s in sections if "Adafruit" in s.name) + sd_end = next(s.end_address for s in sections if "SoftDevice" in s.name) + top = min(s.address for s in sections if s.address >= sd_end) slot0_start = sd_end + mcuboot_size # Align slot size down to a 4 KB sector boundary - slot_size = ((bl_start - slot0_start) // 2 // 0x1000) * 0x1000 + slot_size = ((top - slot0_start) // 2 // 0x1000) * 0x1000 slot1_start = slot0_start + slot_size def _mcuboot_partition_overlay() -> str: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index de4dfd2607b..331a637b6b3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -756,6 +756,7 @@ #define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_SOFTDEVICE_ID 7 #define USE_SOFTDEVICE_VERSION 1 +#define USE_ZEPHYR_COREDUMP_SP_FIX #define USE_ZIGBEE #define USE_ZIGBEE_WIPE_ON_BOOT #define USE_ZIGBEE_WIPE_ON_BOOT_MAGIC 1 diff --git a/tests/component_tests/zephyr_coredump/__init__.py b/tests/component_tests/zephyr_coredump/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml b/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml new file mode 100644 index 00000000000..cb67b8ef9f7 --- /dev/null +++ b/tests/component_tests/zephyr_coredump/config/mcumgr_adafruit.yaml @@ -0,0 +1,15 @@ +esphome: + name: coredumptest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +zephyr_ble_server: + +ota: + - platform: zephyr_mcumgr + +zephyr_coredump: diff --git a/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py b/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py new file mode 100644 index 00000000000..19252188cb8 --- /dev/null +++ b/tests/component_tests/zephyr_coredump/test_zephyr_coredump.py @@ -0,0 +1,104 @@ +"""Tests for the placement of the zephyr_coredump flash partition.""" + +from collections.abc import Callable +import logging +from pathlib import Path +import re + +import pytest + +from esphome.components.nrf52 import set_core_data +from esphome.components.zephyr import zephyr_data +from esphome.components.zephyr.const import KEY_OVERLAY, KEY_PM_STATIC +from esphome.components.zephyr_coredump import ( + DUMP_THREADS_MIN_VERSION, + PARTITION_SIZE, + _partition_address, +) +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + + +def _core_data( + board: str, + bootloader: str, + testing_mode: bool = False, + version: cv.Version = DUMP_THREADS_MIN_VERSION, +) -> None: + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: version} + CORE.testing_mode = testing_mode + set_core_data({"board": board, "bootloader": bootloader}) + + +@pytest.mark.parametrize( + ("board", "bootloader", "testing_mode", "expected"), + [ + # Below the Adafruit bootloader at 0xF4000 + ("adafruit_itsybitsy_nrf52840", "adafruit_nrf52_sd140_v6", False, 0xEC000), + # Below the factory Open DFU bootloader at 0xE0000 + ("nrf52840dongle", "nrf", False, 0xD8000), + # Nothing reserved: the end of the 1 MB flash + ("adafruit_feather_nrf52840", "mcuboot", False, 0xF8000), + # An nRF52840 board whose name does not say so + ("xiao_ble", "mcuboot", False, 0xF8000), + # Testing mode moves the bootloader to the end of the faked 4 MB flash + ("adafruit_itsybitsy_nrf52840", "adafruit_nrf52_sd140_v6", True, 0x3EC000), + ("adafruit_feather_nrf52840", "mcuboot", True, 0x3F8000), + ], +) +def test_partition_address( + board: str, bootloader: str, testing_mode: bool, expected: int +) -> None: + _core_data(board, bootloader, testing_mode) + assert _partition_address() == expected + assert PARTITION_SIZE % 0x1000 == 0 + + +def test_partition_size_is_the_same_on_every_sdk() -> None: + _core_data("adafruit_feather_nrf52840", "mcuboot", version=cv.Version(2, 6, 1)) + assert _partition_address() == 0x100000 - PARTITION_SIZE + + +def test_unknown_flash_size_rejected() -> None: + _core_data("bbc_microbit", "mcuboot") + with pytest.raises(cv.Invalid, match="only nRF52840 boards are supported"): + _partition_address() + + +def test_bootloader_without_layout_rejected() -> None: + """The generic adafruit bootloader registers no sections, so the partition would + land inside the bootloader if it were treated like MCUboot.""" + _core_data("adafruit_itsybitsy_nrf52840", "adafruit") + with pytest.raises(cv.Invalid, match="flash layout of bootloader 'adafruit'"): + _partition_address() + + +def test_mcumgr_slot_ends_below_partition( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The OTA image slot1 must end at or before the coredump partition.""" + generate_main(component_config_path("mcumgr_adafruit.yaml")) + # The logger's crash handler hooks are compiled in for the dump + assert any(d.name == "USE_CRASH_HANDLER" for d in CORE.defines) + sections = {s.name: s for s in zephyr_data()[KEY_PM_STATIC]} + coredump = sections["coredump_partition"] + assert coredump.address == 0xEC000 + match = re.search( + r"slot1_partition: partition@\w+ \{\s*reg = <(0x\w+) (0x\w+)>", + zephyr_data()[KEY_OVERLAY][""], + ) + assert match is not None + start, size = (int(v, 16) for v in match.groups()) + assert start + size <= coredump.address + + +def test_mcumgr_warns_that_the_slots_move( + caplog: pytest.LogCaptureFixture, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + with caplog.at_level(logging.WARNING): + generate_main(component_config_path("mcumgr_adafruit.yaml")) + assert "zephyr_coredump moves the OTA image slots" in caplog.text diff --git a/tests/components/zephyr_coredump/common.yaml b/tests/components/zephyr_coredump/common.yaml new file mode 100644 index 00000000000..e8d8a8b8954 --- /dev/null +++ b/tests/components/zephyr_coredump/common.yaml @@ -0,0 +1 @@ +zephyr_coredump: diff --git a/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml b/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-adafruit.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml b/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-mcumgr.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml b/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-nrf52840dongle.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml diff --git a/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml b/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml new file mode 100644 index 00000000000..1f0aefac131 --- /dev/null +++ b/tests/components/zephyr_coredump/test.nrf52-xiao-ble.yaml @@ -0,0 +1,2 @@ +packages: + zephyr_coredump: !include common.yaml From f72857151af118e0a05cdf1bd7c99bb09fbedcb2 Mon Sep 17 00:00:00 2001 From: functionpointer Date: Sat, 17 Oct 2026 21:28:52 +0200 Subject: [PATCH 052/179] [pylontech] Add individual cell voltage and temperature sensors (#16852) Co-authored-by: Michael Co-authored-by: ritschys <117527311+ritschys@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/pylontech/__init__.py | 32 ++- esphome/components/pylontech/pylontech.cpp | 225 +++++++++++---- esphome/components/pylontech/pylontech.h | 55 +++- .../components/pylontech/sensor/__init__.py | 48 +++- .../pylontech/sensor/pylontech_sensor.cpp | 23 ++ .../pylontech/sensor/pylontech_sensor.h | 16 +- esphome/core/defines.h | 2 + tests/components/pylontech/__init__.py | 4 +- tests/components/pylontech/common.yaml | 21 ++ tests/components/pylontech/pylontech_test.cpp | 260 +++++++++++++++++- tests/integration/test_uart_mock_pylontech.py | 2 +- 11 files changed, 630 insertions(+), 58 deletions(-) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 8bd57f252c1..e15f8f04c82 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -1,8 +1,10 @@ from dataclasses import dataclass, field import logging +from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE @@ -29,11 +31,13 @@ CV_NUM_BATTERIES = cv.int_range(1, 16) _request_listener_slot = cg.slot_counter("PYLONTECH_LISTENER_COUNT") _request_battery_slot = cg.slot_counter("PYLONTECH_BATTERY_COUNT") +_request_cell_battery_slot = cg.slot_counter("PYLONTECH_CELL_BATTERY_COUNT") @dataclass class PylontechData: batteries: set[tuple[str, int]] = field(default_factory=set) + cell_batteries: set[tuple[str, int]] = field(default_factory=set) def _get_data() -> PylontechData: @@ -42,15 +46,26 @@ def _get_data() -> PylontechData: return CORE.data[DOMAIN] -async def register_listener(config: ConfigType, listener: MockObj) -> None: - """Register a battery listener; the hub keeps one line slot per distinct battery.""" +async def register_listener( + config: ConfigType, listener: MockObj, *, cells: bool = False +) -> None: + """Register a battery listener; the hub keeps one line slot per distinct battery. + + With cells, the hub also reads that battery's cells after every pwr reply and keeps + one cell slot per distinct battery. + """ hub = await cg.get_variable(config[CONF_PYLONTECH_ID]) hub_key = str(hub) _request_listener_slot(hub_key) data = _get_data() - if (key := (hub_key, config[CONF_BATTERY])) not in data.batteries: + key = (hub_key, config[CONF_BATTERY]) + if key not in data.batteries: data.batteries.add(key) _request_battery_slot(hub_key) + if cells and key not in data.cell_batteries: + data.cell_batteries.add(key) + _request_cell_battery_slot(hub_key) + cg.add(hub.add_cell_battery(config[CONF_BATTERY])) cg.add(hub.register_listener(listener)) @@ -80,6 +95,17 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( stop_bits=1, ) +automation.register_apply_action( + "pylontech.set_cell_polling", + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(PylontechComponent), + cv.Required(CONF_ENABLED): cv.templatable(cv.boolean), + } + ), + automation.ApplyField(CONF_ENABLED, "set_cell_polling_enabled", cg.bool_), +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 5e48e8b726c..90cb4a8b855 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -1,7 +1,9 @@ #include "pylontech.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include #include #include @@ -20,13 +22,13 @@ #define PARSE_INT(field, field_name) \ { \ - get_token(token_buf); \ + get_token(cursor, token_buf); \ PARSE_TOKEN_INT(field, field_name) \ } #define PARSE_STR(field, field_name) \ { \ - get_token(field); \ + get_token(cursor, field); \ if (strlen(field) < 2) { \ ESP_LOGV(TAG, "too short " field_name " in line %s", buffer); \ this->reject_line_(); \ @@ -38,6 +40,11 @@ namespace esphome::pylontech { ESPHOME_LOG_TAG(TAG, "pylontech"); +#ifdef PYLONTECH_CELL_BATTERY_COUNT +// The pack drops a command that follows its reply without a pause +static constexpr uint32_t BAT_COMMAND_DELAY_MS = 20; +#endif + // Rejects values that would wrap in a narrowed field template static bool in_range(int value) { if constexpr (sizeof(T) >= sizeof(int)) { @@ -47,6 +54,37 @@ template static bool in_range(int value) { } } +#ifdef PYLONTECH_LISTENER_COUNT +// Copies the next whitespace separated token, at most TEXT_SENSOR_MAX_LEN - 1 characters, and moves cursor past it +static void get_token(const char *&cursor, char *token_buf) { + // Skip leading whitespace + while (*cursor == ' ' || *cursor == '\t') { + cursor++; + } + + if (*cursor == '\0') { + token_buf[0] = 0; + return; + } + + const char *start = cursor; + + // Find end of field + while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { + cursor++; + } + + size_t token_len = std::min(static_cast(cursor - start), static_cast(TEXT_SENSOR_MAX_LEN - 1)); + memcpy(token_buf, start, token_len); + token_buf[token_len] = 0; +} + +// Slots are kept in battery order, so a battery's slot index is the number of lower batteries in the mask +static uint8_t slot_index(battery_mask_t batteries, uint8_t bat_index) { + return __builtin_popcount(batteries & ((1u << bat_index) - 1u)); +} +#endif + PylontechComponent::PylontechComponent() {} void PylontechComponent::dump_config() { @@ -67,13 +105,34 @@ void PylontechComponent::setup() { } } -void PylontechComponent::update() { this->write_str("pwr\n"); } +void PylontechComponent::update() { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // A "bat N" that was not sent yet, or whose reply never finished, must not get in the way of "pwr" + this->bat_command_due_ = false; + this->late_reply_ = this->bat_reply_ != 0; + this->bat_reply_ = 0; +#endif + this->write_str("pwr\n"); +} void PylontechComponent::loop() { // Drain the UART quickly; pylontech sends large bursts. Logging and publishing wait until it is idle. size_t avail = this->available(); #ifdef PYLONTECH_LISTENER_COUNT if (avail == 0) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->bat_command_due_ && + static_cast(App.get_loop_component_start_time() - this->bat_command_at_) >= 0) { + this->bat_command_due_ = false; + if (this->cell_polling_enabled_) { + char cmd[9]; // "bat 16\n" + snprintf(cmd, sizeof(cmd), "bat %u\n", this->bat_reply_); + this->write_str(cmd); + } else { + this->bat_reply_ = 0; // turned off during the pause: nothing is coming + } + } +#endif this->dispatch_one_(); return; } @@ -120,20 +179,59 @@ void PylontechComponent::dispatch_one_() { } return; } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->cells_pending_ != 0) { + const uint8_t bat_index = __builtin_ctz(this->cells_pending_); + this->cells_pending_ &= ~(1u << bat_index); + const uint8_t slot = slot_index(this->cell_batteries_, bat_index); + ESP_LOGV(TAG, "Publishing cells of battery %u", bat_index + 1); + for (PylontechListener *listener : this->listeners_) { + if (listener->get_battery() == bat_index + 1) { + listener->on_cells_read(this->cell_slots_[slot], this->cells_read_[slot]); + } + } + return; + } +#endif if (this->rejected_ != 0) { - ESP_LOGW(TAG, "Rejected %u malformed battery lines", this->rejected_); + ESP_LOGW(TAG, "Rejected %u malformed lines", this->rejected_); this->rejected_ = 0; } } void PylontechComponent::parse_header_(const char *buffer) { this->has_tlow_id_ = strstr(buffer, "Tlow.Id") != nullptr; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // The header opens a "pwr" reply, so what follows are battery lines even after a late "bat N" reply + this->bat_reply_ = 0; + this->late_reply_ = false; + this->pwr_reply_ = true; +#endif ESP_LOGV(TAG, "header line %s Tlow.Id: %s", this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"), buffer); } // Runs while the UART drains: no logging above VERBOSE and no publishing here void PylontechComponent::process_line_(const char *buffer) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (strcmp(buffer, "$$") == 0) { + // Every reply ends with "$$"; after the "pwr" reply one "bat N" follows per battery with cell sensors. + // A "$$" that ends neither of the two, such as a late reply to an abandoned "bat N", sends nothing. + if (this->bat_reply_ != 0) { + this->cells_pending_ |= 1u << (this->bat_reply_ - 1); + this->send_next_bat_(); + } else if (this->pwr_reply_) { + this->pwr_reply_ = false; + this->send_next_bat_(); + } + return; + } + // The "pwr" header always starts a "pwr" reply, whatever was expected + if (this->bat_reply_ != 0 && strncmp(buffer, "Power", 5) != 0) { + this->parse_cell_line_(buffer); + return; + } +#endif // clang-format off // example lines to parse: // Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time B.V.St B.T.St MosTempr M.T.St @@ -147,31 +245,7 @@ void PylontechComponent::process_line_(const char *buffer) { const char *cursor = buffer; char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; - // Helper Lambda to extract tokens - auto get_token = [&](char *token_buf) -> void { - // Skip leading whitespace - while (*cursor == ' ' || *cursor == '\t') { - cursor++; - } - - if (*cursor == '\0') { - token_buf[0] = 0; - return; - } - - const char *start = cursor; - - // Find end of field - while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') { - cursor++; - } - - size_t token_len = std::min(static_cast(cursor - start), static_cast(TEXT_SENSOR_MAX_LEN - 1)); - memcpy(token_buf, start, token_len); - token_buf[token_len] = 0; - }; - - get_token(token_buf); + get_token(cursor, token_buf); auto bat = parse_number(token_buf); if (!bat.has_value()) { if (strcmp(token_buf, "Power") == 0) { @@ -182,20 +256,20 @@ void PylontechComponent::process_line_(const char *buffer) { } return; } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + if (this->late_reply_) { + ESP_LOGV(TAG, "dropped late line %s", buffer); // cell rows of the abandoned "bat N" look like battery lines + return; + } +#endif const unsigned bat_index = static_cast(*bat) - 1u; if (bat_index >= MAX_BATTERIES || (this->batteries_ & (1u << bat_index)) == 0) { return; // no listener for this battery } - const battery_mask_t bat_bit = 1u << bat_index; - // The slot index is the number of listened batteries below this one - uint8_t slot = 0; - for (battery_mask_t below = this->batteries_ & (bat_bit - 1); below != 0; below &= below - 1) { - slot++; - } - const battery_mask_t slot_bit = 1u << slot; + const battery_mask_t slot_bit = 1u << slot_index(this->batteries_, bat_index); // Cleared until the whole line parses, so a bad line never publishes a half written slot this->pending_ &= ~slot_bit; - PylontechListener::LineContents &l = this->slots_[slot]; + PylontechListener::LineContents &l = this->slots_[__builtin_ctz(slot_bit)]; l.bat_num = *bat; PARSE_INT(l.volt, "Volt"); @@ -203,40 +277,99 @@ void PylontechComponent::process_line_(const char *buffer) { PARSE_INT(l.tempr, "Tempr"); PARSE_INT(l.tlow, "Tlow"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Tlow.Id + get_token(cursor, token_buf); // Skip Tlow.Id } PARSE_INT(l.thigh, "Thigh"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Thigh.Id + get_token(cursor, token_buf); // Skip Thigh.Id } PARSE_INT(l.vlow, "Vlow"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Vlow.Id + get_token(cursor, token_buf); // Skip Vlow.Id } PARSE_INT(l.vhigh, "Vhigh"); if (this->has_tlow_id_) { - get_token(token_buf); // Skip Vhigh.Id + get_token(cursor, token_buf); // Skip Vhigh.Id } PARSE_STR(l.base_st, "Base.St"); PARSE_STR(l.volt_st, "Volt.St"); PARSE_STR(l.curr_st, "Curr.St"); PARSE_STR(l.temp_st, "Temp.St"); - get_token(token_buf); + get_token(cursor, token_buf); if (char *pct = strchr(token_buf, '%')) { *pct = '\0'; } PARSE_TOKEN_INT(l.coulomb, "Coulomb"); - get_token(token_buf); // Skip Date - get_token(token_buf); // Skip Time - get_token(token_buf); // Skip B.V.St - get_token(token_buf); // Skip B.T.St + get_token(cursor, token_buf); // Skip Date + get_token(cursor, token_buf); // Skip Time + get_token(cursor, token_buf); // Skip B.V.St + get_token(cursor, token_buf); // Skip B.T.St PARSE_INT(l.mostempr, "Mostempr"); this->pending_ |= slot_bit; } + +#ifdef PYLONTECH_CELL_BATTERY_COUNT +// Picks the next cell battery above the one just read and schedules "bat N" for it; the cycle ends when none +// is left or cell polling was turned off +void PylontechComponent::send_next_bat_() { + // Battery n is bit n - 1, so clearing the low bat_reply_ bits leaves the batteries above it + const battery_mask_t remaining = + this->cell_batteries_ & static_cast(~((1u << this->bat_reply_) - 1u)); + if (remaining == 0 || !this->cell_polling_enabled_) { + this->bat_reply_ = 0; + return; + } + const uint8_t bat_index = __builtin_ctz(remaining); + this->bat_reply_ = bat_index + 1; + // The slot is about to be rewritten, so an unpublished record must not go out half filled + this->cells_pending_ &= ~(1u << bat_index); + this->cells_read_[slot_index(this->cell_batteries_, bat_index)] = 0; + // loop() writes the command once the pause has passed and the line is idle + this->bat_command_due_ = true; + this->bat_command_at_ = App.get_loop_component_start_time() + BAT_COMMAND_DELAY_MS; +} + +// Runs while the UART drains, like process_line_ +void PylontechComponent::parse_cell_line_(const char *buffer) { + // clang-format off + // example lines to parse, the reply to "bat N": + // Battery Volt Curr Tempr Base State Volt. State Curr. State Temp. State SOC Coulomb BAL + // 0 3308 -914 18300 Dischg Normal Normal Normal 70% 34015 mAH N + // clang-format on + + const char *cursor = buffer; + char token_buf[TEXT_SENSOR_MAX_LEN] = {0}; + + get_token(cursor, token_buf); + auto cell = parse_number(token_buf); + if (!cell.has_value() || *cell < 0 || *cell >= MAX_CELLS) { + // the header, the echoed command or "Command completed successfully" + ESP_LOGV(TAG, "unknown line %s", buffer); + return; + } + PylontechListener::CellContents c{}; + PARSE_INT(c.volt, "Volt"); + get_token(cursor, token_buf); // Skip Curr + int32_t tempr; // m°C + PARSE_INT(tempr, "Tempr"); + tempr /= 10; + if (!in_range(tempr)) { + ESP_LOGV(TAG, "invalid Tempr in line %s", buffer); + this->reject_line_(); + return; + } + c.tempr = tempr; + + const uint8_t slot = slot_index(this->cell_batteries_, this->bat_reply_ - 1); + this->cell_slots_[slot][*cell] = c; + this->cells_read_[slot] |= 1u << *cell; +} +#endif #endif } // namespace esphome::pylontech +#undef PARSE_TOKEN_INT #undef PARSE_INT #undef PARSE_STR diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index aa652056dca..77403478291 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -16,6 +16,10 @@ static constexpr uint8_t TEXT_SENSOR_MAX_LEN = 14; static constexpr uint8_t MAX_BATTERIES = 16; using battery_mask_t = uint16_t; static_assert(MAX_BATTERIES <= sizeof(battery_mask_t) * 8, "battery mask too narrow"); +// A battery reports cells 0 to MAX_CELLS - 1; bit c of a cell_mask_t stands for cell c +static constexpr uint8_t MAX_CELLS = 15; +using cell_mask_t = uint16_t; +static_assert(MAX_CELLS <= sizeof(cell_mask_t) * 8, "cell mask too narrow"); class PylontechListener { public: @@ -27,12 +31,22 @@ class PylontechListener { char base_st[TEXT_SENSOR_MAX_LEN], volt_st[TEXT_SENSOR_MAX_LEN], curr_st[TEXT_SENSOR_MAX_LEN], temp_st[TEXT_SENSOR_MAX_LEN]; }; + struct CellContents { + int16_t tempr; // 0.01 °C + uint16_t volt; // mV + }; + using CellArray = std::array; explicit PylontechListener(uint8_t bat_num) : bat_num_(bat_num) {} uint8_t get_battery() const { return this->bat_num_; } /// Called only with records for this listener's battery. virtual void on_line_read(LineContents *line) = 0; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + /// Called with the cells of this listener's battery after a "bat N" reply; bit c of read is set when cell c + /// was in the reply. + virtual void on_cells_read(const CellArray &cells, cell_mask_t read) {} +#endif virtual void dump_config() = 0; protected: @@ -51,11 +65,22 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic void setup() override; void dump_config() override; + /// Turn the "bat N" cell readings after each "pwr" reply off or on; nothing to turn off without cell sensors. + void set_cell_polling_enabled(bool enabled) { +#ifdef PYLONTECH_CELL_BATTERY_COUNT + this->cell_polling_enabled_ = enabled; +#endif + } + #ifdef PYLONTECH_LISTENER_COUNT void register_listener(PylontechListener *listener) { this->listeners_.push_back(listener); this->batteries_ |= 1u << (listener->get_battery() - 1); } +#ifdef PYLONTECH_CELL_BATTERY_COUNT + /// Read the cells of this battery with "bat N" after every "pwr" reply. + void add_cell_battery(uint8_t bat_num) { this->cell_batteries_ |= 1u << (bat_num - 1); } +#endif protected: void process_line_(const char *buffer); @@ -67,17 +92,41 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic } } - // The longest line, the newer firmware header, is 182 characters - LineReader<200> line_reader_; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + void parse_cell_line_(const char *buffer); + void send_next_bat_(); +#endif + + // Members are ordered by alignment, widest first, so the small ones pack without padding StaticVector listeners_; // One slot per listened battery, in battery order; parsed while the UART drains, published one per // loop once it is idle. A newer line for a battery replaces the unpublished one. static_assert(PYLONTECH_BATTERY_COUNT <= MAX_BATTERIES, "more slots than batteries"); std::array slots_; +#ifdef PYLONTECH_CELL_BATTERY_COUNT + // One cell slot per battery with cell sensors, in battery order, filled by its "bat N" reply and published + // to that battery's listeners once the reply has ended and the UART is idle + std::array cell_slots_; + uint32_t bat_command_at_{0}; // loop time the pause before the next "bat N" ends at +#endif battery_mask_t batteries_{0}; // batteries with a listener battery_mask_t pending_{0}; // bit i set: slots_[i] is waiting to be published +#ifdef PYLONTECH_CELL_BATTERY_COUNT + std::array cells_read_; // cells the current reply has filled + battery_mask_t cell_batteries_{0}; // batteries whose cells are read + battery_mask_t cells_pending_{0}; // batteries whose cell slot is waiting to be published +#endif bool has_tlow_id_{false}; - uint8_t rejected_{0}; // malformed battery lines since the last idle pass + uint8_t rejected_{0}; // malformed lines since the last idle pass +#ifdef PYLONTECH_CELL_BATTERY_COUNT + uint8_t bat_reply_{0}; // battery whose "bat N" reply is arriving, 0 while the "pwr" reply arrives + bool pwr_reply_{false}; // a "pwr" header arrived and its "$$" has not + bool bat_command_due_{false}; // a "bat N" waits for the pause after the last reply + bool late_reply_{false}; // an abandoned "bat N" may still answer; its lines are dropped until "pwr" answers + bool cell_polling_enabled_{true}; +#endif + // The longest line, the newer firmware header, is 182 characters + LineReader<200> line_reader_; #endif }; diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 51aceace3e7..490331df70f 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -28,6 +28,13 @@ CONF_TEMPERATURE_HIGH = "temperature_high" CONF_VOLTAGE_LOW = "voltage_low" CONF_VOLTAGE_HIGH = "voltage_high" CONF_MOS_TEMPERATURE = "mos_temperature" +CONF_CELLS = "cells" +CONF_CELL = "cell" + +# A battery reports cells 0 to NUM_CELLS - 1 +NUM_CELLS = 15 + +_request_cell_slot = cg.slot_counter("PYLONTECH_SENSOR_CELL_COUNT") TYPES: dict[str, cv.Schema] = { CONF_VOLTAGE: sensor.sensor_schema( @@ -86,8 +93,34 @@ TYPES: dict[str, cv.Schema] = { ), } +CELL_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_CELL): cv.int_range(min=0, max=NUM_CELLS - 1), + cv.Optional(CONF_VOLTAGE): TYPES[CONF_VOLTAGE], + cv.Optional(CONF_TEMPERATURE): TYPES[CONF_TEMPERATURE], + } + ), + cv.has_at_least_one_key(CONF_VOLTAGE, CONF_TEMPERATURE), +) + + +def _unique_cells(cells: list[ConfigType]) -> list[ConfigType]: + seen: set[int] = set() + for cell in cells: + if (number := cell[CONF_CELL]) in seen: + raise cv.Invalid(f"Cell {number} is listed more than once") + seen.add(number) + return cells + + CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend( - {cv.GenerateID(): cv.declare_id(PylontechSensor)} + { + cv.GenerateID(): cv.declare_id(PylontechSensor), + cv.Optional(CONF_CELLS): cv.All( + cv.ensure_list(CELL_SCHEMA), cv.Length(min=1), _unique_cells + ), + } ).extend({cv.Optional(marker): schema for marker, schema in TYPES.items()}) @@ -99,4 +132,15 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(marker_config) cg.add(getattr(bat, f"set_{marker}_sensor")(sens)) - await register_listener(config, bat) + cells = config.get(CONF_CELLS, []) + for cell_config in cells: + _request_cell_slot(str(bat)) + voltage = cg.nullptr + if voltage_config := cell_config.get(CONF_VOLTAGE): + voltage = await sensor.new_sensor(voltage_config) + temperature = cg.nullptr + if temperature_config := cell_config.get(CONF_TEMPERATURE): + temperature = await sensor.new_sensor(temperature_config) + cg.add(bat.add_cell(cell_config[CONF_CELL], voltage, temperature)) + + await register_listener(config, bat, cells=bool(cells)) diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index bead0387bef..c7612f46609 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -20,6 +20,13 @@ void PylontechSensor::dump_config() { LOG_SENSOR(" ", "Voltage high", this->voltage_high_sensor_); LOG_SENSOR(" ", "Coulomb", this->coulomb_sensor_); LOG_SENSOR(" ", "MOS Temperature", this->mos_temperature_sensor_); +#ifdef PYLONTECH_SENSOR_CELL_COUNT + for (const CellSensors &c : this->cells_) { + ESP_LOGCONFIG(TAG, " Cell %u", c.cell); + LOG_SENSOR(" ", "Voltage", c.voltage); + LOG_SENSOR(" ", "Temperature", c.temperature); + } +#endif } void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { @@ -52,4 +59,20 @@ void PylontechSensor::on_line_read(PylontechListener::LineContents *line) { } } +#ifdef PYLONTECH_SENSOR_CELL_COUNT +void PylontechSensor::on_cells_read(const CellArray &cells, cell_mask_t read) { + for (const CellSensors &c : this->cells_) { + if ((read & (1u << c.cell)) == 0) { + continue; + } + if (c.voltage != nullptr) { + c.voltage->publish_state(((float) cells[c.cell].volt) / 1000.0f); + } + if (c.temperature != nullptr) { + c.temperature->publish_state(((float) cells[c.cell].tempr) / 100.0f); + } + } +} +#endif + } // namespace esphome::pylontech diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 6b466b74664..75bc9ba01df 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -17,11 +17,25 @@ class PylontechSensor final : public PylontechListener { SUB_SENSOR(temperature_high) SUB_SENSOR(voltage_low) SUB_SENSOR(voltage_high) - SUB_SENSOR(coulomb) SUB_SENSOR(mos_temperature) void on_line_read(LineContents *line) override; + +#ifdef PYLONTECH_SENSOR_CELL_COUNT + void add_cell(uint8_t cell, sensor::Sensor *voltage, sensor::Sensor *temperature) { + this->cells_.push_back({voltage, temperature, cell}); + } + void on_cells_read(const CellArray &cells, cell_mask_t read) override; + + protected: + struct CellSensors { + sensor::Sensor *voltage; + sensor::Sensor *temperature; + uint8_t cell; + }; + StaticVector cells_; +#endif }; } // namespace esphome::pylontech diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 331a637b6b3..a2480108a72 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -179,7 +179,9 @@ #define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 #define PN71XX_ON_TAG_TRIGGER_COUNT 1 #define PYLONTECH_BATTERY_COUNT 1 +#define PYLONTECH_CELL_BATTERY_COUNT 1 #define PYLONTECH_LISTENER_COUNT 2 +#define PYLONTECH_SENSOR_CELL_COUNT 2 #define REMOTE_BASE_DUMPER_COUNT 1 #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME diff --git a/tests/components/pylontech/__init__.py b/tests/components/pylontech/__init__.py index c16210128f6..15b9ef0e2a6 100644 --- a/tests/components/pylontech/__init__.py +++ b/tests/components/pylontech/__init__.py @@ -5,9 +5,11 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - # Storage is sized by code generation; the gtests register three batteries. + # Storage is sized by code generation; the gtests register three batteries and + # read the cells of two of them. cg.add_define("PYLONTECH_LISTENER_COUNT", 3) cg.add_define("PYLONTECH_BATTERY_COUNT", 3) + cg.add_define("PYLONTECH_CELL_BATTERY_COUNT", 2) cg.add_define("USE_LINE_READER") # A MULTI_CONF component gets no entry in the host build, so its to_code would never run. diff --git a/tests/components/pylontech/common.yaml b/tests/components/pylontech/common.yaml index 537450a3b68..37a1c4514b8 100644 --- a/tests/components/pylontech/common.yaml +++ b/tests/components/pylontech/common.yaml @@ -14,6 +14,15 @@ sensor: id: pyl01_soc mos_temperature: id: pyl01_mos_temperature + cells: + - cell: 0 + voltage: + id: pyl01_cell_0_voltage + temperature: + id: pyl01_cell_0_temperature + - cell: 14 + voltage: + id: pyl01_cell_14_voltage - platform: pylontech pylontech_id: pylontech1 battery: 1 @@ -40,3 +49,15 @@ text_sensor: id: pyl0_current_state temperature_state: id: pyl0_temperature_state + +button: + - platform: template + name: Pylontech cell polling + on_press: + - pylontech.set_cell_polling: + id: pylontech0 + enabled: !lambda return false; + # pylontech1 has no cells, so this does nothing + - pylontech.set_cell_polling: + id: pylontech1 + enabled: true diff --git a/tests/components/pylontech/pylontech_test.cpp b/tests/components/pylontech/pylontech_test.cpp index 479530571c0..6041bd572b1 100644 --- a/tests/components/pylontech/pylontech_test.cpp +++ b/tests/components/pylontech/pylontech_test.cpp @@ -10,6 +10,7 @@ #include #include "esphome/components/pylontech/pylontech.h" +#include "esphome/core/application.h" #include "esphome/components/uart/uart_component.h" namespace esphome::pylontech::testing { @@ -19,6 +20,7 @@ namespace { class MockUARTComponent : public uart::UARTComponent { public: std::vector rx; + std::string tx; // everything the hub wrote, in order void push_rx(std::initializer_list lines) { for (const std::string &line : lines) { @@ -26,7 +28,9 @@ class MockUARTComponent : public uart::UARTComponent { } } - void write_array(const uint8_t *data, size_t len) override {} + void write_array(const uint8_t *data, size_t len) override { + this->tx.append(reinterpret_cast(data), len); + } bool read_array(uint8_t *data, size_t len) override { if (this->rx.size() < len) { return false; @@ -48,6 +52,14 @@ class MockUARTComponent : public uart::UARTComponent { // Every record any listener gets, in publish order std::vector published; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +struct PublishedCells { + uint8_t bat_num; + cell_mask_t read; + PylontechListener::CellArray cells; +}; +// Every cell record any listener gets, in publish order +std::vector published_cells; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + class TestListener final : public PylontechListener { public: explicit TestListener(uint8_t bat_num) : PylontechListener(bat_num) {} @@ -55,6 +67,9 @@ class TestListener final : public PylontechListener { EXPECT_EQ(line->bat_num, this->bat_num_); published.push_back(*line); } + void on_cells_read(const CellArray &cells, cell_mask_t read) override { + published_cells.push_back({this->bat_num_, read, cells}); + } void dump_config() override {} }; @@ -74,8 +89,21 @@ constexpr const char *NEW_HEADER = constexpr const char *NEW_BAT1 = "1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal " "60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n"; +constexpr const char *REPLY_END = "Command completed successfully\r\n$$\r\n"; +constexpr const char *CELL_HEADER = + "Battery Volt Curr Tempr Base State Volt. State Curr. State Temp. State SOC Coulomb BAL\r\n"; // clang-format on +// A line of the "bat N" reply for cell cell with volt (mV) and tempr (m°C); the record keeps 0.01 °C +std::string cell_line(int cell, int volt, int tempr) { + char buf[160]; + snprintf(buf, sizeof(buf), + "%d %d -914 %d Dischg Normal Normal Normal 70%% " + "34015 mAH N\r\n", + cell, volt, tempr); + return buf; +} + // An older firmware line for battery bat with pack voltage volt (mV) std::string bat_line(int bat, int volt) { char buf[192]; @@ -103,6 +131,34 @@ class PylontechTest : public ::testing::Test { TestListener listeners_[3]{TestListener(1), TestListener(2), TestListener(3)}; }; +// The cells of batteries 1 and 3 are read +class PylontechCellTest : public PylontechTest { + protected: + void SetUp() override { + PylontechTest::SetUp(); + published_cells.clear(); + this->hub_.add_cell_battery(1); + this->hub_.add_cell_battery(3); + } + + // Lets the hub drain what was pushed and publish everything pending + void drain_and_publish_() { + for (int i = 0; i < 8; i++) { + this->hub_.loop(); + } + } + + // Lets the pause before a bat command pass: moves the loop clock on and runs one idle loop, which also + // publishes one pending record + void let_pause_pass_() { + this->now_ += 50; + LoopBlockingGuard{nullptr, nullptr, this->now_}; + this->hub_.loop(); + } + + uint32_t now_{1000}; +}; + } // namespace TEST_F(PylontechTest, BurstIsPublishedAfterTheDrainOnePerLoop) { @@ -249,4 +305,206 @@ TEST_F(PylontechTest, BadLineClearsTheBatteryPendingRecord) { EXPECT_EQ(this->published_()[0].bat_num, 2); } +TEST_F(PylontechCellTest, CellsAreReadAfterThePwrReplyOneBatteryAtATime) { + // Each pause's idle loop publishes one pending record: the battery record, then one battery's cells + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + EXPECT_TRUE(this->uart_.tx.empty()); // the pack needs a pause after its reply + EXPECT_TRUE(this->published_().empty()); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(this->published_().size(), 1u); + EXPECT_EQ(this->published_()[0].bat_num, 1); + + this->uart_.push_rx({"pylon>bat 1\r\n@\r\n", CELL_HEADER, cell_line(0, 3308, 18300), cell_line(1, 3310, 18400), + cell_line(14, 3299, 17900), REPLY_END}); + this->hub_.loop(); + EXPECT_TRUE(published_cells.empty()); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\n"); + ASSERT_EQ(published_cells.size(), 1u); + + this->uart_.push_rx({"pylon>bat 3\r\n@\r\n", CELL_HEADER, cell_line(5, 3250, 16000), REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\n"); + ASSERT_EQ(published_cells.size(), 2u); + this->hub_.loop(); + ASSERT_EQ(published_cells.size(), 2u); + + const PublishedCells &b1 = published_cells[0]; + EXPECT_EQ(b1.bat_num, 1); + EXPECT_EQ(b1.read, (1u << 0) | (1u << 1) | (1u << 14)); + EXPECT_EQ(b1.cells[0].volt, 3308); + EXPECT_EQ(b1.cells[0].tempr, 1830); + EXPECT_EQ(b1.cells[1].volt, 3310); + EXPECT_EQ(b1.cells[14].volt, 3299); + EXPECT_EQ(b1.cells[14].tempr, 1790); + const PublishedCells &b3 = published_cells[1]; + EXPECT_EQ(b3.bat_num, 3); + EXPECT_EQ(b3.read, 1u << 5); + EXPECT_EQ(b3.cells[5].volt, 3250); + EXPECT_EQ(b3.cells[5].tempr, 1600); + + // The next pwr reply starts the cycle again + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\nbat 1\n"); +} + +TEST_F(PylontechCellTest, CellPollingCanBeTurnedOffAtAnyReplyEnd) { + this->hub_.set_cell_polling_enabled(false); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_EQ(this->published_().size(), 1u); + + this->hub_.set_cell_polling_enabled(true); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + + // Turned off while bat 1 is answering: its cells are still published, bat 3 is not sent + this->hub_.set_cell_polling_enabled(false); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(published_cells.size(), 1u); + EXPECT_EQ(published_cells[0].bat_num, 1); + + // Turned off during the pause before bat 1: the queued command is dropped and the next pwr lines are + // battery lines + this->hub_.set_cell_polling_enabled(true); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->hub_.set_cell_polling_enabled(false); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + this->uart_.push_rx({HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(this->published_().size(), 4u); + EXPECT_EQ(this->published_()[3].bat_num, 2); +} + +TEST_F(PylontechCellTest, UpdateAbandonsAnUnansweredBatReply) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + + // No reply ever comes; the next pwr lines are battery lines again + this->hub_.update(); + this->uart_.push_rx({HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\npwr\nbat 1\n"); + ASSERT_EQ(this->published_().size(), 2u); + EXPECT_EQ(this->published_()[1].bat_num, 2); + EXPECT_TRUE(published_cells.empty()); +} + +TEST_F(PylontechCellTest, UpdateCancelsABatCommandThatWasNotSentYet) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->hub_.update(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "pwr\n"); +} + +TEST_F(PylontechCellTest, LateBatReplyAfterUpdateIsNotMistakenForThePwrReply) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + EXPECT_EQ(this->uart_.tx, "bat 1\n"); + ASSERT_EQ(this->published_().size(), 1u); + + // Battery 1's next record is still unpublished when update() abandons bat 1; the pack answers it only + // after the next pwr went out, with cell rows that look like battery lines, then answers pwr + this->uart_.push_rx({HEADER, bat_line(1, 49100), REPLY_END}); + this->hub_.loop(); + this->hub_.update(); + this->uart_.push_rx( + {CELL_HEADER, cell_line(0, 3308, 18300), cell_line(1, 3310, 18400), cell_line(2, 3312, 18500), REPLY_END}); + this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, BAT2, REPLY_END}); + this->drain_and_publish_(); + this->let_pause_pass_(); + + // The late rows are dropped without touching battery 1's record, its "$$" sends nothing, and the pwr + // reply starts the cycle once + EXPECT_EQ(this->uart_.tx, "bat 1\npwr\nbat 1\n"); + ASSERT_EQ(this->published_().size(), 3u); + EXPECT_EQ(this->published_()[1].bat_num, 1); + EXPECT_EQ(this->published_()[1].volt, 49100); + EXPECT_EQ(this->published_()[2].bat_num, 2); + EXPECT_EQ(this->published_()[2].volt, 46012); + EXPECT_TRUE(published_cells.empty()); +} + +TEST_F(PylontechCellTest, BadCellLinesAreIgnoredAndTheNextReplyStartsEmpty) { + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), "0 abc -914 18300 Dischg\r\n", + cell_line(15, 3300, 18000), cell_line(14, 70000, 18000), cell_line(13, 3300, 400000), + REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(5, 3250, 16000), cell_line(5, 3251, 16100), REPLY_END}); + this->drain_and_publish_(); + + // The bad line for cell 0 leaves the good one; cell 15 does not exist; 70000 mV and 400 °C are out of + // range; the second cell 5 line replaces the first + ASSERT_EQ(published_cells.size(), 2u); + EXPECT_EQ(published_cells[0].read, 1u << 0); + EXPECT_EQ(published_cells[0].cells[0].volt, 3308); + EXPECT_EQ(published_cells[1].bat_num, 3); + EXPECT_EQ(published_cells[1].read, 1u << 5); + EXPECT_EQ(published_cells[1].cells[5].volt, 3251); + + // A reply that read nothing publishes an empty record, not the old cells + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(published_cells.size(), 3u); + EXPECT_EQ(published_cells[2].bat_num, 1); + EXPECT_EQ(published_cells[2].read, 0u); +} + +TEST_F(PylontechCellTest, ANewReplyUnmarksTheUnpublishedRecordOfItsBattery) { + // A whole cycle arrives with only the idle loops of the pauses: the battery record and battery 1's + // cells are published, battery 3's cells stay pending + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + this->uart_.push_rx({CELL_HEADER, cell_line(5, 3250, 16000), REPLY_END}); + this->hub_.loop(); + this->uart_.push_rx({HEADER, BAT1, REPLY_END}); + this->hub_.loop(); + this->let_pause_pass_(); + ASSERT_EQ(published_cells.size(), 1u); + EXPECT_EQ(published_cells[0].bat_num, 1); + + // The next bat 1 reply is cut short by a gap: battery 3's cells go out, battery 1's wait for "$$" + this->uart_.push_rx({CELL_HEADER, cell_line(0, 3400, 19000)}); + this->hub_.loop(); + this->hub_.loop(); + this->hub_.loop(); + ASSERT_EQ(published_cells.size(), 2u); + EXPECT_EQ(published_cells[1].bat_num, 3); + this->uart_.push_rx({REPLY_END}); + this->drain_and_publish_(); + ASSERT_EQ(published_cells.size(), 3u); + EXPECT_EQ(published_cells[2].bat_num, 1); + EXPECT_EQ(published_cells[2].cells[0].volt, 3400); +} + } // namespace esphome::pylontech::testing diff --git a/tests/integration/test_uart_mock_pylontech.py b/tests/integration/test_uart_mock_pylontech.py index fffaa902072..a434ed5bdee 100644 --- a/tests/integration/test_uart_mock_pylontech.py +++ b/tests/integration/test_uart_mock_pylontech.py @@ -79,7 +79,7 @@ async def test_uart_mock_pylontech( def line_callback(line: str) -> None: if "Line over 199 chars dropped" in line: overflow_lines.append(line) - if "malformed battery lines" in line: + if "malformed lines" in line: rejected_lines.append(line) collector = SensorStateCollector( From 792d650fb2076b64ed4a0d243fbcba22497d6787 Mon Sep 17 00:00:00 2001 From: Simonas Kazlauskas Date: Sat, 17 Oct 2026 22:44:25 +0300 Subject: [PATCH 053/179] [remote_base] Add a protocol for 433MHz A-OK remotes (#20991) --- esphome/components/remote_base/__init__.py | 54 +++++++ .../components/remote_base/aok_protocol.cpp | 80 +++++++++++ esphome/components/remote_base/aok_protocol.h | 26 ++++ esphome/core/defines.h | 1 + tests/components/remote_base/__init__.py | 1 + tests/components/remote_base/aok_test.cpp | 135 ++++++++++++++++++ .../remote_receiver/common-actions.yaml | 5 + .../remote_transmitter/common-buttons.yaml | 17 +++ 8 files changed, 319 insertions(+) create mode 100644 esphome/components/remote_base/aok_protocol.cpp create mode 100644 esphome/components/remote_base/aok_protocol.h create mode 100644 tests/components/remote_base/aok_test.cpp diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 0e0a63e9d94..abb216aad7d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_BUTTON, CONF_CARRIER_FREQUENCY, CONF_CHANNEL, + CONF_CHANNELS, CONF_CHECK, CONF_CODE, CONF_COMMAND, @@ -71,6 +72,7 @@ CONF_CONTROL = "control" CONF_CONTROL_IN_KEY = "control_in_key" CONF_ROLLING_CODE = "rolling_code" CONF_EXTENSION = "extension" +CONF_REMOTE_ID = "remote_id" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -2911,3 +2913,55 @@ register_protocol_action( (CONF_MODE, "mode", IRA211ModeEnum), (CONF_FAN_MODE, "fan", IRA211FanEnum), ) + + +# A-OK +( + AOKData, + AOKBinarySensor, + AOKTrigger, + _, + AOKDumper, +) = declare_protocol("AOK") + +AOK_SCHEMA = cv.Schema( + { + cv.Required(CONF_REMOTE_ID): cv.hex_int_range(0, 0xFFFFFF), + cv.Required(CONF_CHANNELS): cv.hex_uint16_t, + cv.Required(CONF_COMMAND): cv.hex_uint8_t, + } +) + + +@register_binary_sensor("aok", AOKBinarySensor, AOK_SCHEMA) +def aok_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + AOKData, + ("remote_id", config[CONF_REMOTE_ID]), + ("channels", config[CONF_CHANNELS]), + ("command", config[CONF_COMMAND]), + ) + ) + ) + + +@register_trigger("aok", AOKTrigger, AOKData) +def aok_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("aok", AOKDumper) +def aok_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "aok", + "AOK", + AOK_SCHEMA, + (CONF_REMOTE_ID, "remote_id", cg.uint32), + (CONF_CHANNELS, "channels", cg.uint16), + (CONF_COMMAND, "command", cg.uint8), +) diff --git a/esphome/components/remote_base/aok_protocol.cpp b/esphome/components/remote_base/aok_protocol.cpp new file mode 100644 index 00000000000..e0abc1eb351 --- /dev/null +++ b/esphome/components/remote_base/aok_protocol.cpp @@ -0,0 +1,80 @@ +#include "aok_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.aok"); + +// A frame is a long start mark, then 64 bits, then a long gap. A one bit is a long mark and a short space, +// a zero bit is a short mark and a long space. The bits are: header (8), remote id (24), channels (16), +// command (8) and checksum (8), most significant bit first. +static constexpr uint8_t NBITS = 64; +static constexpr uint32_t START_HIGH_US = 5000; +static constexpr uint32_t LONG_US = 600; +static constexpr uint32_t SHORT_US = 285; +static constexpr uint8_t HEADER = 0xA3; +static constexpr uint32_t REMOTE_ID_MASK = 0xFFFFFF; + +// The sum of the six bytes between the header and the checksum +static uint8_t checksum(uint64_t frame) { + uint8_t sum = 0; + for (uint8_t shift = 8; shift < 56; shift += 8) { + sum += frame >> shift; + } + return sum; +} + +void AOKProtocol::encode(RemoteTransmitData *dst, const AOKData &data) { + uint64_t frame = (uint64_t(HEADER) << 56) | (uint64_t(data.remote_id & REMOTE_ID_MASK) << 32) | + (uint64_t(data.channels) << 16) | (uint64_t(data.command) << 8); + frame |= checksum(frame); + + dst->reserve((NBITS + 2) * 2); + dst->item(START_HIGH_US, LONG_US); + for (uint64_t mask = 1ULL << (NBITS - 1); mask; mask >>= 1) { + if (frame & mask) { + dst->item(LONG_US, SHORT_US); + } else { + dst->item(SHORT_US, LONG_US); + } + } + dst->item(LONG_US, START_HIGH_US); +} + +optional AOKProtocol::decode(RemoteReceiveData src) { + // A buffer can hold noise and several repeats of a frame, so look for the first good one. After any + // failure the loop restarts at the next timing, or where the failed frame stopped. + while (src.is_valid()) { + if (!src.expect_pulse_with_gap(START_HIGH_US, LONG_US)) { + src.advance(); + continue; + } + + uint64_t frame = 0; + uint8_t bits = 0; + for (; bits < NBITS; bits++) { + frame <<= 1; + if (src.expect_item(LONG_US, SHORT_US)) { + frame |= 1; + } else if (!src.expect_item(SHORT_US, LONG_US)) { + break; + } + } + + if (bits == NBITS && uint8_t(frame >> 56) == HEADER && uint8_t(frame) == checksum(frame)) { + return AOKData{ + .remote_id = uint32_t(frame >> 32) & REMOTE_ID_MASK, + .channels = uint16_t(frame >> 16), + .command = uint8_t(frame >> 8), + }; + } + } + return {}; +} + +void AOKProtocol::dump(const AOKData &data) { + ESP_LOGI(TAG, "Received A-OK: remote_id=0x%06" PRIX32 ", channels=0x%04X, command=0x%02X", data.remote_id, + data.channels, data.command); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aok_protocol.h b/esphome/components/remote_base/aok_protocol.h new file mode 100644 index 00000000000..19967fa40b6 --- /dev/null +++ b/esphome/components/remote_base/aok_protocol.h @@ -0,0 +1,26 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct AOKData { + uint32_t remote_id; // 24 bits + uint16_t channels; + uint8_t command; + + bool operator==(const AOKData &rhs) const { + return this->remote_id == rhs.remote_id && this->channels == rhs.channels && this->command == rhs.command; + } +}; + +class AOKProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const AOKData &data); + optional decode(RemoteReceiveData src); + void dump(const AOKData &data); +}; + +DECLARE_REMOTE_PROTOCOL(AOK) + +} // namespace esphome::remote_base diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a2480108a72..13dc3492f29 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -186,6 +186,7 @@ #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME #define USE_REMOTE_PROTOCOL_AEHA +#define USE_REMOTE_PROTOCOL_AOK #define USE_REMOTE_PROTOCOL_BEO4 #define USE_REMOTE_PROTOCOL_BRENNENSTUHL #define USE_REMOTE_PROTOCOL_BYRONSX diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index f1045572656..fed957d3378 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -12,6 +12,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "haier", "mirage", "aeha", + "aok", "keeloq", "nexus", "ira211", diff --git a/tests/components/remote_base/aok_test.cpp b/tests/components/remote_base/aok_test.cpp new file mode 100644 index 00000000000..d4fd33d0992 --- /dev/null +++ b/tests/components/remote_base/aok_test.cpp @@ -0,0 +1,135 @@ +#include + +#include "esphome/components/remote_base/aok_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint64_t FRAME = 0xA312345600A10B48ULL; +// The start mark and space, then 64 bits of a mark and a space each, then the closing mark and space +constexpr size_t TIMINGS_SIZE = (1 + 64 + 1) * 2; + +RawTimings encode(const AOKData &data) { + RemoteTransmitData tx; + AOKProtocol().encode(&tx, data); + return tx.get_data(); +} + +optional decode(const RawTimings &timings) { + return AOKProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +// Bit `n` counted from the first one sent starts at timing 2 + 2 * n +RawTimings frame_timings(uint64_t frame) { + RawTimings timings{5000, -600}; + for (int i = 63; i >= 0; i--) { + if ((frame >> i) & 1) { + timings.insert(timings.end(), {600, -285}); + } else { + timings.insert(timings.end(), {285, -600}); + } + } + timings.insert(timings.end(), {600, -5000}); + return timings; +} + +} // namespace + +TEST(AOKProtocolTest, EncodesKnownFrame) { + EXPECT_EQ(encode(AOKData{.remote_id = 0x123456, .channels = 0x00A1, .command = 0x0B}), frame_timings(FRAME)); +} + +TEST(AOKProtocolTest, DecodesKnownFrame) { + auto decoded = decode(frame_timings(FRAME)); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); + EXPECT_EQ(decoded->channels, 0x00A1); + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, RoundTrips) { + for (const AOKData &sent : + {AOKData{0x000000, 0x0000, 0x00}, AOKData{0xFFFFFF, 0xFFFF, 0xFF}, AOKData{0xABCDEF, 0x8001, 0x7E}}) { + const RawTimings timings = encode(sent); + ASSERT_EQ(timings.size(), TIMINGS_SIZE); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_TRUE(*decoded == sent); + } +} + +TEST(AOKProtocolTest, EncodingKeepsRemoteIdToItsFieldWidth) { + auto decoded = decode(encode(AOKData{.remote_id = 0xFF123456, .channels = 0x00A1, .command = 0x0B})); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); +} + +TEST(AOKProtocolTest, RejectsBadChecksum) { EXPECT_FALSE(decode(frame_timings(FRAME ^ 1)).has_value()); } + +TEST(AOKProtocolTest, RejectsBadHeader) { + // The header is not part of the checksum, so only the header check can reject this + EXPECT_FALSE(decode(frame_timings(FRAME ^ (1ULL << 56))).has_value()); +} + +TEST(AOKProtocolTest, SkipsLeadingNoise) { + RawTimings timings{300, -1200, 150, -90}; + const RawTimings frame = frame_timings(FRAME); + timings.insert(timings.end(), frame.begin(), frame.end()); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->remote_id, 0x123456u); + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, UsesRepeatWhenFirstFrameIsCorrupt) { + RawTimings timings = frame_timings(FRAME ^ 1); + const RawTimings good = frame_timings(FRAME); + timings.insert(timings.end(), good.begin(), good.end()); + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->command, 0x0B); +} + +TEST(AOKProtocolTest, UsesRepeatWhenFirstFrameIsCutShort) { + RawTimings timings = frame_timings(FRAME); + timings.resize(timings.size() - 40); + const RawTimings good = frame_timings(FRAME); + timings.insert(timings.end(), good.begin(), good.end()); + EXPECT_TRUE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsBadStart) { + RawTimings timings = frame_timings(FRAME); + timings[0] = 2000; + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsTruncatedFrame) { + RawTimings timings = frame_timings(FRAME); + timings.resize(timings.size() - 6); + EXPECT_FALSE(decode(timings).has_value()); +} + +TEST(AOKProtocolTest, RejectsBadBitTiming) { + RawTimings timings = frame_timings(FRAME); + timings[2 + 2 * 20] = 1200; + EXPECT_FALSE(decode(timings).has_value()); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index da88548d2ab..833de19ff4f 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -187,6 +187,11 @@ on_toto: - logger.log: format: "on_toto: %u %u %u" args: ["x.rc_code_1", "x.rc_code_2", "x.command"] +on_aok: + then: + - logger.log: + format: "on_aok: %u %u %u" + args: ["x.remote_id", "x.channels", "x.command"] on_hob2hood: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 66ee803d4f9..f9f1beceae9 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -395,6 +395,23 @@ button: repeat: times: 3 wait_time: 10ms + - platform: template + name: A-OK + on_press: + remote_transmitter.transmit_aok: + remote_id: 0x123456 + channels: 0x00A1 + command: 0x0B + - platform: template + name: A-OK lambda with repeat + on_press: + remote_transmitter.transmit_aok: + remote_id: 0x123456 + channels: !lambda return 0x1000; + command: 0x43 + repeat: + times: 8 + wait_time: 5ms - platform: template name: Toto with repeat on_press: From a576092f2577bc2ffb362e8754b70cd6fda03c0b Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:52:01 +0200 Subject: [PATCH 054/179] [remote_base] Send the 72-bit Jarolift TDEF group frame with transmit_keeloq (#20927) Co-authored-by: J. Nick Koston --- esphome/components/remote_base/__init__.py | 19 +++- .../remote_base/keeloq_protocol.cpp | 17 +++- .../components/remote_base/keeloq_protocol.h | 12 ++- .../config/keeloq_suffix.yaml | 8 ++ .../remote_transmitter/test_keeloq_action.py | 10 +- tests/components/remote_base/keeloq_test.cpp | 91 ++++++++++++++++++- .../remote_receiver/common-actions.yaml | 4 +- .../remote_transmitter/common-buttons.yaml | 11 +++ .../components/remote_base/test_keeloq.py | 19 ++++ 9 files changed, 181 insertions(+), 10 deletions(-) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index abb216aad7d..990d2b87e4d 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1044,6 +1044,21 @@ KEELOQ_SCHEMA = cv.Schema( ) +def _keeloq_group_alone(config: ConfigType) -> ConfigType: + # The group bits take the places of level and the suffix. + if CONF_GROUP not in config: + return config + if CONF_SUFFIX in config or CONF_SUFFIX_BITS in config: + raise cv.Invalid( + "group cannot be combined with suffix or suffix_bits", [CONF_GROUP] + ) + if config[CONF_LEVEL] is not False: + raise cv.Invalid( + "group sets the bit that level sends; remove level", [CONF_LEVEL] + ) + return config + + def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: # A lambda is only known on the device. A plain value has to fit the width. suffix = config.get(CONF_SUFFIX, 0) @@ -1060,6 +1075,7 @@ KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( # No defaults: an absent key leaves the data at 0 and emits nothing. cv.Optional(CONF_SUFFIX): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), cv.Optional(CONF_SUFFIX_BITS): cv.int_range(min=0, max=16), + cv.Optional(CONF_GROUP): cv.hex_uint8_t, } ) @@ -1097,7 +1113,8 @@ register_protocol_action( (CONF_LEVEL, "vlow", cg.bool_), (CONF_SUFFIX, "suffix", cg.uint16), (CONF_SUFFIX_BITS, "suffix_bits", cg.uint8), - validate=_keeloq_suffix_fits, + automation.ApplyField(CONF_GROUP, "data.set_group", cg.uint8), + validate=cv.All(_keeloq_group_alone, _keeloq_suffix_fits), ) diff --git a/esphome/components/remote_base/keeloq_protocol.cpp b/esphome/components/remote_base/keeloq_protocol.cpp index fff8eb897f0..b91f70227e9 100644 --- a/esphome/components/remote_base/keeloq_protocol.cpp +++ b/esphome/components/remote_base/keeloq_protocol.cpp @@ -93,9 +93,14 @@ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { dst->space(1 * BIT_TIME_US); } - // repeat flag - always sent as a '1' - dst->mark(1 * BIT_TIME_US); - dst->space(2 * BIT_TIME_US); + // repeat flag + if (data.repeat) { + dst->mark(1 * BIT_TIME_US); + dst->space(2 * BIT_TIME_US); + } else { + dst->mark(2 * BIT_TIME_US); + dst->space(1 * BIT_TIME_US); + } const uint8_t extra = data.suffix_bits > NBITS_SUFFIX_MAX ? NBITS_SUFFIX_MAX : data.suffix_bits; for (uint8_t i = 0; i < extra; i++) { @@ -234,6 +239,12 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { } void KeeloqProtocol::dump(const KeeloqData &data) { + if (data.suffix_bits == 6) { + // Jarolift TDEF frame: show the group as transmit_keeloq takes it. + ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", group=0x%02x", + data.address, data.command, data.encrypted, data.group()); + return; + } ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", suffix=0x%04x/%u", data.address, data.command, data.encrypted, data.suffix, data.suffix_bits); } diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index e391212e42d..43675eccaa6 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -9,11 +9,21 @@ struct KeeloqData { uint32_t encrypted; // 32 bit encrypted field uint32_t address; // 28 bit serial number uint8_t command; // Button Status S2-S1-S0-S3 - bool repeat; // Repeated command bit + bool repeat{true}; // Repeated command bit bool vlow; // Battery status bit uint16_t suffix{0}; // Optional bits after the 66-bit HCS301 word uint8_t suffix_bits{0}; + // Jarolift TDEF sends 8 group bits after the 64-bit word where the HCS301 has vlow and repeat. + void set_group(uint8_t group) { + this->vlow = group & 0x01; + this->repeat = group & 0x02; + this->suffix = group >> 2; + this->suffix_bits = 6; + } + // Only a Jarolift TDEF frame (suffix_bits == 6) carries a group; check suffix_bits before using it. + uint8_t group() const { return static_cast((this->suffix << 2) | (this->repeat << 1) | this->vlow); } + bool operator==(const KeeloqData &rhs) const { // 0x10 matches any command. The suffix is not compared: the binary sensor does not take it. if (address != rhs.address) { diff --git a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml index f14bc75f305..a24a50cf8b8 100644 --- a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml +++ b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml @@ -31,3 +31,11 @@ button: code: 0xd19ef0a9 suffix: !lambda return 0x2A; suffix_bits: !lambda return 6; + - platform: template + name: Group frame + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x1234500 + command: 0x8 + code: 0xF94A09F2 + group: 0x0F diff --git a/tests/component_tests/remote_transmitter/test_keeloq_action.py b/tests/component_tests/remote_transmitter/test_keeloq_action.py index cbc1efb0a69..c5137da1b4f 100644 --- a/tests/component_tests/remote_transmitter/test_keeloq_action.py +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -12,12 +12,18 @@ def test_keeloq_suffix_is_set_only_when_configured( ) -> None: main_cpp = generate_main(component_config_path("keeloq_suffix.yaml")) bodies = protocol_call_bodies(main_cpp) - assert len(bodies) == 3 - plain, constant, lambda_ = bodies + assert len(bodies) == 4 + plain, constant, lambda_, group = bodies # The plain word fills only the HCS301 fields. assert "data.suffix" not in plain + assert "set_group" not in plain assert "apply_call.data.suffix = 0x2A;" in constant assert "apply_call.data.suffix_bits = 6;" in constant # A lambda is called inline. assert "apply_call.data.suffix = static_cast(0x2A);" in lambda_ assert "apply_call.data.suffix_bits = static_cast(6);" in lambda_ + # group fills vlow, repeat and the suffix on the device. + assert "apply_call.data.set_group(0x0F);" in group + # It comes after the default level, so the group's bit 0 is what is sent. + assert group.index("data.vlow") < group.index("set_group") + assert "data.suffix" not in group diff --git a/tests/components/remote_base/keeloq_test.cpp b/tests/components/remote_base/keeloq_test.cpp index e96d8b9d01d..c2303b23d35 100644 --- a/tests/components/remote_base/keeloq_test.cpp +++ b/tests/components/remote_base/keeloq_test.cpp @@ -1,4 +1,5 @@ #include +#include #include "esphome/components/remote_base/keeloq_protocol.h" namespace esphome::remote_base::testing { @@ -42,6 +43,40 @@ RawTimings as_received(RawTimings timings, bool drop_tail = false) { return merged; } +// Jarolift TDEF group UP for the channel mask 0x0F0F, from a published test vector. The mask's low byte is +// inside the hopping code, the high byte is the group sent after the 64-bit word. +KeeloqData tdef_frame(uint8_t group) { + KeeloqData data{}; + data.encrypted = 0xF94A09F2; + data.address = 0x1234500; + data.command = 0x8; + data.set_group(group); + return data; +} + +// The data bits after the 10-bit-time header: a 1-bit-time mark is a 1, a 2-bit-time mark a 0. +std::vector sent_bits(const RawTimings &timings) { + std::vector bits; + size_t i = 0; + while (i < timings.size() && timings[i] != -static_cast(10 * BIT_US)) { + i++; + } + for (i++; i < timings.size(); i++) { + if (timings[i] > 0) { + bits.push_back(timings[i] == static_cast(BIT_US)); + } + } + return bits; +} + +uint32_t field(const std::vector &bits, size_t first, size_t count) { + uint32_t value = 0; + for (size_t i = 0; i < count; i++) { + value |= static_cast(bits[first + i]) << i; + } + return value; +} + optional decode(const RawTimings &timings) { return KeeloqProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); } @@ -146,7 +181,7 @@ TEST(KeeloqProtocolTest, ASpaceThatIsNeitherDataNorAGapDropsTheSuffix) { } TEST(KeeloqProtocolTest, ASuffixFollowsARepeatBitOfZero) { - // The encoder always sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. + // frame() sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. for (uint8_t suffix_bits : {0, 8}) { RawTimings timings = as_received(encode(frame(suffix_bits > 0 ? 0xA5 : 0, suffix_bits))); const size_t repeat = timings.size() - 2 * suffix_bits - 2; @@ -174,4 +209,58 @@ TEST(KeeloqProtocolTest, ASuffixEndsAtAGap) { } } +TEST(KeeloqProtocolTest, HcsWordStillEndsWithRepeatOne) { + // 32 code bits, 28 address bits, 4 command bits, vlow, repeat. + std::vector bits = sent_bits(encode(frame(0, 0))); + ASSERT_EQ(bits.size(), 66u); + EXPECT_FALSE(bits[64]); + EXPECT_TRUE(bits[65]); +} + +TEST(KeeloqProtocolTest, TdefGroupFrameSendsTheGroupAfterThe64BitWord) { + std::vector bits = sent_bits(encode(tdef_frame(0x0F))); + ASSERT_EQ(bits.size(), 72u); + EXPECT_EQ(field(bits, 0, 32), 0xF94A09F2u); + EXPECT_EQ(field(bits, 32, 28), 0x1234500u); + EXPECT_EQ(field(bits, 60, 4), 0x8u); + EXPECT_EQ(field(bits, 64, 8), 0x0Fu); +} + +TEST(KeeloqProtocolTest, TdefChannelOneSendsNoChannelTenBit) { + // Channels 1 to 8 have no bit in the group. Bit 65 is the HCS301 repeat bit, which is always 1 + // without a group; a Jarolift receiver reads it as channel 10. + std::vector bits = sent_bits(encode(tdef_frame(0x00))); + ASSERT_EQ(bits.size(), 72u); + EXPECT_EQ(field(bits, 64, 8), 0x00u); +} + +TEST(KeeloqProtocolTest, TdefGroupRoundTrips) { + // 0x0F is channels 9 to 12, 0x80 is channel 16, the last bit of the frame. + for (uint8_t group : {0x0F, 0x80}) { + auto decoded = decode(as_received(encode(tdef_frame(group)))); + ASSERT_TRUE(decoded.has_value()) << int(group); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x1234500u); + EXPECT_EQ(decoded->command, 0x8); + EXPECT_EQ(decoded->encrypted, 0xF94A09F2u); + EXPECT_EQ(decoded->suffix_bits, 6); + EXPECT_EQ(decoded->group(), group); + } + } +} + +TEST(KeeloqProtocolTest, AReceivedRepeatBitOfZeroIsSentAgainAsZero) { + KeeloqData received = frame(0, 0); + received.repeat = false; + std::vector bits = sent_bits(encode(received)); + ASSERT_EQ(bits.size(), 66u); + EXPECT_FALSE(bits[65]); + + auto decoded = decode(as_received(encode(received))); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_FALSE(decoded->repeat); + } +} + } // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 833de19ff4f..b37f838b811 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -71,8 +71,8 @@ on_jvc: on_keeloq: then: - logger.log: - format: "on_keeloq: %lu %lu %u %u %u" - args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits"] + format: "on_keeloq: %lu %lu %u %u %u %u" + args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits", "x.suffix_bits == 6 ? x.group() : 0"] on_haier: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index f9f1beceae9..cc3ff4d4e1a 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -385,6 +385,17 @@ button: code: 0xd19ef0a9 suffix: 0x00 suffix_bits: 8 + - platform: template + name: KeeLoq TDEF group + on_press: + remote_transmitter.transmit_keeloq: + address: 0x1234500 + command: 0x8 + code: 0xF94A09F2 + group: 0x0F + repeat: + times: 2 + wait_time: 0ms - platform: template name: KeeLoq lambda with repeat on_press: diff --git a/tests/unit_tests/components/remote_base/test_keeloq.py b/tests/unit_tests/components/remote_base/test_keeloq.py index bd0f817dd1f..57204d17d84 100644 --- a/tests/unit_tests/components/remote_base/test_keeloq.py +++ b/tests/unit_tests/components/remote_base/test_keeloq.py @@ -12,6 +12,7 @@ from esphome.components.remote_base import ( _keeloq_suffix_fits, ) import esphome.config_validation as cv +from esphome.const import CONF_GROUP from esphome.core import Lambda @@ -43,3 +44,21 @@ def test_transmit_action_checks_the_width() -> None: ) with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): schema({**base, CONF_SUFFIX: 0x100}) + + +def test_group_takes_the_place_of_level_and_suffix() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + base = {"address": 0x1234500, "code": 0xF94A09F2, CONF_GROUP: 0x0F} + assert schema(base)[CONF_GROUP] == 0x0F + with pytest.raises(cv.Invalid, match="group cannot be combined with suffix"): + schema({**base, CONF_SUFFIX_BITS: 6}) + with pytest.raises(cv.Invalid, match="group cannot be combined with suffix"): + schema({**base, CONF_SUFFIX: 0}) + with pytest.raises(cv.Invalid, match="remove level"): + schema({**base, "level": True}) + + +def test_group_is_one_byte() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + with pytest.raises(cv.Invalid): + schema({"address": 0x1, "code": 0x2, CONF_GROUP: 0x100}) From d02da3c8dede540dba7415b2d08ea90df9b9e849 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:54:55 -0500 Subject: [PATCH 055/179] [wifi] Support hidden option in single-network shorthand (#18157) Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 11 +++- tests/component_tests/wifi/test_wifi.py | 51 +++++++++++++++++++ .../validate-hidden-shorthand.esp32-idf.yaml | 5 ++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/wifi/test_wifi.py create mode 100644 tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5fceb891573..5660b1189b8 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -515,10 +515,16 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_PASSWORD in config and CONF_SSID not in config: raise cv.Invalid("Cannot have WiFi password without SSID!") + if CONF_HIDDEN in config and CONF_SSID not in config: + raise cv.Invalid( + "Cannot have 'hidden' without 'ssid'. If you are using the 'networks' " + "list, put 'hidden' inside the network entry instead." + ) + if CONF_SSID in config: # Automatically move single network to 'networks' section config = config.copy() @@ -527,6 +533,8 @@ def _validate(config): network[CONF_PASSWORD] = config.pop(CONF_PASSWORD) if CONF_EAP in config: network[CONF_EAP] = config.pop(CONF_EAP) + if CONF_HIDDEN in config: + network[CONF_HIDDEN] = config.pop(CONF_HIDDEN) if CONF_NETWORKS in config: # In testing mode, merged component tests may have both ssid and networks # Just use the networks list and ignore the single ssid @@ -643,6 +651,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_SSID): cv.sensitive(cv.ssid), cv.Optional(CONF_PASSWORD): cv.sensitive(validate_password), + cv.Optional(CONF_HIDDEN): cv.boolean, cv.Optional( CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED ): STA_MANUAL_IP_SCHEMA, diff --git a/tests/component_tests/wifi/test_wifi.py b/tests/component_tests/wifi/test_wifi.py new file mode 100644 index 00000000000..3513ee88a69 --- /dev/null +++ b/tests/component_tests/wifi/test_wifi.py @@ -0,0 +1,51 @@ +"""Config-validation tests for the wifi component.""" + +import pytest +from voluptuous import Invalid + +from esphome.components.wifi import _validate +from esphome.const import ( + CONF_HIDDEN, + CONF_NETWORKS, + CONF_PASSWORD, + CONF_SSID, + CONF_USE_ADDRESS, +) + + +def test_hidden_folded_into_networks() -> None: + """The shorthand 'hidden' option is moved into the generated networks entry.""" + config = _validate( + { + CONF_SSID: "MyHomeNetwork", + CONF_PASSWORD: "password1", + CONF_HIDDEN: True, + CONF_USE_ADDRESS: "test.local", + } + ) + assert CONF_SSID not in config + assert CONF_PASSWORD not in config + assert CONF_HIDDEN not in config + networks = config[CONF_NETWORKS] + assert len(networks) == 1 + assert networks[0][CONF_SSID] == "MyHomeNetwork" + assert networks[0][CONF_PASSWORD] == "password1" + assert networks[0][CONF_HIDDEN] is True + + +def test_shorthand_without_hidden_has_no_hidden_key() -> None: + """No 'hidden' key is set on the networks entry unless explicitly configured.""" + config = _validate( + { + CONF_SSID: "MyHomeNetwork", + CONF_PASSWORD: "password1", + CONF_USE_ADDRESS: "test.local", + } + ) + assert CONF_HIDDEN not in config[CONF_NETWORKS][0] + + +def test_hidden_without_ssid_rejected() -> None: + """A 'hidden' option without an SSID has nothing to apply to and is rejected.""" + with pytest.raises(Invalid, match="hidden"): + _validate({CONF_HIDDEN: True, CONF_USE_ADDRESS: "test.local"}) diff --git a/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml b/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml new file mode 100644 index 00000000000..8482c4e2022 --- /dev/null +++ b/tests/components/wifi/validate-hidden-shorthand.esp32-idf.yaml @@ -0,0 +1,5 @@ +# Exercises the hidden option in the single-network ssid/password shorthand. +wifi: + ssid: MyHiddenNetwork + password: password1 + hidden: true From c9ba6c355c92693e5790bdbc6d2461c094db499c Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:00:31 +0200 Subject: [PATCH 056/179] [hoermann_hcp] Add an actuator error binary sensor (#20299) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../hoermann_hcp/binary_sensor/__init__.py | 19 +++-- .../components/hoermann_hcp/hoermann_hcp.cpp | 20 +++++- .../components/hoermann_hcp/hoermann_hcp.h | 8 +++ .../hoermann_hcp_binary_sensor_test.cpp | 70 +++++++++++++++++++ tests/components/hoermann_hcp/common.yaml | 2 + 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/esphome/components/hoermann_hcp/binary_sensor/__init__.py b/esphome/components/hoermann_hcp/binary_sensor/__init__.py index 3de6a161e65..ec03e3256f6 100644 --- a/esphome/components/hoermann_hcp/binary_sensor/__init__.py +++ b/esphome/components/hoermann_hcp/binary_sensor/__init__.py @@ -1,13 +1,18 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.const import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_PROBLEM, + ENTITY_CATEGORY_DIAGNOSTIC, +) from esphome.types import ConfigType from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns DEPENDENCIES = ["hoermann_hcp"] +CONF_ACTUATOR_ERROR = "actuator_error" CONF_IS_CONNECTED = "is_connected" HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_( @@ -23,14 +28,20 @@ CONFIG_SCHEMA = cv.All( device_class=DEVICE_CLASS_CONNECTIVITY, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend(cv.COMPONENT_SCHEMA), + cv.Optional(CONF_ACTUATOR_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), } ), - cv.has_at_least_one_key(CONF_IS_CONNECTED), + cv.has_at_least_one_key(CONF_IS_CONNECTED, CONF_ACTUATOR_ERROR), ) async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) if (conf := config.get(CONF_IS_CONNECTED)) is not None: - parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) - var = await binary_sensor.new_binary_sensor(conf, parent) + var = await binary_sensor.new_binary_sensor(conf, hub) await cg.register_component(var, conf) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_ACTUATOR_ERROR, hub.set_actuator_error_binary_sensor) diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index bbf1380d178..6c573f0a047 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -187,6 +187,9 @@ void HoermannHcp::dump_config() { "Hoermann HCP bridge:\n" " Modbus server address: 0x%02X", this->get_address()); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Actuator Error", this->actuator_error_binary_sensor_); +#endif #ifdef USE_HOERMANN_HCP_IDENTITY LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_text_sensor_); log_identity_value(this->serial_number_text_sensor_); @@ -551,10 +554,25 @@ void HoermannHcp::on_state_reg_(uint16_t value) { } // Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records -// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. +// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. In the high byte, Hoermann's own bus accessory +// treats either bit of 0x30 as an actuator error; what sets them is not known. void HoermannHcp::on_light_reg_(uint16_t value) { this->set_light_seen_(true); this->set_light_on_((value & 0x0010) != 0); + const bool actuator_error = (value & 0x3000) != 0; +#ifdef USE_BINARY_SENSOR + // Unknown until first reported, then kept like the other entities here while the bus is quiet. + if (this->actuator_error_binary_sensor_ != nullptr) + this->actuator_error_binary_sensor_->publish_state(actuator_error); +#endif + if (this->actuator_error_ == actuator_error) + return; + this->actuator_error_ = actuator_error; + if (actuator_error) { + ESP_LOGW(TAG, "Motor reports an actuator error (0x%04X)", value); + } else { + ESP_LOGI(TAG, "Actuator error cleared"); + } } bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index c16a0890ef9..2fca12f11b4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -6,6 +6,9 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_HOERMANN_HCP_IDENTITY #include "esphome/components/text_sensor/text_sensor.h" #endif @@ -55,6 +58,9 @@ struct HoermannHcpCommand { }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(actuator_error) +#endif #ifdef USE_HOERMANN_HCP_IDENTITY // The motor is asked for these only when one of them is configured. SUB_TEXT_SENSOR(serial_number) @@ -186,6 +192,8 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool light_requested_{false}; bool light_command_sent_{false}; bool light_target_{false}; + // Last reported, kept across a quiet bus so a reconnect logs only a real change. + bool actuator_error_{false}; bool door_state_seen_{false}; bool short_broadcast_logged_{false}; // Only the read half right after a 0x17 write carries a command, so a second read without a new write does not. diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp index 6e9b5670805..d22d030770d 100644 --- a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -1,3 +1,5 @@ +#include + #include #include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" @@ -49,4 +51,72 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { EXPECT_EQ(publishes, 1); } +// Nothing has reported the flag yet, so the sensor stays unknown rather than claiming the motor is fine. +TEST(HoermannHcpActuatorErrorTest, UnknownUntilReported) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + connect_controller(door); + door.update(); + EXPECT_FALSE(sensor.has_state()); + // A broadcast without register 6 does not report it either. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + door.update(); + EXPECT_FALSE(sensor.has_state()); +} + +// Either bit of 0x30 in the high byte of register 6 is an error. Nothing else in the register is: 0x0200 is set on +// some installations, and the bits above 0x30 are not part of the flag. +TEST(HoermannHcpActuatorErrorTest, FollowsTheErrorBits) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + const std::pair cases[] = { + {0x0000, false}, {0x1000, true}, {0x0010, false}, {0x2000, true}, {0x0200, false}, + {0x3014, true}, {0x0214, false}, {0x4000, false}, {0x8000, false}, {0xC0FF, false}, + }; + for (const auto &[reg, error] : cases) { + door.on_write_registers(BROADCAST_REG, lamp_broadcast(reg)); + ASSERT_TRUE(sensor.has_state()) << std::hex << reg; + EXPECT_EQ(sensor.state, error) << std::hex << reg; + } +} + +// A quiet bus or a broadcast without register 6 keeps the last value, which the next report replaces. +TEST(HoermannHcpActuatorErrorTest, KeepsTheLastValueWhileNotReported) { + TestableHoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1000)); + ASSERT_TRUE(sensor.state); + door.set_valid_(false); + door.update(); + EXPECT_TRUE(sensor.has_state()); + EXPECT_TRUE(sensor.state); + + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + door.update(); + EXPECT_TRUE(sensor.state); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + EXPECT_FALSE(sensor.state); +} + +// An unchanged flag is not reported again while the lamp next to it switches. +TEST(HoermannHcpActuatorErrorTest, LampChangeDoesNotRepublish) { + HoermannHcp door; + binary_sensor::BinarySensor sensor; + door.set_actuator_error_binary_sensor(&sensor); + int publishes = 0; + sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1000)); + ASSERT_EQ(publishes, 1); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x1010)); + EXPECT_EQ(publishes, 1); +} + } // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 21d3d2b8485..e663d5d3e45 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -11,6 +11,8 @@ binary_sensor: - platform: hoermann_hcp is_connected: name: Garage Connected + actuator_error: + name: Garage Actuator Error button: - platform: hoermann_hcp From 5759c6b8e6cce4a5542c27dc320abe56f407b8b0 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:25:40 -0500 Subject: [PATCH 057/179] [deep_sleep] Honor nested wakeup_pin_mode on ESP32 single-pin configs (#16618) Co-authored-by: J. Nick Koston --- esphome/components/deep_sleep/__init__.py | 17 ++++++++-- .../deep_sleep/test_deep_sleep.py | 32 +++++++++++++++++++ ...est_deep_sleep_nested_wakeup_pin_mode.yaml | 13 ++++++++ .../deep_sleep/validate.esp32-idf.yaml | 14 ++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml create mode 100644 tests/components/deep_sleep/validate.esp32-idf.yaml diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index d60a51c3cf7..c9a0ec2196f 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -155,6 +155,12 @@ def validate_wakeup_pin( return processed_pins +_WAKEUP_PIN_MODE_BOTH_LEVELS = ( + "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " + "or under the pin entry, not both" +) + + def validate_config(config: ConfigType) -> ConfigType: wakeup_pins = config.get(CONF_WAKEUP_PIN) has_pin_mode = CONF_WAKEUP_PIN_MODE in config @@ -170,6 +176,14 @@ def validate_config(config: ConfigType) -> ConfigType: "ESP32 does not support multiple entries for 'wakeup_pin'", path=[CONF_WAKEUP_PIN], ) + if wakeup_pins and CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): + if has_pin_mode: + raise cv.Invalid( + _WAKEUP_PIN_MODE_BOTH_LEVELS, + path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], + ) + # ESP32 has one global mode, which to_code() reads from the top level + config[CONF_WAKEUP_PIN_MODE] = pin.pop(CONF_WAKEUP_PIN_MODE) elif has_pin_mode: if multiple_pins: raise cv.Invalid( @@ -178,8 +192,7 @@ def validate_config(config: ConfigType) -> ConfigType: ) if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): raise cv.Invalid( - "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " - "or under the pin entry, not both", + _WAKEUP_PIN_MODE_BOTH_LEVELS, path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], ) # Move the global mode into the single pin entry, which to_code() reads diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index e05109f246a..4671efc7b9f 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -1,5 +1,8 @@ """Tests for the deep sleep component.""" +from collections.abc import Callable +from pathlib import Path + import pytest from esphome import config_validation as cv @@ -186,3 +189,32 @@ def test_deep_sleep_bk72xx_global_wakeup_pin_mode_with_multiple_pins_rejected( with pytest.raises(cv.Invalid, match="define it per pin") as exc: deep_sleep.validate_config(config) assert exc.value.path == [deep_sleep.CONF_WAKEUP_PIN_MODE] + + +def test_deep_sleep_esp32_nested_wakeup_pin_mode( + generate_main: Callable[[str | Path], str], +) -> None: + """On ESP32, a wakeup_pin_mode nested under the pin entry is applied, not dropped.""" + main_cpp = generate_main( + "tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml" + ) + + assert "deepsleep->set_wakeup_pin(" in main_cpp + assert "deepsleep->set_wakeup_pin_mode(" in main_cpp + assert "WAKEUP_PIN_MODE_KEEP_AWAKE" in main_cpp + + +def test_deep_sleep_esp32_wakeup_pin_mode_at_both_levels_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On ESP32, wakeup_pin_mode at the top level and under the pin entry is an error.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = { + CONF_WAKEUP_PIN: [ + {"pin": "GPIO4", deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + ], + deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP", + } + with pytest.raises(cv.Invalid, match="not both") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [CONF_WAKEUP_PIN, 0, deep_sleep.CONF_WAKEUP_PIN_MODE] diff --git a/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml b/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml new file mode 100644 index 00000000000..4bab75ea40f --- /dev/null +++ b/tests/component_tests/deep_sleep/test_deep_sleep_nested_wakeup_pin_mode.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + +deep_sleep: + id: deepsleep + sleep_duration: 1min + wakeup_pin: + - pin: GPIO4 + wakeup_pin_mode: KEEP_AWAKE diff --git a/tests/components/deep_sleep/validate.esp32-idf.yaml b/tests/components/deep_sleep/validate.esp32-idf.yaml new file mode 100644 index 00000000000..3488914c074 --- /dev/null +++ b/tests/components/deep_sleep/validate.esp32-idf.yaml @@ -0,0 +1,14 @@ +substitutions: + wakeup_pin: GPIO4 + +packages: + deep_sleep: !include common.yaml + +# Nested per-pin wakeup_pin_mode on ESP32 is hoisted to the top level +deep_sleep: + id: deep_sleep_1 + run_duration: 10s + sleep_duration: 50s + wakeup_pin: + - pin: ${wakeup_pin} + wakeup_pin_mode: KEEP_AWAKE From 325c747dcd19fad67b2bf62fd74bec7e1356f82b Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:41:13 -0500 Subject: [PATCH 058/179] [sen6x] Add CO2 configuration options (#18780) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 143 +++++++++++++++++++++++------ esphome/components/sen6x/sen6x.h | 29 ++++++ esphome/components/sen6x/sensor.py | 32 +++++++ tests/components/sen6x/common.yaml | 8 ++ 4 files changed, 183 insertions(+), 29 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 5087b112a8d..dabf16d760b 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -22,6 +22,9 @@ static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400; static constexpr uint32_t FAN_CLEANING_DELAY = 10000; static constexpr uint32_t SHT_HEATER_DELAY = 20000; static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; +// Accepted range of a runtime pressure source, the same as the schema's ambient_pressure_compensation +static constexpr float AMBIENT_PRESSURE_MIN_HPA = 700.0f; +static constexpr float AMBIENT_PRESSURE_MAX_HPA = 1200.0f; static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; @@ -42,6 +45,9 @@ static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; +static constexpr uint16_t SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL = 0x6711; +static constexpr uint16_t SEN6X_CMD_AMBIENT_PRESSURE = 0x6720; +static constexpr uint16_t SEN6X_CMD_SENSOR_ALTITUDE = 0x6736; static inline void set_read_command_and_words(SEN6XComponent::Sen6xType type, uint16_t &read_cmd, uint8_t &read_words) { read_cmd = SEN6X_CMD_READ_MEASUREMENT; @@ -168,32 +174,54 @@ void SEN6XComponent::setup() { }); } -// One configuration write per invocation, spaced by CMD_EXEC_DELAY. Cases without a -// configured value fall through; each taken case must advance setup_step_index_ so the -// next invocation resumes at the following step. These writes are optional, so a failure -// only warns and the chain continues to the mandatory start-measurements write. +// One configuration write per invocation, spaced by CMD_EXEC_DELAY, then the start command. +// These writes are optional, so a failure only warns and the chain carries on. void SEN6XComponent::run_next_setup_step_() { - switch (this->setup_step_index_) { - // Tuning writes are skipped when setup() disabled the sensor for this variant - case 0: - this->setup_step_index_++; - if (this->voc_sensor_ != nullptr && this->voc_tuning_params_.has_value()) { - this->write_tuning_parameters_(SEN6X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); - break; - } - [[fallthrough]]; - case 1: - this->setup_step_index_++; - if (this->nox_sensor_ != nullptr && this->nox_tuning_params_.has_value()) { - this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); - break; - } - [[fallthrough]]; - default: - this->finish_setup_(); + while (this->setup_step_index_ < SETUP_STEP_COUNT) { + if (this->write_setup_step_(static_cast(this->setup_step_index_++))) { + this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); return; + } } - this->set_timeout(TIMEOUT_SETUP_STEP, CMD_EXEC_DELAY, [this]() { this->run_next_setup_step_(); }); + this->finish_setup_(); +} + +// Returns false when the step has nothing to write: the setting is not configured, or setup() +// disabled its sensor for this variant +bool SEN6XComponent::write_setup_step_(SetupStep step) { + switch (step) { + case SETUP_STEP_VOC_TUNING: + if (this->voc_sensor_ == nullptr || !this->voc_tuning_params_.has_value()) + return false; + this->write_tuning_parameters_(SEN6X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); + return true; + case SETUP_STEP_NOX_TUNING: + if (this->nox_sensor_ == nullptr || !this->nox_tuning_params_.has_value()) + return false; + this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); + return true; + case SETUP_STEP_CO2_ASC: + if (this->co2_sensor_ == nullptr || !this->co2_asc_.has_value()) + return false; + this->write_setup_register_(SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL, this->co2_asc_.value() ? 1 : 0); + return true; + case SETUP_STEP_ALTITUDE: + if (this->co2_sensor_ == nullptr || this->altitude_compensation_ == ALTITUDE_UNSET) + return false; + this->write_setup_register_(SEN6X_CMD_SENSOR_ALTITUDE, this->altitude_compensation_); + return true; + case SETUP_STEP_AMBIENT_PRESSURE: + if (this->co2_sensor_ == nullptr || this->ambient_pressure_ == PRESSURE_UNSET) + return false; + this->write_ambient_pressure_(this->ambient_pressure_); + return true; + default: + return false; + } +} + +bool SEN6XComponent::write_setup_register_(uint16_t i2c_command, uint16_t value) { + return this->write_config_words_(i2c_command, &value, 1); } void SEN6XComponent::finish_setup_() { @@ -208,14 +236,15 @@ void SEN6XComponent::finish_setup_() { ESP_LOGD(TAG, "Initialized"); } -// Writes one optional configuration command. A failure warns and returns false, but does -// not stop setup: the sensor still measures with that setting left at its default. +// Writes one optional configuration command and reserves its execution time. A failure warns and +// returns false, but does not stop setup: the sensor still measures with that setting left at its default. bool SEN6XComponent::write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len) { if (!this->write_command(i2c_command, data, len)) { ESP_LOGE(TAG, "Write 0x%04X failed, error %d", i2c_command, this->last_error_); this->status_set_warning(); return false; } + this->set_command_wait_(CMD_EXEC_DELAY); return true; } @@ -239,6 +268,22 @@ void SEN6XComponent::dump_config() { this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_, this->firmware_version_minor_, this->address_); LOG_UPDATE_INTERVAL(this); + // setup() clears co2_sensor_ once it detects a variant without CO2 + if (this->co2_sensor_ != nullptr) { + if (this->co2_asc_.has_value()) { + ESP_LOGCONFIG(TAG, " CO2 automatic self-calibration: %s", ONOFF(this->co2_asc_.value())); + } + if (this->altitude_compensation_ != ALTITUDE_UNSET) { + ESP_LOGCONFIG(TAG, " Altitude compensation: %u m", this->altitude_compensation_); + } + if (this->ambient_pressure_ != PRESSURE_UNSET) { + ESP_LOGCONFIG(TAG, " Ambient pressure compensation: %u hPa", this->ambient_pressure_); + } + if (this->ambient_pressure_source_ != nullptr) { + ESP_LOGCONFIG(TAG, " Ambient pressure compensation source: %s", + this->ambient_pressure_source_->get_name().c_str()); + } + } LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM 4.0", this->pm_4_0_sensor_); @@ -255,9 +300,17 @@ void SEN6XComponent::update() { if (!this->initialized_ || !this->measuring_) { return; } - - // Cancel any in-flight polling from a previous update() cycle. + // Cancel any in-flight polling from a previous update() cycle before touching the bus. this->cancel_timeout(TIMEOUT_POLL); + // A command is still executing (an action moments ago): run this cycle once its window has passed + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->update(); }); + return; + } + + if (this->ambient_pressure_source_ != nullptr && this->co2_sensor_ != nullptr) { + this->update_ambient_pressure_compensation_(this->ambient_pressure_source_->state); + } set_read_command_and_words(this->sen6x_type_, this->read_cmd_, this->read_words_); @@ -286,6 +339,11 @@ void SEN6XComponent::poll_data_ready_() { ESP_LOGD(TAG, "Data not ready"); return; } + // A set command (the pressure write above all) gets its execution time before the chain writes again + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->poll_data_ready_(); }); + return; + } ESP_LOGV(TAG, "Data ready polling attempt %u", static_cast(POLL_RETRIES - this->poll_retries_remaining_ + 1)); this->poll_retries_remaining_--; @@ -454,8 +512,35 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } -// True while the previous command's execution or settling time has not elapsed -bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; } +void SEN6XComponent::write_ambient_pressure_(uint16_t pressure_hpa) { + if (this->write_setup_register_(SEN6X_CMD_AMBIENT_PRESSURE, pressure_hpa)) + this->last_ambient_pressure_ = pressure_hpa; +} + +void SEN6XComponent::update_ambient_pressure_compensation_(float pressure_hpa) { + if (std::isnan(pressure_hpa)) + return; + // Range-check before narrowing so out-of-unit sources (e.g. Pa) can't wrap into range + if (pressure_hpa < AMBIENT_PRESSURE_MIN_HPA || pressure_hpa > AMBIENT_PRESSURE_MAX_HPA) { + if (!this->pressure_range_warned_) { + ESP_LOGW(TAG, "Ambient pressure out of range: %.0f hPa", pressure_hpa); + this->pressure_range_warned_ = true; + } + return; + } + this->pressure_range_warned_ = false; + const auto value = static_cast(lroundf(pressure_hpa)); + if (value != this->last_ambient_pressure_) + this->write_ambient_pressure_(value); +} + +// Time left in the previous command's execution or settling window, 0 once it has passed +uint32_t SEN6XComponent::command_wait_remaining_() const { + const uint32_t elapsed = millis() - this->command_started_at_; + return elapsed < this->command_wait_ ? this->command_wait_ - elapsed : 0; +} + +bool SEN6XComponent::command_blocked_() const { return this->command_wait_remaining_() != 0; } void SEN6XComponent::set_command_wait_(uint32_t wait_ms) { this->command_started_at_ = millis(); diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 588a466d714..43d0ce00120 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -20,6 +20,10 @@ struct GasTuning { uint16_t gain_factor; }; +// The CO2 settings a config may leave out: altitude is 0 to 3000 m, pressure 700 to 1200 hPa +inline constexpr uint16_t ALTITUDE_UNSET = 0xFFFF; +inline constexpr uint16_t PRESSURE_UNSET = 0; + class SEN6XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { SUB_SENSOR(pm_1_0) SUB_SENSOR(pm_2_5) @@ -58,17 +62,35 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } + void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } + void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } + void set_ambient_pressure_source(sensor::Sensor *pressure) { this->ambient_pressure_source_ = pressure; } void start_measurement(); void stop_measurement(); void start_fan_cleaning(); void activate_sht_heater(); protected: + enum SetupStep : uint8_t { + SETUP_STEP_VOC_TUNING, + SETUP_STEP_NOX_TUNING, + SETUP_STEP_CO2_ASC, + SETUP_STEP_ALTITUDE, + SETUP_STEP_AMBIENT_PRESSURE, + SETUP_STEP_COUNT, + }; + Sen6xType infer_type_from_product_name_(const std::string &product_name); void run_next_setup_step_(); + bool write_setup_step_(SetupStep step); void finish_setup_(); bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool write_setup_register_(uint16_t i2c_command, uint16_t value); + void write_ambient_pressure_(uint16_t pressure_hpa); + void update_ambient_pressure_compensation_(float pressure_hpa); + uint32_t command_wait_remaining_() const; bool command_blocked_() const; void set_command_wait_(uint32_t wait_ms); void on_measurement_started_(); @@ -80,6 +102,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string serial_number_; optional voc_tuning_params_; optional nox_tuning_params_; + sensor::Sensor *ambient_pressure_source_{nullptr}; Sen6xType sen6x_type_{UNKNOWN}; // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap uint32_t command_started_at_{0}; @@ -87,12 +110,18 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S uint32_t co2_started_at_{0}; uint32_t co2_wait_{0}; uint16_t read_cmd_{0}; + uint16_t altitude_compensation_{ALTITUDE_UNSET}; + uint16_t ambient_pressure_{PRESSURE_UNSET}; + // Last pressure written to the device, used to skip redundant writes + uint16_t last_ambient_pressure_{PRESSURE_UNSET}; + optional co2_asc_; uint8_t setup_step_index_{0}; uint8_t firmware_version_major_{0}; uint8_t firmware_version_minor_{0}; uint8_t poll_retries_remaining_{0}; uint8_t read_words_{0}; bool initialized_{false}; + bool pressure_range_warned_{false}; bool measuring_{false}; bool startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 9893ef80233..b219caafa09 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -6,6 +6,10 @@ from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, + CONF_ALTITUDE_COMPENSATION, + CONF_AMBIENT_PRESSURE_COMPENSATION, + CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE, + CONF_AUTOMATIC_SELF_CALIBRATION, CONF_CO2, CONF_FORMALDEHYDE, CONF_GAIN_FACTOR, @@ -155,6 +159,22 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, device_class=DEVICE_CLASS_CARBON_DIOXIDE, state_class=STATE_CLASS_MEASUREMENT, + ).extend( + { + cv.Optional(CONF_AUTOMATIC_SELF_CALIBRATION): cv.boolean, + cv.Optional(CONF_ALTITUDE_COMPENSATION): cv.All( + cv.float_with_unit( + "altitude", "(m|m a.s.l.|MAMSL|MASL)", optional_unit=True + ), + cv.int_range(min=0, max=3000), + ), + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.int_range( + min=700, max=1200 + ), + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id( + sensor.Sensor + ), + } ), cv.Optional(CONF_FORMALDEHYDE): sensor.sensor_schema( unit_of_measurement="ppb", @@ -209,6 +229,18 @@ async def to_code(config: ConfigType) -> None: args.append(std_initial) args.append(tuning[CONF_GAIN_FACTOR]) cg.add(getattr(var, setter)(*args)) + if co2_cfg := config.get(CONF_CO2): + if (asc := co2_cfg.get(CONF_AUTOMATIC_SELF_CALIBRATION)) is not None: + cg.add(var.set_automatic_self_calibration(asc)) + if (altitude := co2_cfg.get(CONF_ALTITUDE_COMPENSATION)) is not None: + cg.add(var.set_altitude_compensation(altitude)) + if (pressure := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION)) is not None: + cg.add(var.set_ambient_pressure_compensation(pressure)) + if ( + source := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE) + ) is not None: + sens = await cg.get_variable(source) + cg.add(var.set_ambient_pressure_source(sens)) SEN6X_ACTION_SCHEMA = maybe_simple_id( diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 6ab80a76b21..eed0dd51d67 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -1,4 +1,8 @@ sensor: + - platform: template + id: pressure_source + name: Pressure Source + unit_of_measurement: hPa # Test with explicit type parameter - platform: sen6x id: sen6x_sensor @@ -45,6 +49,10 @@ sensor: gain_factor: 180 co2: name: Carbon Dioxide + automatic_self_calibration: true + altitude_compensation: 100 + ambient_pressure_compensation: 1013 + ambient_pressure_compensation_source: pressure_source formaldehyde: name: Formaldehyde address: 0x6B From ae6296019303e7492a0f584cf5c011241e62cf2e Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 17 Oct 2026 15:51:07 -0500 Subject: [PATCH 059/179] [epaper_spi] Add the UC8279 controller and the Xteink X4 Pro (#20545) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/display.py | 14 +- esphome/components/epaper_spi/epaper_spi.cpp | 9 +- esphome/components/epaper_spi/epaper_spi.h | 8 + .../epaper_spi/epaper_spi_uc8179.cpp | 37 +-- .../components/epaper_spi/epaper_spi_uc8179.h | 10 +- .../epaper_spi/epaper_spi_uc8279.cpp | 144 +++++++++++ .../components/epaper_spi/epaper_spi_uc8279.h | 46 ++++ .../epaper_spi/epaper_spi_ultrachip.cpp | 33 +++ .../epaper_spi/epaper_spi_ultrachip.h | 43 ++++ .../components/epaper_spi/models/__init__.py | 14 +- .../components/epaper_spi/models/uc8179.py | 10 +- .../components/epaper_spi/models/uc8279.py | 74 ++++++ .../epaper_spi/config/uc8279_x4pro_test.yaml | 15 ++ tests/component_tests/epaper_spi/test_init.py | 141 ++++++++++- .../display/test_uc8279_transfer.cpp | 237 ++++++++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 24 ++ 16 files changed, 811 insertions(+), 48 deletions(-) create mode 100644 esphome/components/epaper_spi/epaper_spi_uc8279.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_uc8279.h create mode 100644 esphome/components/epaper_spi/epaper_spi_ultrachip.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_ultrachip.h create mode 100644 esphome/components/epaper_spi/models/uc8279.py create mode 100644 tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml create mode 100644 tests/components/epaper_spi/display/test_uc8279_transfer.cpp diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index d04ffd33be8..6a391811112 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -30,6 +30,7 @@ from esphome.const import ( CONF_MIRROR_X, CONF_MIRROR_Y, CONF_MODEL, + CONF_OFFSET_HEIGHT, CONF_PAGES, CONF_RESET_DURATION, CONF_RESET_PIN, @@ -73,12 +74,17 @@ for module_info in pkgutil.iter_modules(models.__path__): MODELS = models.EpaperModel.models -DIMENSION_SCHEMA = cv.Schema( - { + +def dimension_schema(model: models.EpaperModel) -> cv.Schema: + schema = { cv.Required(CONF_WIDTH): cv.int_, cv.Required(CONF_HEIGHT): cv.int_, } -) + # Only models with an offset_height default use it, and the default must survive explicit dimensions + if (offset_height := model.get_default(CONF_OFFSET_HEIGHT, None)) is not None: + schema[cv.Optional(CONF_OFFSET_HEIGHT, default=offset_height)] = cv.positive_int + return cv.Schema(schema) + TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} @@ -135,7 +141,7 @@ def model_schema(config): model.option(CONF_RESET_PIN): pins.gpio_output_pin_schema, cv.GenerateID(): cv.declare_id(class_name), cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8), - cv_dimensions(CONF_DIMENSIONS): DIMENSION_SCHEMA, + cv_dimensions(CONF_DIMENSIONS): dimension_schema(model), model.option(CONF_ENABLE_PIN): cv.ensure_list(pins.gpio_output_pin_schema), model.option(CONF_INIT_SEQUENCE, cv.UNDEFINED): cv.ensure_list( map_sequence diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index e95d2fd2869..71e87e038b6 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -227,10 +227,12 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::IDLE); return; } + // A full update resets update_count_ to zero, so a non-zero count marks a partial one + this->partial_ = this->update_count_ != 0; this->set_state_(EPaperState::RESET); break; case EPaperState::INITIALISE: - if (!this->initialise(this->update_count_ != 0)) { + if (!this->initialise(this->partial_)) { return; // Not done yet, come back next loop } this->set_state_(EPaperState::TRANSFER_DATA); @@ -250,11 +252,14 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::REFRESH_SCREEN); break; case EPaperState::REFRESH_SCREEN: - this->refresh_screen(this->update_count_ != 0); + this->refresh_screen(this->partial_); this->update_count_ = (this->update_count_ + 1) % this->full_update_every_; this->set_state_(EPaperState::POWER_OFF); break; case EPaperState::POWER_OFF: + if (!this->after_refresh()) { + return; // Not done yet, come back next loop + } this->power_off(); this->set_state_(EPaperState::DEEP_SLEEP); break; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 046df97cccd..d953bf3df81 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -167,6 +167,13 @@ class EPaperBase : public Display, */ virtual void power_off() = 0; + /** + * Called after the refresh, before power off, repeatedly until it returns true. + * Lets a driver do extra work spread over several loops, such as rewriting the previous image. + * @return true when done, false to be called again next loop + */ + virtual bool after_refresh() { return true; } + /** * Place the display into deep sleep */ @@ -201,6 +208,7 @@ class EPaperBase : public Display, uint8_t effective_transform_{}; uint8_t update_count_{}; bool full_update_requested_{}; + bool partial_{}; // the update in progress is a partial one; set when UPDATE moves to RESET // these values represent the bounds of the updated buffer. Note that x_high and y_high // point to the pixel past the last one updated, i.e. may range up to width/height. uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp index 328cd833299..60cc80e91bd 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp @@ -10,12 +10,11 @@ ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8179"); bool EPaperUC8179::initialise(bool partial) { EPaperBase::initialise(partial); // send the model init sequence - this->partial_ = partial; ESP_LOGV(TAG, "Power on"); // POWER ON must precede the waveform/mode registers and the data transfer // (the original driver powers on and busy-waits before writing them). // The state machine busy-waits before entering TRANSFER_DATA. - this->command(0x04); + this->command(CMD_POWER_ON); // Give the busy line time to assert before the state machine polls it this->next_delay_ = 100; return true; @@ -27,21 +26,17 @@ void EPaperUC8179::set_refresh_mode_() { return; // plain full refresh uses the mode set by the init sequence } // Fast and partial refresh use flipped data polarity and a floating border - this->cmd_data(0x50, {0xA9, 0x07}); + this->cmd_data(CMD_CDI, {0xA9, 0x07}); // Force the waveform via the temperature registers: 0x5A selects the fast // full-refresh waveform, 0x6E the partial-refresh waveform - this->cmd_data(0xE0, {0x02}); + this->cmd_data(CMD_CCSET, {0x02}); if (this->partial_) { - this->cmd_data(0xE5, {0x6E}); - this->command(0x91); // enter partial mode - // Set the partial window to the full screen - const uint16_t x_end = this->width_ - 1; - const uint16_t y_end = this->height_ - 1; - this->cmd_data(0x90, {0x00, 0x00, static_cast(x_end >> 8), static_cast(x_end & 0xFF), 0x00, 0x00, - static_cast(y_end >> 8), static_cast(y_end & 0xFF), 0x01}); + this->cmd_data(CMD_TSSET, {0x6E}); + // Partial mode, with the window set to the full screen + this->set_partial_window_(0, this->width_ - 1, 0, this->height_ - 1); } else { - this->cmd_data(0xE5, {0x5A}); - this->command(0x92); // exit partial mode + this->cmd_data(CMD_TSSET, {0x5A}); + this->command(CMD_PARTIAL_OUT); // exit partial mode } } @@ -63,7 +58,7 @@ bool HOT EPaperUC8179::transfer_data() { // Phase 1 (fast full refresh only): previous image via 0x10 (DTM1), inverse of the new image if (two_pass && this->current_data_index_ < buffer_length) { if (this->current_data_index_ == 0) { - this->command(0x10); // DATA START TRANSMISSION 1 (previous image) + this->command(CMD_DTM1); // DATA START TRANSMISSION 1 (previous image) } this->start_data_(); while (this->current_data_index_ < buffer_length) { @@ -86,7 +81,7 @@ bool HOT EPaperUC8179::transfer_data() { const size_t total = offset + buffer_length; if (this->current_data_index_ < total) { if (this->current_data_index_ == offset) { - this->command(0x13); // DATA START TRANSMISSION 2 (new image) + this->command(CMD_DTM2); // DATA START TRANSMISSION 2 (new image) } this->start_data_(); while (this->current_data_index_ < total) { @@ -117,7 +112,7 @@ void EPaperUC8179::power_on() { void EPaperUC8179::refresh_screen(bool /*partial*/) { ESP_LOGV(TAG, "Refresh"); - this->command(0x12); // DISPLAY REFRESH + this->command(CMD_REFRESH); // DISPLAY REFRESH // Delay the next busy poll: the busy line takes a short time to assert after // the refresh command, and polling too early would read it as already idle this->next_delay_ = 100; @@ -125,15 +120,7 @@ void EPaperUC8179::refresh_screen(bool /*partial*/) { void EPaperUC8179::power_off() { ESP_LOGV(TAG, "Power off"); - this->command(0x02); // POWER OFF -} - -void EPaperUC8179::deep_sleep() { - // Deep sleep loses the previous-image RAM that partial refresh compares against - if (!this->is_using_partial_update_()) { - ESP_LOGV(TAG, "Deep sleep"); - this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code - } + this->command(CMD_POWER_OFF); // POWER OFF } } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index fda4297802b..5d85c6bd04a 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -1,6 +1,6 @@ #pragma once -#include "epaper_spi.h" +#include "epaper_spi_ultrachip.h" namespace esphome::epaper_spi { @@ -28,11 +28,11 @@ namespace esphome::epaper_spi { * waveform is forced, partial mode is entered with a full-screen window and * only DTM2 is sent; the controller compares against its previous-image RAM. */ -class EPaperUC8179 final : public EPaperBase { +class EPaperUC8179 final : public EPaperUltraChip { public: EPaperUC8179(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {} + : EPaperUltraChip(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {} protected: bool initialise(bool partial) override; @@ -40,11 +40,7 @@ class EPaperUC8179 final : public EPaperBase { void refresh_screen(bool partial) override; void power_on() override; void power_off() override; - void deep_sleep() override; void set_refresh_mode_(); - - // Set by initialise() so transfer_data() knows which planes to send - bool partial_{}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8279.cpp b/esphome/components/epaper_spi/epaper_spi_uc8279.cpp new file mode 100644 index 00000000000..096b2e54ae7 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8279.cpp @@ -0,0 +1,144 @@ +#include "epaper_spi_uc8279.h" + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8279"); + +static constexpr uint8_t CMD_PSR = 0x00; +static constexpr uint8_t CMD_PFS = 0x03; +static constexpr uint8_t CMD_GATE_SCAN = 0xE1; + +static constexpr uint8_t PSR0_OTP_LUT = 0x17; +static constexpr uint8_t PSR1 = 0x4D; +static constexpr uint8_t PFS = 0x20; +static constexpr uint8_t GATE_SCAN = 0x02; +static constexpr uint8_t CCSET = 0x02; +static constexpr uint8_t TSSET_FULL = 0x1E; +static constexpr uint8_t TSSET_PARTIAL = 0x5A; +static constexpr uint8_t CDI_FULL = 0x97; +static constexpr uint8_t CDI_PARTIAL = 0xD7; + +static constexpr uint16_t RESET_SETTLE_MS = 50; +static constexpr uint16_t BUSY_ASSERT_MS = 10; +static constexpr uint16_t REFRESH_START_MS = 50; + +bool EPaperUC8279::reset() { + // A partial update follows one that left the controller configured, in standby, with DTM1 holding the image + if (this->partial_) + return true; + if (!EPaperBase::reset()) + return false; + this->next_delay_ = RESET_SETTLE_MS; + return true; +} + +bool EPaperUC8279::initialise(bool partial) { + if (partial) + return true; + return EPaperBase::initialise(partial); +} + +void EPaperUC8279::write_gate_(size_t gate, bool white) { + const size_t row_width = this->row_width_; + if (white || gate < this->offset_height_) { + uint8_t white_bytes[MAX_TRANSFER_SIZE]; + memset(white_bytes, 0xFF, sizeof(white_bytes)); + for (size_t written = 0; written != row_width;) { + const size_t length = std::min(row_width - written, MAX_TRANSFER_SIZE); + this->write_array(white_bytes, length); + written += length; + } + return; + } + size_t index = (gate - this->offset_height_) * row_width; + for (size_t remaining = row_width; remaining != 0;) { + size_t length; + const uint8_t *data = this->buffer_.get_span(index, length); + length = std::min(length, remaining); + this->write_array(data, length); + index += length; + remaining -= length; + } +} + +// One plane of one gate's row per step, time-sliced; `base` is the value of current_data_index_ at its first gate +bool HOT EPaperUC8279::write_plane_(uint8_t command, bool white, size_t base) { + const uint32_t start_time = millis(); + const size_t gates = this->gate_count_(); + if (this->current_data_index_ == base) + this->command(command); + this->start_data_(); + while (this->current_data_index_ - base != gates) { + this->write_gate_(this->current_data_index_ - base, white); + ++this->current_data_index_; + if (this->current_data_index_ - base != gates && millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + return true; +} + +// DTM2 gets the new image; a full refresh also writes a white DTM1, so every pixel transitions +bool HOT EPaperUC8279::transfer_data() { + const size_t gates = this->gate_count_(); + if (this->current_data_index_ < gates) { + if (!this->write_plane_(CMD_DTM2, false, 0)) + return false; + // Start DTM1 on the next pass, so this one stays within its time slice + if (!this->partial_) + return false; + } + if (!this->partial_ && !this->write_plane_(CMD_DTM1, true, gates)) + return false; + this->current_data_index_ = 0; + return true; +} + +void EPaperUC8279::power_on() { + ESP_LOGV(TAG, "Power on"); + this->cmd_data(CMD_CDI, {this->partial_ ? CDI_PARTIAL : CDI_FULL}); + this->cmd_data(CMD_CCSET, {CCSET}); + this->cmd_data(CMD_TSSET, {this->partial_ ? TSSET_PARTIAL : TSSET_FULL}); + if (this->partial_) { + this->cmd_data(CMD_PFS, {PFS}); + this->cmd_data(CMD_GATE_SCAN, {GATE_SCAN}); + } + this->command(CMD_POWER_ON); + this->next_delay_ = BUSY_ASSERT_MS; +} + +void EPaperUC8279::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen (%s)", partial ? LOG_STR_LITERAL("partial") : LOG_STR_LITERAL("full")); + if (partial) + this->set_partial_window_(0, this->width_ - 1, this->offset_height_, this->gate_count_() - 1); + // Power-on reloads the panel's stored settings, so the panel setting only takes when written after it + this->cmd_data(CMD_PSR, {PSR0_OTP_LUT, PSR1}); + this->command(CMD_REFRESH); + this->next_delay_ = REFRESH_START_MS; +} + +bool HOT EPaperUC8279::after_refresh() { + // While partial updates are in use, DTM1 holds the image on the panel for the next partial comparison + if (!this->is_using_partial_update_()) + return true; + if (this->current_data_index_ == 0 && this->partial_) + this->command(CMD_PARTIAL_OUT); + if (!this->write_plane_(CMD_DTM1, false, 0)) + return false; + this->current_data_index_ = 0; + return true; +} + +void EPaperUC8279::power_off() { + this->command(CMD_POWER_OFF); + this->next_delay_ = BUSY_ASSERT_MS; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8279.h b/esphome/components/epaper_spi/epaper_spi_uc8279.h new file mode 100644 index 00000000000..fb430855516 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8279.h @@ -0,0 +1,46 @@ +#pragma once + +#include "epaper_spi_ultrachip.h" + +namespace esphome::epaper_spi { + +/** + * Monochrome e-paper displays using the UltraChip UC8279 controller, such as the Xteink X4 Pro. + * + * Buffer layout: 1 bit per pixel, 1=white, 0=black (the base class default). + * + * The controller scans offset_height + height gates; the panel is bonded to the last height of them, + * so every image plane is written for all gates, white outside the panel. + * + * A full refresh writes the new image to DTM2 and white to DTM1. A partial refresh writes only DTM2 + * and drives the pixels that differ from DTM1, which therefore has to hold the image on the panel: + * while partial updates are in use it is rewritten after every refresh, in time slices like the + * image transfer. The panel loses both planes in its own deep sleep, so it then waits in standby + * between updates instead. + */ +class EPaperUC8279 : public EPaperUltraChip { + public: + EPaperUC8279(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length, uint16_t offset_height) + : EPaperUltraChip(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY), + offset_height_(offset_height) {} + + protected: + bool reset() override; + bool initialise(bool partial) override; + bool transfer_data() override; + void power_on() override; + void refresh_screen(bool partial) override; + void power_off() override; + bool after_refresh() override; + + uint16_t gate_count_() const { return this->offset_height_ + this->height_; } + // Writes one gate's row of the image, or white for a gate outside the panel or when white is true + void write_gate_(size_t gate, bool white); + // Writes one plane in time slices, sending `command` at its first gate; true once all gates are written + bool write_plane_(uint8_t command, bool white, size_t base); + + uint16_t offset_height_; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp b/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp new file mode 100644 index 00000000000..d34e022380e --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ultrachip.cpp @@ -0,0 +1,33 @@ +#include "epaper_spi_ultrachip.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +ESPHOME_LOG_TAG(TAG, "epaper_spi.ultrachip"); + +static constexpr uint8_t hi(uint16_t v) { return static_cast(v >> 8); } +static constexpr uint8_t lo(uint16_t v) { return static_cast(v & 0xFF); } + +void EPaperUltraChip::deep_sleep() { + // Partial updates compare against the previous image, which deep sleep would lose; standby keeps it + if (this->is_using_partial_update_()) + return; + this->send_deep_sleep_(); +} + +void EPaperUltraChip::send_deep_sleep_() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(CMD_DEEP_SLEEP, {DEEP_SLEEP_CHECK}); +} + +void EPaperUltraChip::on_safe_shutdown() { this->send_deep_sleep_(); } + +void EPaperUltraChip::set_partial_window_(uint16_t x_start, uint16_t x_end, uint16_t y_start, uint16_t y_end) { + this->command(CMD_PARTIAL_IN); + this->cmd_data(CMD_PARTIAL_WINDOW, + {hi(x_start), static_cast(lo(x_start) & 0xF8), hi(x_end), + static_cast(lo(x_end) | 0x07), hi(y_start), lo(y_start), hi(y_end), lo(y_end), 0x01}); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ultrachip.h b/esphome/components/epaper_spi/epaper_spi_ultrachip.h new file mode 100644 index 00000000000..4dfd732fa24 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ultrachip.h @@ -0,0 +1,43 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Shared base for the UltraChip UC81xx mono controllers (UC8179, UC8279). + * + * They use the same command set and the same deep-sleep and partial-window handling; the + * concrete drivers differ only in how each refresh is configured and which planes are sent. + */ +class EPaperUltraChip : public EPaperBase { + public: + using EPaperBase::EPaperBase; + + // Nothing carries the image across a restart or the controller's deep sleep, so sleep the panel rather than + // leaving it waiting in standby + void on_safe_shutdown() override; + + protected: + static constexpr uint8_t CMD_POWER_OFF = 0x02; + static constexpr uint8_t CMD_POWER_ON = 0x04; + static constexpr uint8_t CMD_DEEP_SLEEP = 0x07; + static constexpr uint8_t CMD_DTM1 = 0x10; + static constexpr uint8_t CMD_REFRESH = 0x12; + static constexpr uint8_t CMD_DTM2 = 0x13; + static constexpr uint8_t CMD_CDI = 0x50; + static constexpr uint8_t CMD_PARTIAL_WINDOW = 0x90; + static constexpr uint8_t CMD_PARTIAL_IN = 0x91; + static constexpr uint8_t CMD_PARTIAL_OUT = 0x92; + static constexpr uint8_t CMD_CCSET = 0xE0; + static constexpr uint8_t CMD_TSSET = 0xE5; + static constexpr uint8_t DEEP_SLEEP_CHECK = 0xA5; + + void deep_sleep() override; + // Sends the deep-sleep command unconditionally; deep_sleep() skips it while partial updates are in use + void send_deep_sleep_(); + // Enters partial mode and sets the refresh window; x is rounded out to whole bytes + void set_partial_window_(uint16_t x_start, uint16_t x_end, uint16_t y_start, uint16_t y_end); +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index fbedd6371d1..ba8ebb6c873 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -1,12 +1,17 @@ from typing import Any, Self import esphome.config_validation as cv -from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_OFFSET_HEIGHT, CONF_WIDTH from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType +def ultrachip_resolution(width: int, gates: int) -> tuple: + """The UC81xx resolution command (0x61): source (width) and gate counts, each big-endian.""" + return (0x61, width >> 8, width & 0xFF, gates >> 8, gates & 0xFF) + + class EpaperModel: models: dict[str, Self] = {} @@ -86,6 +91,13 @@ class EpaperModel: height = self.get_default(CONF_HEIGHT) return width, height + def get_offset_height(self, config) -> int: + # The panel's first row within the controller's addressable area; from explicit dimensions or the model default + dimensions = config.get(CONF_DIMENSIONS, {}) + return dimensions.get( + CONF_OFFSET_HEIGHT, self.get_default(CONF_OFFSET_HEIGHT, 0) + ) + def extend(self, name, **kwargs) -> "EpaperModel": """ Extend the current model with additional parameters or a modified init sequence. diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py index 91c649808a8..84b736081fb 100644 --- a/esphome/components/epaper_spi/models/uc8179.py +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -18,7 +18,7 @@ from typing import Any from esphome.const import CONF_DATA_RATE -from . import EpaperModel +from . import EpaperModel, ultrachip_resolution class UC8179(EpaperModel): @@ -51,13 +51,7 @@ class UC8179(EpaperModel): # PANEL SETTING (black/white mode, LUT from OTP) (0x00, 0x1F), # RESOLUTION SETTING (width x height) - ( - 0x61, - (width >> 8) & 0xFF, - width & 0xFF, - (height >> 8) & 0xFF, - height & 0xFF, - ), + ultrachip_resolution(width, height), # DUAL SPI MODE (disabled) (0x15, 0x00), # VCOM AND DATA INTERVAL SETTING diff --git a/esphome/components/epaper_spi/models/uc8279.py b/esphome/components/epaper_spi/models/uc8279.py new file mode 100644 index 00000000000..deb487883de --- /dev/null +++ b/esphome/components/epaper_spi/models/uc8279.py @@ -0,0 +1,74 @@ +"""Monochrome e-paper displays using the UltraChip UC8279 controller. + +Supported models: +- uc8279: the controller on its own; dimensions and pins must be configured +- xteink-x4pro-uc8279: the Xteink X4 Pro e-reader (ESP32-S3) with its 800x480 UC8279 panel; + the X4 Pro also ships with other panel controllers + +The controller scans offset_height + height gates, and the panel is bonded to the last +height of them; offset_height (under dimensions) is a property of the panel. +""" + +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DATA_RATE, CONF_DIMENSIONS, CONF_OFFSET_HEIGHT +from esphome.types import ConfigType + +from . import EpaperModel, ultrachip_resolution + + +class UC8279(EpaperModel): + def __init__( + self, + name: str, + class_name: str = "EPaperUC8279", + data_rate: str = "10MHz", + **defaults: Any, + ) -> None: + defaults.setdefault(CONF_DATA_RATE, data_rate) + defaults.setdefault("partial_update", True) + defaults.setdefault(CONF_OFFSET_HEIGHT, 0) + defaults.setdefault("width_multiple", 8) + super().__init__(name, class_name=class_name, **defaults) + + def validate_config(self, config: ConfigType) -> ConfigType: + _width, height = self.get_dimensions(config) + if self.get_offset_height(config) + height > 0xFFFF: + raise cv.Invalid( + f"{CONF_OFFSET_HEIGHT} plus the panel height must not exceed {0xFFFF} gates", + path=[CONF_DIMENSIONS, CONF_OFFSET_HEIGHT], + ) + return config + + def get_constructor_args(self, config) -> tuple: + return (self.get_offset_height(config),) + + # fmt: off + def get_init_sequence(self, config: dict) -> tuple: + width, height = self.get_dimensions(config) + gates = self.get_offset_height(config) + height + return ( + (0x00, 0x37, 0x4D), # panel setting; the driver switches it to the OTP LUT before each refresh + ultrachip_resolution(width, gates), # resolution: sources, gates + (0x65, 0x00, 0x00, 0x00, 0x00), # gate and source start + (0x03, 0x20), # power off sequence + (0x30, 0x0E), # PLL + (0xE1, 0x02), # gate scan + ) + # fmt: on + + +uc8279 = UC8279("uc8279") + +uc8279.extend( + "xteink-x4pro-uc8279", + width=800, + height=480, + offset_height=120, + cs_pin=13, + dc_pin=18, + reset_pin=14, + busy_pin={"number": 6, "inverted": True}, + reset_duration="50ms", +) diff --git a/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml b/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml new file mode 100644 index 00000000000..34b55ab4456 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/uc8279_x4pro_test.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + +spi: + clk_pin: GPIO12 + mosi_pin: GPIO11 + +display: + - platform: epaper_spi + id: epaper_display + model: xteink-x4pro-uc8279 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 81e197abf81..644a505bf82 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -13,7 +13,12 @@ from esphome.components.epaper_spi.display import ( FINAL_VALIDATE_SCHEMA, MODELS, ) -from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, @@ -516,6 +521,140 @@ def test_uc8179_e1001_code_generation( assert "97, 4, 3, 32, 1, 224" in main_cpp +def test_uc8279_width_must_be_a_multiple_of_8( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The UC8279 takes whole bytes of sources, so the width is a multiple of 8.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="multiple of 8"): + run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 804, "height": 480}, + } + ) + + +def test_uc8279_x4pro_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The Xteink X4 Pro generates the UC8279 driver with the panel's gate offset and gate count.""" + main_cpp = generate_main(component_config_path("uc8279_x4pro_test.yaml")) + + assert "epaper_spi::EPaperUC8279" in main_cpp + # 800 sources, 480 rows, and the panel on the last 480 of 600 gates + assert re.search( + r'"XTEINK-X4PRO-UC8279",\s*800,\s*480,.*?,\s*120\)', main_cpp, re.DOTALL + ) + resolution = next( + cmd + for cmd in MODELS["XTEINK-X4PRO-UC8279"].get_init_sequence({}) + if cmd[0] == 0x61 + ) + assert resolution == (0x61, 0x03, 0x20, 0x02, 0x58) + + +def test_uc8279_offset_height_configurable( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The generic uc8279 model takes offset_height under dimensions, reaching the constructor and gate count.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + config = run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 16, "height": 2, "offset_height": 8}, + } + ) + model = MODELS["UC8279"] + assert model.get_constructor_args(config) == (8,) + resolution = next(cmd for cmd in model.get_init_sequence(config) if cmd[0] == 0x61) + # 16 sources, and 8 + 2 = 10 gates + assert resolution == (0x61, 0x00, 0x10, 0x00, 0x0A) + + +def test_uc8279_offset_height_plus_height_must_fit_16_bits( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """offset_height plus the panel height is the gate count, which the controller holds in 16 bits.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="must not exceed"): + run_schema_validation( + { + "id": "test_display", + "model": "uc8279", + "dc_pin": 21, + "dimensions": {"width": 16, "height": 2, "offset_height": 0xFFFF}, + } + ) + + +def test_uc8279_x4pro_explicit_dimensions_keep_the_model_offset( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """Writing out the X4 Pro's dimensions without offset_height keeps the panel's offset of 120.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 12, "mosi_pin": 11}) + + config = run_schema_validation( + { + "id": "test_display", + "model": "xteink-x4pro-uc8279", + "dimensions": {"width": 800, "height": 480}, + } + ) + assert MODELS["XTEINK-X4PRO-UC8279"].get_constructor_args(config) == (120,) + + +def test_offset_height_rejected_by_models_without_it( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """A model that does not use offset_height rejects it rather than ignoring it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match="offset_height"): + run_schema_validation( + { + "id": "test_display", + "model": "ssd1677", + "dc_pin": 21, + "dimensions": {"width": 200, "height": 200, "offset_height": 8}, + } + ) + + def test_enable_pin_code_generation( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/epaper_spi/display/test_uc8279_transfer.cpp b/tests/components/epaper_spi/display/test_uc8279_transfer.cpp new file mode 100644 index 00000000000..a0a1e1258c5 --- /dev/null +++ b/tests/components/epaper_spi/display/test_uc8279_transfer.cpp @@ -0,0 +1,237 @@ +#include + +#include +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_uc8279.h" + +namespace esphome::epaper_spi::testing { + +/// A reset pin that counts how often it is driven low. +class CountingPin : public RecordingPin { + public: + void digital_write(bool value) override { + if (!value) + this->pulses++; + RecordingPin::digital_write(value); + } + int pulses{0}; +}; + +/// A 16x2 panel bonded to gates 1 and 2 of three, so gate 0 is outside the panel. +class TestableUC8279 : public EPaperUC8279 { + public: + TestableUC8279() : EPaperUC8279("test", 16, 2, nullptr, 0, 1) {} + + void install(spi::SPIDelegate *delegate, uint8_t full_update_every) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_reset_pin(&this->reset_pin); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->set_full_update_every(full_update_every); + } + + void set_frame(std::initializer_list bytes) { + size_t i = 0; + for (const uint8_t byte : bytes) + this->buffer_[i++] = byte; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + /// A whole update with the frame already drawn, run through loop() as the main loop would. + void run_update() { + this->set_auto_clear(false); + this->state_ = EPaperState::UPDATE; + const uint32_t deadline = millis() + 2000; + while (this->state_ != EPaperState::IDLE) { + ASSERT_LT(millis(), deadline) << "update did not finish"; + this->loop(); + } + } + + bool transfer() { return this->transfer_data(); } + + using EPaperUC8279::on_safe_shutdown; + + RecordingPin dc; + CountingPin reset_pin; +}; + +using Bytes = std::vector; + +static size_t count(const Bytes &commands, uint8_t command) { + return std::count(commands.begin(), commands.end(), command); +} + +/// A full refresh writes the new image to DTM2 and white to DTM1, for every gate the controller scans: +/// white on the gate outside the panel, then the panel's rows. +TEST(EPaperUC8279, FullRefreshWritesTheImageAndAWhitePreviousImage) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_TRUE(display.reset_pin.level) << "reset pin left low"; + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.data[0x10].size(), 12u) << "DTM1 is not the white previous image plus the image written after"; + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); + EXPECT_EQ(count(bus.commands, 0x91), 0u) << "a full refresh entered partial mode"; + EXPECT_EQ(bus.data[0x00], (Bytes{0x17, 0x4D})) << "the OTP LUT was not selected before the refresh"; +} + +/// While partial updates are in use, DTM1 is rewritten with the image after every refresh, so the next +/// partial one compares against the image on the panel. The panel then waits in standby, since its own +/// deep sleep loses both planes. +TEST(EPaperUC8279, PreviousImageIsRewrittenAfterEveryRefresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.commands.back(), 0x02) << "the panel was not powered off after DTM1 was written"; + EXPECT_EQ(count(bus.commands, 0x07), 0u) << "the panel was put into deep sleep"; +} + +/// A partial refresh writes only DTM2, without a reset or the configuration, inside a partial window +/// covering the panel's gates. +TEST(EPaperUC8279, PartialRefreshWritesOnlyTheNewImage) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + + EXPECT_EQ(display.reset_pin.pulses, pulses) << "the partial refresh reset the panel"; + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})) + << "DTM1 was written before the refresh, or not rewritten after it"; + EXPECT_EQ(bus.data[0x90], (Bytes{0x00, 0x00, 0x00, 0x0F, 0x00, 0x01, 0x00, 0x02, 0x01})) + << "the partial window does not cover the panel's gates"; + const auto refresh = std::find(bus.commands.begin(), bus.commands.end(), 0x12); + const auto dtm1 = std::find(bus.commands.begin(), bus.commands.end(), 0x10); + EXPECT_LT(refresh, dtm1) << "DTM1 was written before the refresh"; +} + +/// Without partial updates the panel goes into its deep sleep after every update, and the next update +/// resets it and writes both planes again. +TEST(EPaperUC8279, EveryUpdateFullSleepsAndResetsEachTime) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + EXPECT_EQ(bus.data[0x07], (Bytes{0xA5})); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))) << "DTM1 was rewritten without partial updates"; + bus.clear(); + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))); + EXPECT_EQ(count(bus.commands, 0x91), 0u); +} + +/// The rewrite of DTM1 is spread over loop iterations like the image transfer, and the state machine +/// waits for it: the panel is powered off and the update finishes only once all of it is written. +TEST(EPaperUC8279, PreviousImageRewriteIsTimeSliced) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); // every row overruns the time slice + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.commands.back(), 0x02); +} + +/// In a full update the white DTM1 plane starts on the pass after DTM2 ends, so one pass never writes both planes. +TEST(EPaperUC8279, FullTransferWritesOnePlanePerPass) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + + EXPECT_FALSE(display.transfer()); + EXPECT_EQ(count(bus.commands, 0x13), 1u); + EXPECT_EQ(count(bus.commands, 0x10), 0u) << "DTM1 started in the same pass as DTM2"; + + EXPECT_TRUE(display.transfer()); + EXPECT_EQ(count(bus.commands, 0x13), 1u); + EXPECT_EQ(bus.data[0x10], (Bytes(6, 0xFF))); +} + +/// The full refresh that comes round after partial ones resets the controller, configures it again and writes a white +/// DTM1 over the image it held, as the first full refresh does. +TEST(EPaperUC8279, FullRefreshAfterPartialOnesStartsAfresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 2); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.set_frame({0x0F, 0xF0, 0x00, 0x00}); + display.run_update(); + EXPECT_EQ(display.reset_pin.pulses, pulses + 1) << "the full refresh did not reset the controller"; + EXPECT_EQ(count(bus.commands, 0x91), 0u) << "the full refresh entered partial mode"; + ASSERT_EQ(bus.data[0x10].size(), 12u); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); + EXPECT_EQ(Bytes(bus.data[0x10].begin() + 6, bus.data[0x10].end()), (Bytes{0xFF, 0xFF, 0x0F, 0xF0, 0x00, 0x00})); +} + +/// A requested full update, between partial ones, is a full refresh from scratch too. +TEST(EPaperUC8279, RequestedFullUpdateStartsAfresh) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + const int pulses = display.reset_pin.pulses; + + display.request_full_update(); + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.run_update(); + EXPECT_EQ(display.reset_pin.pulses, pulses + 1); + EXPECT_EQ(count(bus.commands, 0x91), 0u); + EXPECT_EQ(Bytes(bus.data[0x10].begin(), bus.data[0x10].begin() + 6), (Bytes(6, 0xFF))); +} + +/// Before a restart or the controller's deep sleep the panel goes into its own deep sleep, even while partial updates +/// would otherwise leave it in standby. +TEST(EPaperUC8279, ShutdownPutsThePanelToSleep) { + TestableUC8279 display; + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + display.on_safe_shutdown(); + EXPECT_EQ(bus.data[0x07], (Bytes{0xA5})); +} + +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 6a487064cbc..144c60dd2ff 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -435,3 +435,27 @@ display: allow_other_uses: true number: GPIO4 inverted: true + + # Xteink X4 Pro (800x480, UC8279) + # Pins overridden to avoid conflicts with the other display configs + # full_update_every > 1 exercises the partial refresh path + - platform: epaper_spi + spi_id: spi_bus + model: xteink-x4pro-uc8279 + full_update_every: 10 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); From 0f6fc503bd0b62cace4ebce4fe202f62462fc2b1 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:01:14 -0500 Subject: [PATCH 060/179] [sen6x] Add temperature compensation, acceleration and startup delay (#18781) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 43 +++++++++++-- esphome/components/sen6x/sen6x.h | 31 ++++++++- esphome/components/sen6x/sensor.py | 63 +++++++++++++++++++ tests/components/sen6x/common.yaml | 10 +++ .../components/sen6x/validate.esp32-idf.yaml | 8 ++- 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index dabf16d760b..42bc3aea6a6 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -1,6 +1,7 @@ #include "sen6x.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include #include namespace esphome::sen6x { @@ -25,7 +26,6 @@ static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; // Accepted range of a runtime pressure source, the same as the schema's ambient_pressure_compensation static constexpr float AMBIENT_PRESSURE_MIN_HPA = 700.0f; static constexpr float AMBIENT_PRESSURE_MAX_HPA = 1200.0f; -static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -45,6 +45,8 @@ static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; +static constexpr uint16_t SEN6X_CMD_TEMPERATURE_COMPENSATION = 0x60B2; +static constexpr uint16_t SEN6X_CMD_TEMPERATURE_ACCELERATION = 0x6100; static constexpr uint16_t SEN6X_CMD_CO2_AUTOMATIC_SELF_CAL = 0x6711; static constexpr uint16_t SEN6X_CMD_AMBIENT_PRESSURE = 0x6720; static constexpr uint16_t SEN6X_CMD_SENSOR_ALTITUDE = 0x6736; @@ -200,6 +202,16 @@ bool SEN6XComponent::write_setup_step_(SetupStep step) { return false; this->write_tuning_parameters_(SEN6X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); return true; + case SETUP_STEP_TEMPERATURE_COMPENSATION: + if (!this->temperature_compensation_.has_value()) + return false; + this->write_temperature_compensation_(this->temperature_compensation_.value()); + return true; + case SETUP_STEP_TEMPERATURE_ACCELERATION: + if (!this->temperature_acceleration_.has_value()) + return false; + this->write_temperature_acceleration_(this->temperature_acceleration_.value()); + return true; case SETUP_STEP_CO2_ASC: if (this->co2_sensor_ == nullptr || !this->co2_asc_.has_value()) return false; @@ -220,6 +232,18 @@ bool SEN6XComponent::write_setup_step_(SetupStep step) { } } +bool SEN6XComponent::write_temperature_compensation_(const TemperatureCompensation &compensation) { + // Word 4 selects compensation slot 0; other slots are not exposed + uint16_t params[4] = {static_cast(compensation.offset), + static_cast(compensation.normalized_offset_slope), compensation.time_constant, 0}; + return this->write_config_words_(SEN6X_CMD_TEMPERATURE_COMPENSATION, params, 4); +} + +bool SEN6XComponent::write_temperature_acceleration_(const TemperatureAcceleration &acceleration) { + uint16_t params[4] = {acceleration.k, acceleration.p, acceleration.t1, acceleration.t2}; + return this->write_config_words_(SEN6X_CMD_TEMPERATURE_ACCELERATION, params, 4); +} + bool SEN6XComponent::write_setup_register_(uint16_t i2c_command, uint16_t value) { return this->write_config_words_(i2c_command, &value, 1); } @@ -264,10 +288,21 @@ void SEN6XComponent::dump_config() { " Product: %s\n" " Serial: %s\n" " Firmware: %u.%u\n" - " Address: 0x%02X", + " Address: 0x%02X\n" + " Startup delay: %" PRIu32 " ms", this->product_name_.c_str(), this->serial_number_.c_str(), this->firmware_version_major_, - this->firmware_version_minor_, this->address_); + this->firmware_version_minor_, this->address_, this->startup_delay_ms_); LOG_UPDATE_INTERVAL(this); + if (this->temperature_compensation_.has_value()) { + const auto &comp = this->temperature_compensation_.value(); + ESP_LOGCONFIG(TAG, " Temperature compensation: offset=%.2f slope=%.4f time_constant=%us", comp.offset / 200.0f, + comp.normalized_offset_slope / 10000.0f, comp.time_constant); + } + if (this->temperature_acceleration_.has_value()) { + const auto &accel = this->temperature_acceleration_.value(); + ESP_LOGCONFIG(TAG, " Temperature acceleration: K=%.1f P=%.1f T1=%.1f T2=%.1f", accel.k / 10.0f, accel.p / 10.0f, + accel.t1 / 10.0f, accel.t2 / 10.0f); + } // setup() clears co2_sensor_ once it detects a variant without CO2 if (this->co2_sensor_ != nullptr) { if (this->co2_asc_.has_value()) { @@ -557,7 +592,7 @@ void SEN6XComponent::on_measurement_started_() { } // Values need the warm-up period again after every start this->startup_complete_ = false; - this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; }); + this->set_timeout(TIMEOUT_STARTUP, this->startup_delay_ms_, [this]() { this->startup_complete_ = true; }); } void SEN6XComponent::start_measurement() { diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 43d0ce00120..8cc187ebf2b 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -20,6 +20,21 @@ struct GasTuning { uint16_t gain_factor; }; +// Raw values in the device's fixed-point encoding (offset x200, slope x10000) +struct TemperatureCompensation { + int16_t offset; + int16_t normalized_offset_slope; + uint16_t time_constant; +}; + +// Raw values in the device's fixed-point encoding (all x10) +struct TemperatureAcceleration { + uint16_t k; + uint16_t p; + uint16_t t1; + uint16_t t2; +}; + // The CO2 settings a config may leave out: altitude is 0 to 3000 m, pressure 700 to 1200 hPa inline constexpr uint16_t ALTITUDE_UNSET = 0xFFFF; inline constexpr uint16_t PRESSURE_UNSET = 0; @@ -62,6 +77,13 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void set_startup_delay(uint32_t delay_ms) { this->startup_delay_ms_ = delay_ms; } + void set_temperature_compensation(int16_t offset, int16_t normalized_offset_slope, uint16_t time_constant) { + this->temperature_compensation_ = TemperatureCompensation{offset, normalized_offset_slope, time_constant}; + } + void set_temperature_acceleration(uint16_t k, uint16_t p, uint16_t t1, uint16_t t2) { + this->temperature_acceleration_ = TemperatureAcceleration{k, p, t1, t2}; + } void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } @@ -75,6 +97,8 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S enum SetupStep : uint8_t { SETUP_STEP_VOC_TUNING, SETUP_STEP_NOX_TUNING, + SETUP_STEP_TEMPERATURE_COMPENSATION, + SETUP_STEP_TEMPERATURE_ACCELERATION, SETUP_STEP_CO2_ASC, SETUP_STEP_ALTITUDE, SETUP_STEP_AMBIENT_PRESSURE, @@ -88,6 +112,8 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); bool write_setup_register_(uint16_t i2c_command, uint16_t value); + bool write_temperature_compensation_(const TemperatureCompensation &compensation); + bool write_temperature_acceleration_(const TemperatureAcceleration &acceleration); void write_ambient_pressure_(uint16_t pressure_hpa); void update_ambient_pressure_compensation_(float pressure_hpa); uint32_t command_wait_remaining_() const; @@ -102,14 +128,17 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string serial_number_; optional voc_tuning_params_; optional nox_tuning_params_; + optional temperature_acceleration_; + optional temperature_compensation_; + uint16_t read_cmd_{0}; sensor::Sensor *ambient_pressure_source_{nullptr}; + uint32_t startup_delay_ms_{60000}; Sen6xType sen6x_type_{UNKNOWN}; // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap uint32_t command_started_at_{0}; uint32_t command_wait_{0}; uint32_t co2_started_at_{0}; uint32_t co2_wait_{0}; - uint16_t read_cmd_{0}; uint16_t altitude_compensation_{ALTITUDE_UNSET}; uint16_t ambient_pressure_{PRESSURE_UNSET}; // Last pressure written to the device, used to skip redundant writes diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index b219caafa09..c62aa5af4cd 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -19,13 +19,18 @@ from esphome.const import ( CONF_INDEX_OFFSET, CONF_LEARNING_TIME_GAIN_HOURS, CONF_LEARNING_TIME_OFFSET_HOURS, + CONF_NORMALIZED_OFFSET_SLOPE, CONF_NOX, + CONF_OFFSET, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_STARTUP_DELAY, CONF_STD_INITIAL, CONF_TEMPERATURE, + CONF_TEMPERATURE_COMPENSATION, + CONF_TIME_CONSTANT, CONF_TYPE, CONF_VOC, DEVICE_CLASS_CARBON_DIOXIDE, @@ -47,6 +52,17 @@ from esphome.const import ( ) from esphome.types import ConfigType +CONF_TEMPERATURE_ACCELERATION = "temperature_acceleration" +CONF_K = "k" +CONF_P = "p" +CONF_T1 = "t1" +CONF_T2 = "t2" + +# The device takes these as fixed-point words: offset x200, slope x10000, acceleration x10 +TEMPERATURE_OFFSET_SCALE = 200 +TEMPERATURE_SLOPE_SCALE = 10000 +TEMPERATURE_ACCELERATION_SCALE = 10 + CODEOWNERS = ["@martgras", "@mebner86", "@tuct"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensirion_common"] @@ -93,6 +109,8 @@ def _gas_index_schema( ).extend({cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema(tuning_schema)}) +_acceleration_parameter = cv.float_range(min=0.1, max=6553.5) + CONFIG_SCHEMA = cv.All( cv.rename_key(CONF_VOC, CONF_VOC_INDEX, removed_in="2027.2.0", component="sen6x"), cv.rename_key(CONF_NOX, CONF_NOX_INDEX, removed_in="2027.2.0", component="sen6x"), @@ -182,6 +200,31 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_STARTUP_DELAY, default="60s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(hours=1)), + ), + cv.Optional(CONF_TEMPERATURE_COMPENSATION): cv.Schema( + { + cv.Optional(CONF_OFFSET, default=0): cv.float_range( + min=-163.84, max=163.835 + ), + cv.Optional( + CONF_NORMALIZED_OFFSET_SLOPE, default=0 + ): cv.float_range(min=-3.2768, max=3.2767), + cv.Optional(CONF_TIME_CONSTANT, default=0): cv.int_range( + min=0, max=65535 + ), + } + ), + cv.Optional(CONF_TEMPERATURE_ACCELERATION): cv.Schema( + { + cv.Optional(CONF_K, default=20.0): _acceleration_parameter, + cv.Optional(CONF_P, default=20.0): _acceleration_parameter, + cv.Optional(CONF_T1, default=100.0): _acceleration_parameter, + cv.Optional(CONF_T2, default=300.0): _acceleration_parameter, + } + ), } ) .extend(cv.polling_component_schema("60s")) @@ -208,6 +251,26 @@ async def to_code(config: ConfigType) -> None: if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) + cg.add(var.set_startup_delay(config[CONF_STARTUP_DELAY])) + + if (comp := config.get(CONF_TEMPERATURE_COMPENSATION)) is not None: + cg.add( + var.set_temperature_compensation( + round(comp[CONF_OFFSET] * TEMPERATURE_OFFSET_SCALE), + round(comp[CONF_NORMALIZED_OFFSET_SLOPE] * TEMPERATURE_SLOPE_SCALE), + comp[CONF_TIME_CONSTANT], + ) + ) + if (accel := config.get(CONF_TEMPERATURE_ACCELERATION)) is not None: + cg.add( + var.set_temperature_acceleration( + *( + round(accel[key] * TEMPERATURE_ACCELERATION_SCALE) + for key in (CONF_K, CONF_P, CONF_T1, CONF_T2) + ) + ) + ) + for key, func_name in SENSOR_MAP.items(): if cfg := config.get(key): sens = await sensor.new_sensor(cfg) diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index eed0dd51d67..d276cecbc7c 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -8,6 +8,16 @@ sensor: id: sen6x_sensor type: SEN69C i2c_id: i2c_bus + startup_delay: 30s + temperature_compensation: + offset: -2.5 + normalized_offset_slope: 0.01 + time_constant: 60 + temperature_acceleration: + k: 25.0 + p: 15.0 + t1: 120.0 + t2: 240.0 temperature: name: Temperature accuracy_decimals: 1 diff --git a/tests/components/sen6x/validate.esp32-idf.yaml b/tests/components/sen6x/validate.esp32-idf.yaml index 3ae23af4ac4..927a8d478a9 100644 --- a/tests/components/sen6x/validate.esp32-idf.yaml +++ b/tests/components/sen6x/validate.esp32-idf.yaml @@ -1,5 +1,5 @@ -# Config-only: partial algorithm_tuning blocks, so the schema defaults fill in the -# keys that are left out. +# Config-only: partial algorithm_tuning, temperature_compensation and +# temperature_acceleration blocks, so the schema defaults fill in the keys that are left out. packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml @@ -8,6 +8,10 @@ sensor: id: sen6x_partial_tuning type: SEN65 i2c_id: i2c_bus + temperature_compensation: + offset: 1.5 + temperature_acceleration: + k: 30 voc_index: name: VOC Index algorithm_tuning: From 022733c46b6b41c21e35e35b52c6e6518fbdef97 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:16:45 -0500 Subject: [PATCH 061/179] [sen6x] Add PM number concentration sensors (#18782) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 57 ++++++++++++++++++++++++++++++ esphome/components/sen6x/sen6x.h | 12 +++++++ esphome/components/sen6x/sensor.py | 29 +++++++++++++++ esphome/core/defines.h | 1 + tests/components/sen6x/common.yaml | 10 ++++++ 5 files changed, 109 insertions(+) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 42bc3aea6a6..aaad70dd940 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include #include +#include namespace esphome::sen6x { @@ -38,6 +39,9 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN65 = 0x0446; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; +#ifdef USE_SEN6X_NUMBER_CONCENTRATION +static constexpr uint16_t SEN6X_CMD_READ_NUMBER_CONCENTRATION = 0x0316; +#endif static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; static constexpr uint16_t SEN6X_CMD_STOP_MEASUREMENTS = 0x0104; static constexpr uint16_t SEN6X_CMD_START_FAN_CLEANING = 0x5607; @@ -323,6 +327,13 @@ void SEN6XComponent::dump_config() { LOG_SENSOR(" ", "PM 2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM 4.0", this->pm_4_0_sensor_); LOG_SENSOR(" ", "PM 10.0", this->pm_10_0_sensor_); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + LOG_SENSOR(" ", "PMC 0.5", this->pmc_0_5_sensor_); + LOG_SENSOR(" ", "PMC 1.0", this->pmc_1_0_sensor_); + LOG_SENSOR(" ", "PMC 2.5", this->pmc_2_5_sensor_); + LOG_SENSOR(" ", "PMC 4.0", this->pmc_4_0_sensor_); + LOG_SENSOR(" ", "PMC 10.0", this->pmc_10_0_sensor_); +#endif LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); LOG_SENSOR(" ", "VOC", this->voc_sensor_); @@ -361,6 +372,10 @@ void SEN6XComponent::update() { // -> write_command (read measurement) // -> timeout I2C_READ_DELAY // -> parse_and_publish_measurements_() + // -> with pmc_* sensors: read_number_concentration_() + // -> write_command (read number concentration) + // -> timeout I2C_READ_DELAY + // -> parse_and_publish_number_concentration_() // // All timeouts share a single ID (TIMEOUT_POLL) since only one is active // at a time. cancel_timeout in update() stops any in-flight chain. @@ -544,9 +559,51 @@ void SEN6XComponent::parse_and_publish_measurements_() { if (this->co2_sensor_ != nullptr) this->co2_sensor_->publish_state(co2); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + // The define says some device in the build has pmc sensors; this one may not + if (this->has_number_concentration_sensor_()) { + this->read_number_concentration_(); // the chain ends there, with the warning cleared + return; + } +#endif this->status_clear_warning(); } +#ifdef USE_SEN6X_NUMBER_CONCENTRATION +bool SEN6XComponent::has_number_concentration_sensor_() const { + return this->pmc_0_5_sensor_ != nullptr || this->pmc_1_0_sensor_ != nullptr || this->pmc_2_5_sensor_ != nullptr || + this->pmc_4_0_sensor_ != nullptr || this->pmc_10_0_sensor_ != nullptr; +} + +void SEN6XComponent::read_number_concentration_() { + if (!this->write_command(SEN6X_CMD_READ_NUMBER_CONCENTRATION)) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read number concentration failed (%d)", this->last_error_); + return; + } + + this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { this->parse_and_publish_number_concentration_(); }); +} + +void SEN6XComponent::parse_and_publish_number_concentration_() { + sensor::Sensor *sensors[] = {this->pmc_0_5_sensor_, this->pmc_1_0_sensor_, this->pmc_2_5_sensor_, + this->pmc_4_0_sensor_, this->pmc_10_0_sensor_}; + uint16_t measurements[std::size(sensors)]; + + if (!this->read_data(measurements, std::size(measurements))) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_); + return; + } + + for (size_t i = 0; i < std::size(sensors); i++) { + if (sensors[i] != nullptr) + sensors[i]->publish_state(measurements[i] == 0xFFFF ? NAN : measurements[i] / 10.0f); + } + this->status_clear_warning(); +} +#endif + void SEN6XComponent::write_ambient_pressure_(uint16_t pressure_hpa) { if (this->write_setup_register_(SEN6X_CMD_AMBIENT_PRESSURE, pressure_hpa)) this->last_ambient_pressure_ = pressure_hpa; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 8cc187ebf2b..f3054a8ffbd 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -44,6 +44,13 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S SUB_SENSOR(pm_2_5) SUB_SENSOR(pm_4_0) SUB_SENSOR(pm_10_0) +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + SUB_SENSOR(pmc_0_5) + SUB_SENSOR(pmc_1_0) + SUB_SENSOR(pmc_2_5) + SUB_SENSOR(pmc_4_0) + SUB_SENSOR(pmc_10_0) +#endif SUB_SENSOR(temperature) SUB_SENSOR(humidity) SUB_SENSOR(voc) @@ -123,6 +130,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); +#ifdef USE_SEN6X_NUMBER_CONCENTRATION + bool has_number_concentration_sensor_() const; + void read_number_concentration_(); + void parse_and_publish_number_concentration_(); +#endif std::string product_name_; std::string serial_number_; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index c62aa5af4cd..953b6529ef6 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -26,6 +26,11 @@ from esphome.const import ( CONF_PM_2_5, CONF_PM_4_0, CONF_PM_10_0, + CONF_PMC_0_5, + CONF_PMC_1_0, + CONF_PMC_2_5, + CONF_PMC_4_0, + CONF_PMC_10_0, CONF_STARTUP_DELAY, CONF_STD_INITIAL, CONF_TEMPERATURE, @@ -40,12 +45,14 @@ from esphome.const import ( DEVICE_CLASS_PM25, DEVICE_CLASS_TEMPERATURE, ICON_CHEMICAL_WEAPON, + ICON_COUNTER, ICON_MOLECULE_CO2, ICON_RADIATOR, ICON_THERMOMETER, ICON_WATER_PERCENT, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, + UNIT_COUNTS_PER_CUBIC_CENTIMETER, UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PARTS_PER_MILLION, UNIT_PERCENT, @@ -73,6 +80,15 @@ SEN6XComponent = sen6x_ns.class_( ) +_PMC_KEYS = (CONF_PMC_0_5, CONF_PMC_1_0, CONF_PMC_2_5, CONF_PMC_4_0, CONF_PMC_10_0) +_PMC_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS_PER_CUBIC_CENTIMETER, + icon=ICON_COUNTER, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, +) + + def _gas_index_schema( *, index_offset: int, @@ -147,6 +163,11 @@ CONFIG_SCHEMA = cv.All( device_class=DEVICE_CLASS_PM10, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_PMC_0_5): _PMC_SCHEMA, + cv.Optional(CONF_PMC_1_0): _PMC_SCHEMA, + cv.Optional(CONF_PMC_2_5): _PMC_SCHEMA, + cv.Optional(CONF_PMC_4_0): _PMC_SCHEMA, + cv.Optional(CONF_PMC_10_0): _PMC_SCHEMA, cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, icon=ICON_THERMOMETER, @@ -236,6 +257,11 @@ SENSOR_MAP = { CONF_PM_2_5: "set_pm_2_5_sensor", CONF_PM_4_0: "set_pm_4_0_sensor", CONF_PM_10_0: "set_pm_10_0_sensor", + CONF_PMC_0_5: "set_pmc_0_5_sensor", + CONF_PMC_1_0: "set_pmc_1_0_sensor", + CONF_PMC_2_5: "set_pmc_2_5_sensor", + CONF_PMC_4_0: "set_pmc_4_0_sensor", + CONF_PMC_10_0: "set_pmc_10_0_sensor", CONF_TEMPERATURE: "set_temperature_sensor", CONF_HUMIDITY: "set_humidity_sensor", CONF_VOC_INDEX: "set_voc_sensor", @@ -250,6 +276,9 @@ async def to_code(config: ConfigType) -> None: if CONF_TYPE in config: cg.add(var.set_type(config[CONF_TYPE])) + # The number concentration read and its sensors compile in only when one is configured + if any(key in config for key in _PMC_KEYS): + cg.add_define("USE_SEN6X_NUMBER_CONCENTRATION") cg.add(var.set_startup_delay(config[CONF_STARTUP_DELAY])) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 13dc3492f29..1fa6f7822a5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -275,6 +275,7 @@ #define USE_SCRIPT_ON_STOP #define USE_SELECT #define USE_SELEC_METER_EM4M +#define USE_SEN6X_NUMBER_CONCENTRATION #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index d276cecbc7c..6e39256d159 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -40,6 +40,16 @@ sensor: name: PM <10µm Weight concentration id: sen6x_pm_10_0 accuracy_decimals: 1 + pmc_0_5: + name: PM <0.5µm Number concentration + pmc_1_0: + name: PM <1µm Number concentration + pmc_2_5: + name: PM <2.5µm Number concentration + pmc_4_0: + name: PM <4µm Number concentration + pmc_10_0: + name: PM <10µm Number concentration nox_index: name: NOx Index algorithm_tuning: From 1a28a9220ba1f77f8d9576562adb0baacba3ed0c Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 18 Oct 2026 00:35:46 +0200 Subject: [PATCH 062/179] [uart] Add a binary sensor that fires on a byte sequence (#14032) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/uart/binary_sensor/__init__.py | 101 ++++++++++++++++++ .../uart/binary_sensor/uart_binary_sensor.cpp | 50 +++++++++ .../uart/binary_sensor/uart_binary_sensor.h | 37 +++++++ esphome/core/defines.h | 1 + esphome/core/helpers.h | 8 ++ tests/components/uart/test.esp32-idf.yaml | 10 ++ tests/components/uart/test.esp8266-ard.yaml | 10 ++ 7 files changed, 217 insertions(+) create mode 100644 esphome/components/uart/binary_sensor/__init__.py create mode 100644 esphome/components/uart/binary_sensor/uart_binary_sensor.cpp create mode 100644 esphome/components/uart/binary_sensor/uart_binary_sensor.h diff --git a/esphome/components/uart/binary_sensor/__init__.py b/esphome/components/uart/binary_sensor/__init__.py new file mode 100644 index 00000000000..07ab31ab19c --- /dev/null +++ b/esphome/components/uart/binary_sensor/__init__.py @@ -0,0 +1,101 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor, uart +import esphome.config_validation as cv +from esphome.const import CONF_DATA, CONF_UART_ID +from esphome.core import CORE, ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import uart_ns, validate_raw_data + +DEPENDENCIES = ["uart"] +DOMAIN = "uart_binary_sensor" + +CONF_HUB_ID = "hub_id" + +UARTBinarySensorHub = uart_ns.class_( + "UARTBinarySensorHub", uart.UARTDevice, cg.Component +) + +# Every sensor on a bus is one matcher of that bus's hub; the define is the largest hub +_request_matcher_slot = cg.slot_counter("UART_BINARY_SENSOR_COUNT") + +CONFIG_SCHEMA = ( + # Initially off, so the first sequence after boot is an OFF to ON edge for on_press + binary_sensor.binary_sensor_schema(binary_sensor.BinarySensorInitiallyOff) + .extend( + { + cv.GenerateID(CONF_HUB_ID): cv.declare_id(UARTBinarySensorHub), + # An empty pattern would match every byte; the hub's buffer index is a byte + cv.Required(CONF_DATA): cv.All( + validate_raw_data, cv.Length(min=1, max=255) + ), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +_validate_bus = uart.final_validate_device_schema("uart binary_sensor", require_rx=True) + + +def _one_hub_per_bus(config: ConfigType) -> ConfigType: + """The first matching sensor takes the bytes and clears them, so a sequence that contains + another on the same bus would leave one of them dead; and the sensors of a bus share the hub + the first one creates, so only that one can name it and claim the bus's receive side.""" + data = bytes(config[CONF_DATA]) + first = True + for other in fv.full_config.get().get("binary_sensor", []): + if ( + other.get("platform") != "uart" + or other[CONF_UART_ID] != config[CONF_UART_ID] + ): + continue + if other is config: + break # Every sensor before this one on the bus has been seen + first = False + other_data = bytes(other[CONF_DATA]) + if other_data in data or data in other_data: + raise cv.Invalid( + f"Another uart binary_sensor on '{config[CONF_UART_ID]}' has data this pattern " + "would match as well" + ) + if config[CONF_HUB_ID].is_manual: + raise cv.Invalid( + f"Set hub_id on the first uart binary_sensor of '{config[CONF_UART_ID]}'; " + "the others share its hub" + ) + if first: + _validate_bus(config) + return config + + +FINAL_VALIDATE_SCHEMA = _one_hub_per_bus + + +def _hubs() -> dict[ID, cg.MockObj]: + """The hub of each bus, keyed by the bus id.""" + return CORE.data.setdefault(DOMAIN, {}) + + +async def _hub_for(config: ConfigType) -> cg.MockObj: + """The one hub that reads the bus for every sensor on it, created by the first sensor.""" + hubs = _hubs() + if (hub := hubs.get(config[CONF_UART_ID])) is None: + hub = hubs[config[CONF_UART_ID]] = cg.new_Pvariable(config[CONF_HUB_ID]) + await cg.register_component(hub, config) + await uart.register_uart_device(hub, config) + return hub + + +async def to_code(config: ConfigType) -> None: + hub = await _hub_for(config) + var = await binary_sensor.new_binary_sensor(config) + data = config[CONF_DATA] + _request_matcher_slot(str(hub)) + cg.add( + hub.add_event_matcher( + var, automation.progmem_bytes("uart_binary_sensor_data", data), len(data) + ) + ) diff --git a/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp new file mode 100644 index 00000000000..9bf9e726561 --- /dev/null +++ b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp @@ -0,0 +1,50 @@ +#include "uart_binary_sensor.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::uart { + +ESPHOME_LOG_TAG(TAG, "uart.binary_sensor"); + +void UARTBinarySensorHub::dump_config() { + ESP_LOGCONFIG(TAG, "UART Binary Sensor:"); + for (const auto &matcher : this->matchers_) { + LOG_BINARY_SENSOR(" ", "Pattern", matcher.sensor); + } +} + +void UARTBinarySensorHub::loop() { + while (this->available()) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + this->buffer_.push_overwrite(byte); + for (const auto &matcher : this->matchers_) { + // The byte just read has to be the pattern's last one before the rest is compared + if (progmem_read_byte(&matcher.data[matcher.data_len - 1]) != byte || !this->matches_(matcher)) { + continue; + } + // A momentary press, as the remote receiver binary sensors publish theirs + matcher.sensor->publish_state(true); + matcher.sensor->publish_state(false); + this->buffer_.clear(); + break; + } + } +} + +// Whether the pattern, apart from its last byte, ends the buffer +bool UARTBinarySensorHub::matches_(const EventMatcher &matcher) const { + if (this->buffer_.size() < matcher.data_len) { + return false; + } + const size_t start = this->buffer_.size() - matcher.data_len; + for (size_t i = 0; i + 1 < matcher.data_len; i++) { + if (this->buffer_[start + i] != progmem_read_byte(&matcher.data[i])) { + return false; + } + } + return true; +} + +} // namespace esphome::uart diff --git a/esphome/components/uart/binary_sensor/uart_binary_sensor.h b/esphome/components/uart/binary_sensor/uart_binary_sensor.h new file mode 100644 index 00000000000..71ccda9ef21 --- /dev/null +++ b/esphome/components/uart/binary_sensor/uart_binary_sensor.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +// One per bus: reads the bytes once and presses each sensor whose pattern ends the stream +class UARTBinarySensorHub : public UARTDevice, public Component { + public: + void setup() override { this->buffer_.init(this->max_len_); } + void loop() override; + void dump_config() override; + + // The pattern lives in flash; called once per sensor by the generated code + void add_event_matcher(binary_sensor::BinarySensor *sensor, const uint8_t *data, uint8_t data_len) { + this->matchers_.push_back({sensor, data, data_len}); + this->max_len_ = std::max(this->max_len_, data_len); + } + + protected: + struct EventMatcher { + binary_sensor::BinarySensor *sensor; + const uint8_t *data; + uint8_t data_len; + }; + + bool matches_(const EventMatcher &matcher) const; + + StaticVector matchers_; + FixedRingBuffer buffer_; + uint8_t max_len_{0}; +}; + +} // namespace esphome::uart diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1fa6f7822a5..24f66a87518 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -167,6 +167,7 @@ #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 +#define UART_BINARY_SENSOR_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define MICS_5524_GAS_COUNT 5 #define USE_MICS_5524_DATASHEET diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 72bd3a0c6ba..2989655a29a 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -576,6 +576,14 @@ template::max()> ConstIterator begin() const { return ConstIterator(this, 0); } ConstIterator end() const { return ConstIterator(this, this->count_); } + /// The element `pos` places after the oldest one; the sum is widened so a small index type cannot truncate it + const T &operator[](index_type pos) const { + size_t index = static_cast(this->head_) + pos; + if (index >= this->capacity_) + index -= this->capacity_; + return this->data_[index]; + } + protected: T *data_{nullptr}; index_type head_{0}; diff --git a/tests/components/uart/test.esp32-idf.yaml b/tests/components/uart/test.esp32-idf.yaml index 3bb87aa3cc7..02b53a89e44 100644 --- a/tests/components/uart/test.esp32-idf.yaml +++ b/tests/components/uart/test.esp32-idf.yaml @@ -120,3 +120,13 @@ event: event_types: - "string_event_A": "*A#" - "bytes_event_B": [0x2A, 0x42, 0x23] + +binary_sensor: + - platform: uart + id: up + uart_id: uart_id + data: [0x1B, 0x5B, 0x41] + - platform: uart + id: down + uart_id: uart_id + data: [0x1B, 0x5B, 0x42] diff --git a/tests/components/uart/test.esp8266-ard.yaml b/tests/components/uart/test.esp8266-ard.yaml index 09d23e2925e..b8f07fa9d62 100644 --- a/tests/components/uart/test.esp8266-ard.yaml +++ b/tests/components/uart/test.esp8266-ard.yaml @@ -55,3 +55,13 @@ event: event_types: - "string_event_A": "*A#" - "bytes_event_B": [0x2A, 0x42, 0x23] + +binary_sensor: + - platform: uart + id: up + uart_id: uart_id + data: [0x1B, 0x5B, 0x41] + - platform: uart + id: down + uart_id: uart_id + data: [0x1B, 0x5B, 0x42] From 818d43f742e3fc18c22bb6d2e41f46d1e0debb73 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sat, 17 Oct 2026 18:25:36 -0500 Subject: [PATCH 063/179] [epaper_spi] Run the loop continuously between the steps of an update (#20554) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/epaper_spi/epaper_spi.cpp | 27 ++- esphome/components/epaper_spi/epaper_spi.h | 4 + tests/components/epaper_spi/common.h | 8 + .../epaper_spi/display/test_busy_timeout.cpp | 11 +- .../epaper_spi/display/test_fast_loop.cpp | 212 ++++++++++++++++++ 5 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_fast_loop.cpp diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 71e87e038b6..fccb5325a7f 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -133,7 +133,9 @@ void EPaperBase::update() { } void EPaperBase::wait_for_idle_(bool should_wait) { - this->waiting_for_idle_start_ = millis(); + // The wait starts when the step's delay ends, or now for a wait in the middle of a step + const uint32_t now = millis(); + this->waiting_for_idle_start_ = static_cast(this->delay_until_ - now) > 0 ? this->delay_until_ : now; this->waiting_for_idle_ = should_wait; } @@ -147,19 +149,30 @@ void EPaperBase::loop() { auto now = millis(); // using modulus arithmetic to handle wrap-around int diff = now - this->delay_until_; - if (diff < 0) + if (diff < 0) { + // Running the loop continuously cannot shorten a delay longer than one loop interval + if (-diff > static_cast(App.get_loop_interval())) { + this->high_frequency_loop_.stop(); + } else { + this->high_frequency_loop_.start(); + } return; + } if (this->waiting_for_idle_) { if (this->is_idle_()) { this->waiting_for_idle_ = false; + this->high_frequency_loop_.start(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); #endif } else { - if (now - this->waiting_for_idle_start_ > this->busy_timeout_seconds_ * 1000u) { + const uint32_t busy_for = now - this->waiting_for_idle_start_; + if (busy_for > this->busy_timeout_seconds_ * 1000u) { this->abandon_update_(); return; } + if (busy_for > FAST_BUSY_POLL_MS) + this->high_frequency_loop_.stop(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE if (now - this->waiting_for_idle_last_print_ >= 1000) { ESP_LOGV(TAG, "Waiting for idle in state %s", this->epaper_state_to_string_()); @@ -170,6 +183,8 @@ void EPaperBase::loop() { } } this->process_state_(); + if (this->is_failed()) + this->high_frequency_loop_.stop(); } // The busy pin never cleared: give up on this update so later ones are not refused, and make the next one full @@ -275,16 +290,20 @@ void EPaperBase::process_state_() { void EPaperBase::set_state_(EPaperState state, uint16_t delay) { ESP_LOGV(TAG, "Exit state %s", this->epaper_state_to_string_()); this->state_ = state; - this->wait_for_idle_(state > EPaperState::SHOULD_WAIT); // allow subclasses to nominate delays if (delay == 0) delay = this->next_delay_; this->next_delay_ = 0; this->delay_until_ = millis() + delay; + this->wait_for_idle_(state > EPaperState::SHOULD_WAIT); ESP_LOGV(TAG, "Enter state %s, delay %u, wait_for_idle=%s", this->epaper_state_to_string_(), delay, TRUEFALSE(this->waiting_for_idle_)); + // Each step of an update otherwise waits for the next loop iteration; loop() slows down again for long waits if (state == EPaperState::IDLE) { + this->high_frequency_loop_.stop(); this->disable_loop(); + } else { + this->high_frequency_loop_.start(); } } diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index d953bf3df81..d6fe4238800 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -5,6 +5,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/split_buffer/split_buffer.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome::epaper_spi { using namespace display; @@ -31,6 +32,8 @@ static constexpr uint8_t SWAP_XY = 4; static constexpr uint32_t MAX_TRANSFER_TIME = 10; // Transfer in 10ms blocks to allow the loop to run static constexpr size_t MAX_TRANSFER_SIZE = 128; +// Longer busy waits are the waveform or a stuck pin, and are polled at the normal loop pace +static constexpr uint32_t FAST_BUSY_POLL_MS = 50; static constexpr uint8_t DELAY_FLAG = 0xFF; class EPaperBase : public Display, @@ -202,6 +205,7 @@ class EPaperBase : public Display, GPIOPin *reset_pin_{}; std::vector enable_pins_{}; bool waiting_for_idle_{}; + HighFrequencyLoopRequester high_frequency_loop_; uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state uint8_t transform_{}; diff --git a/tests/components/epaper_spi/common.h b/tests/components/epaper_spi/common.h index f21e674fde2..9631f1e0776 100644 --- a/tests/components/epaper_spi/common.h +++ b/tests/components/epaper_spi/common.h @@ -50,6 +50,14 @@ class RecordingPin : public GPIOPin { bool level{true}; }; +/// A busy pin the test can hold at "busy" and release. +class SwitchableBusyPin : public RecordingPin { + public: + bool digital_read() override { return this->busy; } + + bool busy{false}; +}; + /// SPI delegate that records what reaches the bus, filing each payload under the command it /// followed; commands are the bytes written while D/C is low. Optionally burns wall-clock time on /// each data row, so a transfer can be driven past its yield deadline. diff --git a/tests/components/epaper_spi/display/test_busy_timeout.cpp b/tests/components/epaper_spi/display/test_busy_timeout.cpp index 7080619b2ff..533c795f272 100644 --- a/tests/components/epaper_spi/display/test_busy_timeout.cpp +++ b/tests/components/epaper_spi/display/test_busy_timeout.cpp @@ -7,14 +7,8 @@ #include "esphome/components/epaper_spi/epaper_spi_t133a01.h" namespace esphome::epaper_spi::testing { - -/// A busy pin the test can hold at "busy" and release. -class SwitchableBusyPin : public RecordingPin { - public: - bool digital_read() override { return this->busy; } - - bool busy{false}; -}; +// Other test files define fixtures with the same names; keep these to this file +namespace { template void loop_until_idle(T &display) { const uint32_t deadline = millis() + 1000; @@ -142,4 +136,5 @@ TEST(EPaperBusyTimeout, AbandonedUpdatePowersThePanelOff) { << "the panel was not powered off (POF) when the update was abandoned"; } +} // namespace } // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_fast_loop.cpp b/tests/components/epaper_spi/display/test_fast_loop.cpp new file mode 100644 index 00000000000..3a2a905281e --- /dev/null +++ b/tests/components/epaper_spi/display/test_fast_loop.cpp @@ -0,0 +1,212 @@ +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" + +namespace esphome::epaper_spi::testing { +// Other test files define fixtures with the same names; keep these to this file +namespace { + +constexpr uint8_t MALFORMED_INIT_SEQUENCE[] = {0x01}; + +class TestableSSD1683 : public EPaperSSD1683 { + public: + explicit TestableSSD1683(const uint8_t *init_sequence = nullptr, size_t init_sequence_length = 0) + : EPaperSSD1683("test", 16, 2, init_sequence, init_sequence_length) {} + // The request count is shared by the whole test binary, so never leave it raised + ~TestableSSD1683() { this->high_frequency_loop_.stop(); } + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_busy_pin(&this->busy); + this->set_auto_clear(false); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void draw_whole_panel() { + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + void draw_nothing() { + this->x_low_ = this->width_; + this->y_low_ = this->height_; + this->x_high_ = 0; + this->y_high_ = 0; + } + + /// Runs the loop for the given time, or until the display is idle. + void loop_for(uint32_t ms) { + const uint32_t until = millis() + ms; + while (this->state_ != EPaperState::IDLE && millis() < until) + this->loop(); + } + + EPaperState state() const { return this->state_; } + + RecordingPin dc, reset; + SwitchableBusyPin busy; +}; + +/// With no long waits the loop runs continuously from the start of an update to its end, then goes back to its +/// normal pace. +TEST(EPaperFastLoop, LoopRunsContinuouslyForShortStepsAndStopsAfterward) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + ASSERT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.draw_whole_panel(); + display.update(); + bool always_fast = true; + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::IDLE && millis() < deadline) { + always_fast &= HighFrequencyLoopRequester::is_high_frequency(); + display.loop(); + } + + ASSERT_EQ(display.state(), EPaperState::IDLE); + EXPECT_TRUE(always_fast); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()) << "the loop was left running continuously"; +} + +/// An update with nothing drawn ends straight away and does not leave the loop running continuously. +TEST(EPaperFastLoop, UpdateWithNothingDrawnStopsTheFastLoop) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.draw_nothing(); + display.update(); + display.loop(); + + ASSERT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A display marked failed part way through an update is no longer looped, so it must stop the fast loop itself. +TEST(EPaperFastLoop, FailedDisplayStopsTheFastLoop) { + TestableSSD1683 display(MALFORMED_INIT_SEQUENCE, sizeof(MALFORMED_INIT_SEQUENCE)); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (!display.is_failed() && millis() < deadline) + display.loop(); + + ASSERT_TRUE(display.is_failed()); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A long busy wait, such as the waveform or a stuck busy pin, is polled at the normal pace. Once the pin clears, +/// the next steps run continuously again. +TEST(EPaperFastLoop, LongBusyWaitUsesTheNormalPace) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.busy.busy = true; + display.draw_whole_panel(); + display.update(); + display.loop_for(FAST_BUSY_POLL_MS + 20); + + ASSERT_NE(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.busy.busy = false; + display.loop(); + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); + display.loop_for(1000); + EXPECT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A delay longer than a loop interval, such as a long reset pulse, is waited out at the normal pace. +TEST(EPaperFastLoop, LongDelayUsesTheNormalPace) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_reset_pin(&display.reset); + display.set_reset_duration(200); + + display.draw_whole_panel(); + display.update(); + display.loop_for(50); + + ASSERT_EQ(display.state(), EPaperState::RESET_END); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.loop_for(1000); + EXPECT_EQ(display.state(), EPaperState::IDLE); + EXPECT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); +} + +/// A step that takes several loop passes gets the fast loop back once a long busy wait before it has ended. +TEST(EPaperFastLoop, FastLoopResumesWhenALongBusyWaitEnds) { + TestableSSD1683 display; + // every row of data overruns a transfer slice, so the transfer takes a loop pass per row + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); + display.install(&bus); + + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::TRANSFER_DATA && millis() < deadline) + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + display.busy.busy = true; + display.loop_for(FAST_BUSY_POLL_MS + 20); + ASSERT_FALSE(HighFrequencyLoopRequester::is_high_frequency()); + + display.busy.busy = false; + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA) << "the transfer should take more than one pass"; + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); +} + +class TestableT133A01 : public EPaperT133A01 { + public: + TestableT133A01() : EPaperT133A01("test", 8, 2, nullptr, 0) {} + ~TestableT133A01() { this->high_frequency_loop_.stop(); } + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_cs_pins(&this->cs, &this->cs1); + this->set_busy_pin(&this->busy); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void start_transfer() { this->set_state_(EPaperState::TRANSFER_DATA); } + + RecordingPin dc, cs, cs1; + SwitchableBusyPin busy; +}; + +/// A busy wait that starts in the middle of a step is polled continuously for its first moments, however long +/// the step has already run. +TEST(EPaperFastLoop, WaitInTheMiddleOfAStepIsPolledFast) { + TestableT133A01 display; + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + display.start_transfer(); + const uint32_t until = millis() + FAST_BUSY_POLL_MS + 20; + while (millis() < until) { + } + // the transfer waits for the busy pin after its first command, then sends a row and yields + display.loop(); + display.busy.busy = true; + display.loop(); + + EXPECT_TRUE(HighFrequencyLoopRequester::is_high_frequency()); +} + +} // namespace +} // namespace esphome::epaper_spi::testing From d1ec52e95a7506bbb4365d1f8db3aa37234e6df6 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:34:15 -0500 Subject: [PATCH 064/179] [sen6x] Add device status binary sensors (#18784) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/binary_sensor.py | 53 +++++++++++++ esphome/components/sen6x/sen6x.cpp | 90 ++++++++++++++++++++++- esphome/components/sen6x/sen6x.h | 35 +++++++++ esphome/core/defines.h | 1 + tests/components/sen6x/common.yaml | 18 +++++ 5 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sen6x/binary_sensor.py diff --git a/esphome/components/sen6x/binary_sensor.py b/esphome/components/sen6x/binary_sensor.py new file mode 100644 index 00000000000..9662e65a378 --- /dev/null +++ b/esphome/components/sen6x/binary_sensor.py @@ -0,0 +1,53 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .sensor import SEN6XComponent, sen6x_ns + +CONF_SEN6X_ID = "sen6x_id" +CONF_FAN_ERROR = "fan_error" +CONF_FAN_SPEED_WARNING = "fan_speed_warning" +CONF_RHT_ERROR = "rht_error" +CONF_GAS_ERROR = "gas_error" +CONF_CO2_ERROR = "co2_error" +CONF_HCHO_ERROR = "hcho_error" +CONF_PM_ERROR = "pm_error" + +Sen6xStatus = sen6x_ns.enum("Sen6xStatus", is_class=True) + +# Each key watches one bit of the device status register +_STATUS_KINDS = ( + (CONF_FAN_ERROR, Sen6xStatus.SEN6X_STATUS_FAN_ERROR), + (CONF_FAN_SPEED_WARNING, Sen6xStatus.SEN6X_STATUS_FAN_SPEED_WARNING), + (CONF_RHT_ERROR, Sen6xStatus.SEN6X_STATUS_RHT_ERROR), + (CONF_GAS_ERROR, Sen6xStatus.SEN6X_STATUS_GAS_ERROR), + (CONF_CO2_ERROR, Sen6xStatus.SEN6X_STATUS_CO2_ERROR), + (CONF_HCHO_ERROR, Sen6xStatus.SEN6X_STATUS_HCHO_ERROR), + (CONF_PM_ERROR, Sen6xStatus.SEN6X_STATUS_PM_ERROR), +) + +_request_status_slot = cg.slot_counter("SEN6X_STATUS_SENSOR_COUNT") + +_STATUS_SCHEMA = binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_SEN6X_ID): cv.use_id(SEN6XComponent), + **{cv.Optional(key): _STATUS_SCHEMA for key, _ in _STATUS_KINDS}, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_SEN6X_ID]) + for key, status in _STATUS_KINDS: + if (conf := config.get(key)) is None: + continue + sens = await binary_sensor.new_binary_sensor(conf) + _request_status_slot(str(hub)) + cg.add(hub.add_status_binary_sensor(sens, status)) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index aaad70dd940..9f95ed192c0 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -9,6 +9,29 @@ namespace esphome::sen6x { ESPHOME_LOG_TAG(TAG, "sen6x"); +#ifdef SEN6X_STATUS_SENSOR_COUNT +// Bit of the status register behind each kind; the CO2 error bit is 9 on SEN66 and 12 on SEN63C/SEN69C +static uint8_t status_bit(Sen6xStatus status, SEN6XComponent::Sen6xType type) { + switch (status) { + case Sen6xStatus::SEN6X_STATUS_FAN_ERROR: + return 4; + case Sen6xStatus::SEN6X_STATUS_FAN_SPEED_WARNING: + return 21; + case Sen6xStatus::SEN6X_STATUS_RHT_ERROR: + return 6; + case Sen6xStatus::SEN6X_STATUS_GAS_ERROR: + return 7; + case Sen6xStatus::SEN6X_STATUS_CO2_ERROR: + return type == SEN6XComponent::SEN66 ? 9 : 12; + case Sen6xStatus::SEN6X_STATUS_HCHO_ERROR: + return 10; + case Sen6xStatus::SEN6X_STATUS_PM_ERROR: + return 11; + } + return 0; +} +#endif + static constexpr uint8_t POLL_RETRIES = 24; // 24 attempts static constexpr uint32_t I2C_READ_DELAY = 20; // 20 ms to wait for I2C read to complete static constexpr uint32_t CMD_EXEC_DELAY = 20; // execution time of set commands (datasheet section 4.8) @@ -31,6 +54,7 @@ static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; static constexpr uint16_t SEN6X_CMD_GET_SERIAL_NUMBER = 0xD033; +static constexpr uint16_t SEN6X_CMD_READ_AND_CLEAR_DEVICE_STATUS = 0xD210; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT = 0x0300; // SEN66 only! static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN62 = 0x04A3; @@ -158,6 +182,22 @@ void SEN6XComponent::setup() { ESP_LOGE(TAG, "Formaldehyde requires SEN68 or SEN69C"); this->hcho_sensor_ = nullptr; } +#ifdef SEN6X_STATUS_SENSOR_COUNT + // A status sensor for a feature this variant lacks is dropped like the measurement sensors above, so + // an empty table skips the status read; the kept ones learn their register bit here + size_t kept = 0; + for (auto entry : this->status_sensors_) { + if ((entry.status == Sen6xStatus::SEN6X_STATUS_GAS_ERROR && !has_voc_nox) || + (entry.status == Sen6xStatus::SEN6X_STATUS_CO2_ERROR && !has_co2) || + (entry.status == Sen6xStatus::SEN6X_STATUS_HCHO_ERROR && !has_hcho)) { + ESP_LOGE(TAG, "'%s' needs a variant with that feature", entry.sensor->get_name().c_str()); + continue; + } + entry.bit = status_bit(entry.status, this->sen6x_type_); + this->status_sensors_[kept++] = entry; + } + this->status_sensors_.resize(kept); +#endif // Step 3: Read firmware version and start measurements in next loop iteration this->set_timeout(0, [this]() { @@ -340,6 +380,11 @@ void SEN6XComponent::dump_config() { LOG_SENSOR(" ", "NOx", this->nox_sensor_); LOG_SENSOR(" ", "HCHO", this->hcho_sensor_); LOG_SENSOR(" ", "CO2", this->co2_sensor_); +#ifdef SEN6X_STATUS_SENSOR_COUNT + for (const auto &entry : this->status_sensors_) { + LOG_BINARY_SENSOR(" ", "Device status", entry.sensor); + } +#endif } void SEN6XComponent::update() { @@ -363,6 +408,11 @@ void SEN6XComponent::update() { // Polling uses chained timeouts to guarantee each I2C operation completes // before the next begins. The flow is: // + // read_device_status_() + // with status sensors: write_command (read and clear device status) + // -> timeout I2C_READ_DELAY -> parse_and_publish_device_status_() + // -> poll_data_ready_() + // // poll_data_ready_() // -> write_command (data ready status) // -> timeout I2C_READ_DELAY @@ -380,10 +430,13 @@ void SEN6XComponent::update() { // All timeouts share a single ID (TIMEOUT_POLL) since only one is active // at a time. cancel_timeout in update() stops any in-flight chain. this->poll_retries_remaining_ = POLL_RETRIES; - this->poll_data_ready_(); + this->read_device_status_(); } void SEN6XComponent::poll_data_ready_() { + // stop_measurement() may have run from a publish callback while the chain was in flight + if (!this->measuring_) + return; if (this->poll_retries_remaining_ == 0) { this->status_set_warning(); ESP_LOGD(TAG, "Data not ready"); @@ -569,6 +622,41 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } +// Reads the status register first, so the error sensors update even when the measurement read fails, then runs +// the measurement poll chain. Read And Clear (0xD210) is used because error flags are sticky on the device; +// clearing each cycle makes the sensors report whether a problem occurred since the last update. +void SEN6XComponent::read_device_status_() { +#ifdef SEN6X_STATUS_SENSOR_COUNT + if (!this->status_sensors_.empty()) { + // The pressure write in update() may still be executing; the device rejects commands until it is done + if (const uint32_t wait = this->command_wait_remaining_(); wait != 0) { + this->set_timeout(TIMEOUT_POLL, wait, [this]() { this->read_device_status_(); }); + return; + } + if (this->write_command(SEN6X_CMD_READ_AND_CLEAR_DEVICE_STATUS)) { + this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { this->parse_and_publish_device_status_(); }); + return; + } + ESP_LOGD(TAG, "Read device status failed (%d)", this->last_error_); + } +#endif + this->poll_data_ready_(); +} + +#ifdef SEN6X_STATUS_SENSOR_COUNT +void SEN6XComponent::parse_and_publish_device_status_() { + uint16_t words[2]; + if (this->read_data(words, 2)) { + const uint32_t status = (static_cast(words[0]) << 16) | words[1]; + for (const auto &entry : this->status_sensors_) + entry.sensor->publish_state(((status >> entry.bit) & 1) != 0); + } else { + ESP_LOGD(TAG, "Read device status failed (%d)", this->last_error_); + } + this->poll_data_ready_(); +} +#endif + #ifdef USE_SEN6X_NUMBER_CONCENTRATION bool SEN6XComponent::has_number_concentration_sensor_() const { return this->pmc_0_5_sensor_ != nullptr || this->pmc_1_0_sensor_ != nullptr || this->pmc_2_5_sensor_ != nullptr || diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index f3054a8ffbd..e73f93f5a4c 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -4,9 +4,32 @@ #include "esphome/core/optional.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" +#ifdef SEN6X_STATUS_SENSOR_COUNT +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/helpers.h" +#endif namespace esphome::sen6x { +#ifdef SEN6X_STATUS_SENSOR_COUNT +// Bits of the device status register (datasheet section 4.3) a binary sensor can watch +enum class Sen6xStatus : uint8_t { + SEN6X_STATUS_FAN_ERROR, + SEN6X_STATUS_FAN_SPEED_WARNING, + SEN6X_STATUS_RHT_ERROR, + SEN6X_STATUS_GAS_ERROR, + SEN6X_STATUS_CO2_ERROR, + SEN6X_STATUS_HCHO_ERROR, + SEN6X_STATUS_PM_ERROR, +}; + +struct StatusSensor { + binary_sensor::BinarySensor *sensor; + Sen6xStatus status; + uint8_t bit; // of the status register, set at setup once the variant is known +}; +#endif + // The NOx algorithm requires std_initial to stay at 50 (Sensirion datasheet) static constexpr uint16_t NOX_STD_INITIAL = 50; @@ -91,6 +114,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void set_temperature_acceleration(uint16_t k, uint16_t p, uint16_t t1, uint16_t t2) { this->temperature_acceleration_ = TemperatureAcceleration{k, p, t1, t2}; } +#ifdef SEN6X_STATUS_SENSOR_COUNT + void add_status_binary_sensor(binary_sensor::BinarySensor *sensor, Sen6xStatus status) { + this->status_sensors_.push_back({sensor, status, 0}); + } +#endif void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } @@ -130,6 +158,10 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); + void read_device_status_(); +#ifdef SEN6X_STATUS_SENSOR_COUNT + void parse_and_publish_device_status_(); +#endif #ifdef USE_SEN6X_NUMBER_CONCENTRATION bool has_number_concentration_sensor_() const; void read_number_concentration_(); @@ -138,6 +170,9 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S std::string product_name_; std::string serial_number_; +#ifdef SEN6X_STATUS_SENSOR_COUNT + StaticVector status_sensors_; +#endif optional voc_tuning_params_; optional nox_tuning_params_; optional temperature_acceleration_; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 24f66a87518..935952d3b31 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -167,6 +167,7 @@ #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 #define LD2450_POLYGON_ZONE_COUNT 2 +#define SEN6X_STATUS_SENSOR_COUNT 1 #define UART_BINARY_SENSOR_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define MICS_5524_GAS_COUNT 5 diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index 6e39256d159..f6fee2cdf58 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -77,6 +77,24 @@ sensor: name: Formaldehyde address: 0x6B +binary_sensor: + - platform: sen6x + sen6x_id: sen6x_sensor + fan_error: + name: Fan Error + fan_speed_warning: + name: Fan Speed Warning + rht_error: + name: RHT Error + gas_error: + name: Gas Error + co2_error: + name: CO2 Error + hcho_error: + name: HCHO Error + pm_error: + name: PM Error + button: - platform: template name: Fan Cleaning From 89afb111ad86c087a7d60a0171068d610acaf318 Mon Sep 17 00:00:00 2001 From: C H R I S T I A N Date: Sun, 18 Oct 2026 02:06:46 +0200 Subject: [PATCH 065/179] [mt6701] Add MT6701 (MagnTek magnetic encoder) component (#17643) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/mt6701/__init__.py | 9 + esphome/components/mt6701/mt6701.cpp | 28 +++ esphome/components/mt6701/mt6701.h | 33 +++ esphome/components/mt6701/sensor/__init__.py | 51 +++++ .../mt6701/sensor/mt6701_sensor.cpp | 23 ++ .../components/mt6701/sensor/mt6701_sensor.h | 19 ++ esphome/components/mt6701_i2c/__init__.py | 202 ++++++++++++++++++ esphome/components/mt6701_i2c/mt6701_i2c.cpp | 184 ++++++++++++++++ esphome/components/mt6701_i2c/mt6701_i2c.h | 79 +++++++ esphome/components/mt6701_spi/__init__.py | 47 ++++ .../mt6701_spi/binary_sensor/__init__.py | 46 ++++ .../mt6701_spi_binary_sensors.cpp | 29 +++ .../binary_sensor/mt6701_spi_binary_sensors.h | 25 +++ esphome/components/mt6701_spi/mt6701_spi.cpp | 67 ++++++ esphome/components/mt6701_spi/mt6701_spi.h | 41 ++++ .../mt6701_spi/text_sensor/__init__.py | 36 ++++ .../text_sensor/mt6701_spi_field_status.cpp | 28 +++ .../text_sensor/mt6701_spi_field_status.h | 19 ++ tests/component_tests/mt6701/__init__.py | 0 .../mt6701/config/mt6701_i2c_modes.yaml | 19 ++ .../config/mt6701_i2c_modes_uvw_pwm.yaml | 21 ++ tests/component_tests/mt6701/test_mt6701.py | 95 ++++++++ tests/components/mt6701_i2c/common.yaml | 33 +++ .../components/mt6701_i2c/test.esp32-ard.yaml | 3 + .../mt6701_i2c/test.esp32-c3-idf.yaml | 3 + .../components/mt6701_i2c/test.esp32-idf.yaml | 3 + .../mt6701_i2c/test.esp8266-ard.yaml | 3 + tests/components/mt6701_i2c/test.host.yaml | 3 + .../mt6701_i2c/test.rp2040-ard.yaml | 3 + tests/components/mt6701_spi/__init__.py | 6 + tests/components/mt6701_spi/common.yaml | 27 +++ .../mt6701_spi/mt6701_spi_frame_test.cpp | 96 +++++++++ .../components/mt6701_spi/test.esp32-ard.yaml | 6 + .../mt6701_spi/test.esp32-c3-idf.yaml | 6 + .../components/mt6701_spi/test.esp32-idf.yaml | 6 + .../mt6701_spi/test.esp8266-ard.yaml | 6 + .../mt6701_spi/test.rp2040-ard.yaml | 6 + 38 files changed, 1314 insertions(+) create mode 100644 esphome/components/mt6701/__init__.py create mode 100644 esphome/components/mt6701/mt6701.cpp create mode 100644 esphome/components/mt6701/mt6701.h create mode 100644 esphome/components/mt6701/sensor/__init__.py create mode 100644 esphome/components/mt6701/sensor/mt6701_sensor.cpp create mode 100644 esphome/components/mt6701/sensor/mt6701_sensor.h create mode 100644 esphome/components/mt6701_i2c/__init__.py create mode 100644 esphome/components/mt6701_i2c/mt6701_i2c.cpp create mode 100644 esphome/components/mt6701_i2c/mt6701_i2c.h create mode 100644 esphome/components/mt6701_spi/__init__.py create mode 100644 esphome/components/mt6701_spi/binary_sensor/__init__.py create mode 100644 esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp create mode 100644 esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h create mode 100644 esphome/components/mt6701_spi/mt6701_spi.cpp create mode 100644 esphome/components/mt6701_spi/mt6701_spi.h create mode 100644 esphome/components/mt6701_spi/text_sensor/__init__.py create mode 100644 esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp create mode 100644 esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h create mode 100644 tests/component_tests/mt6701/__init__.py create mode 100644 tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml create mode 100644 tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml create mode 100644 tests/component_tests/mt6701/test_mt6701.py create mode 100644 tests/components/mt6701_i2c/common.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-ard.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-c3-idf.yaml create mode 100644 tests/components/mt6701_i2c/test.esp32-idf.yaml create mode 100644 tests/components/mt6701_i2c/test.esp8266-ard.yaml create mode 100644 tests/components/mt6701_i2c/test.host.yaml create mode 100644 tests/components/mt6701_i2c/test.rp2040-ard.yaml create mode 100644 tests/components/mt6701_spi/__init__.py create mode 100644 tests/components/mt6701_spi/common.yaml create mode 100644 tests/components/mt6701_spi/mt6701_spi_frame_test.cpp create mode 100644 tests/components/mt6701_spi/test.esp32-ard.yaml create mode 100644 tests/components/mt6701_spi/test.esp32-c3-idf.yaml create mode 100644 tests/components/mt6701_spi/test.esp32-idf.yaml create mode 100644 tests/components/mt6701_spi/test.esp8266-ard.yaml create mode 100644 tests/components/mt6701_spi/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 539730a4a67..0def07d0237 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -428,6 +428,9 @@ esphome/components/mpl3115a2/* @kbickar esphome/components/mpu6886/* @fabaff esphome/components/ms8607/* @e28eta esphome/components/msa3xx/* @latonita +esphome/components/mt6701/* @slimcdk +esphome/components/mt6701_i2c/* @slimcdk +esphome/components/mt6701_spi/* @slimcdk esphome/components/nau7802/* @cujomalainey esphome/components/network/* @esphome/core esphome/components/nextion/* @edwardtfn @senexcrenshaw diff --git a/esphome/components/mt6701/__init__.py b/esphome/components/mt6701/__init__.py new file mode 100644 index 00000000000..9325b8299f1 --- /dev/null +++ b/esphome/components/mt6701/__init__.py @@ -0,0 +1,9 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@slimcdk"] +DOMAIN = "mt6701" + +mt6701_ns = cg.esphome_ns.namespace("mt6701") +MT6701Component = mt6701_ns.class_("MT6701Component", cg.Component) + +CONF_MT6701_ID = "mt6701_id" diff --git a/esphome/components/mt6701/mt6701.cpp b/esphome/components/mt6701/mt6701.cpp new file mode 100644 index 00000000000..46316b8e551 --- /dev/null +++ b/esphome/components/mt6701/mt6701.cpp @@ -0,0 +1,28 @@ +#include "mt6701.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701 { + +bool MT6701Component::read_encoder() { + if (this->is_failed() || this->suspend_sampling_) + return false; + + uint16_t count; + if (!this->read_count(count)) { + if (this->consecutive_errors_ < MAX_CONSECUTIVE_ERRORS) { + this->consecutive_errors_++; + if (this->consecutive_errors_ == MAX_CONSECUTIVE_ERRORS) + this->status_set_warning(LOG_STR("repeated read/CRC errors")); + } + return false; + } + if (this->consecutive_errors_ != 0) { + this->consecutive_errors_ = 0; + this->status_clear_warning(); + } + + this->count_ = count; + return true; +} + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/mt6701.h b/esphome/components/mt6701/mt6701.h new file mode 100644 index 00000000000..4795441f167 --- /dev/null +++ b/esphome/components/mt6701/mt6701.h @@ -0,0 +1,33 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::mt6701 { + +// A 14-bit absolute angle: one revolution is 16384 counts +static constexpr float COUNTS_TO_DEGREES = 360.0f / 16384; +// Consecutive read or CRC failures tolerated before the component reports a warning +static constexpr uint8_t MAX_CONSECUTIVE_ERRORS = 5; + +/// Bus-independent core; the I2C and SSI hubs provide read_count(), the entities poll it +class MT6701Component : public Component { + public: + /// Read a fresh angle; false on a bus or CRC error, while suspended, or when setup failed + bool read_encoder(); + + /// Most recent absolute angle as a raw count in the range [0, 16383]. + uint16_t get_count() const { return this->count_; } + /// Most recent absolute shaft angle in degrees, in the range [0, 360). + float get_angle_degrees() const { return this->count_ * COUNTS_TO_DEGREES; } + + protected: + /// The current 14-bit count from the bus; false on a bus or CRC error + virtual bool read_count(uint16_t &count) = 0; + + uint16_t count_{0}; + uint8_t consecutive_errors_{0}; + // Set by the I2C hub while the EEPROM is programmed; no bus access until it clears + bool suspend_sampling_{false}; +}; + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/sensor/__init__.py b/esphome/components/mt6701/sensor/__init__.py new file mode 100644 index 00000000000..617524b1917 --- /dev/null +++ b/esphome/components/mt6701/sensor/__init__.py @@ -0,0 +1,51 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ICON_ROTATE_RIGHT, + STATE_CLASS_MEASUREMENT, + UNIT_DEGREES, + UNIT_EMPTY, +) +from esphome.types import ConfigType + +from .. import CONF_MT6701_ID, MT6701Component, mt6701_ns + +AUTO_LOAD = ["mt6701"] + +MT6701Sensor = mt6701_ns.class_("MT6701Sensor", sensor.Sensor, cg.PollingComponent) + +CONF_RAW_COUNT = "raw_count" + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + MT6701Sensor, + unit_of_measurement=UNIT_DEGREES, + accuracy_decimals=2, + icon=ICON_ROTATE_RIGHT, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.GenerateID(CONF_MT6701_ID): cv.use_id(MT6701Component), + cv.Optional(CONF_RAW_COUNT): sensor.sensor_schema( + unit_of_measurement=UNIT_EMPTY, + accuracy_decimals=0, + icon=ICON_ROTATE_RIGHT, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_MT6701_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_RAW_COUNT, var.set_raw_count_sensor) diff --git a/esphome/components/mt6701/sensor/mt6701_sensor.cpp b/esphome/components/mt6701/sensor/mt6701_sensor.cpp new file mode 100644 index 00000000000..c849ef91397 --- /dev/null +++ b/esphome/components/mt6701/sensor/mt6701_sensor.cpp @@ -0,0 +1,23 @@ +#include "mt6701_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701 { + +ESPHOME_LOG_TAG(TAG, "mt6701.sensor"); + +void MT6701Sensor::update() { + if (!this->parent_->read_encoder()) + return; + + this->publish_state(this->parent_->get_angle_degrees()); + if (this->raw_count_sensor_ != nullptr) + this->raw_count_sensor_->publish_state(this->parent_->get_count()); +} + +void MT6701Sensor::dump_config() { + LOG_SENSOR("", "MT6701 Sensor", this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Raw Count", this->raw_count_sensor_); +} + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701/sensor/mt6701_sensor.h b/esphome/components/mt6701/sensor/mt6701_sensor.h new file mode 100644 index 00000000000..2bf03dd7225 --- /dev/null +++ b/esphome/components/mt6701/sensor/mt6701_sensor.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/mt6701/mt6701.h" + +namespace esphome::mt6701 { + +/// The shaft angle in degrees, with the raw count as an optional sub-sensor +class MT6701Sensor final : public PollingComponent, public Parented, public sensor::Sensor { + SUB_SENSOR(raw_count) + + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::mt6701 diff --git a/esphome/components/mt6701_i2c/__init__.py b/esphome/components/mt6701_i2c/__init__.py new file mode 100644 index 00000000000..17734e00459 --- /dev/null +++ b/esphome/components/mt6701_i2c/__init__.py @@ -0,0 +1,202 @@ +from typing import Any + +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_DIRECTION, CONF_HYSTERESIS, CONF_ID, CONF_MODE +from esphome.types import ConfigType + +from ..mt6701 import MT6701Component + +CODEOWNERS = ["@slimcdk"] +AUTO_LOAD = ["mt6701"] +DEPENDENCIES = ["i2c"] +DOMAIN = "mt6701_i2c" +MULTI_CONF = True + +mt6701_i2c_ns = cg.esphome_ns.namespace("mt6701_i2c") +MT6701I2CComponent = mt6701_i2c_ns.class_( + "MT6701I2CComponent", MT6701Component, i2c.I2CDevice +) + +CONF_ZERO_OFFSET = "zero_offset" +CONF_OUTPUT_MODE = "output_mode" +CONF_ABZ = "abz" +CONF_PULSES_PER_REVOLUTION = "pulses_per_revolution" +CONF_Z_PULSE_WIDTH = "z_pulse_width" +CONF_UVW = "uvw" +CONF_POLE_PAIRS = "pole_pairs" +CONF_OUT_PIN = "out_pin" +CONF_PWM_FREQUENCY = "pwm_frequency" +CONF_PWM_POLARITY = "pwm_polarity" +CONF_ANALOG_START = "analog_start" +CONF_ANALOG_STOP = "analog_stop" + +# DIR bit: 1 counts clockwise +DIRECTION = { + "CLOCKWISE": 1, + "COUNTERCLOCKWISE": 0, +} + +# Output LSBs to the HYST register code +HYSTERESIS = { + 0: 4, + 0.25: 5, + 0.5: 6, + 1: 0, + 2: 1, + 4: 2, + 8: 3, +} + +Z_PULSE_WIDTH = { + "1LSB": 0, + "2LSB": 1, + "4LSB": 2, + "8LSB": 3, + "12LSB": 4, + "16LSB": 5, + "180DEG": 6, +} + +OUTPUT_MODE = { + "ABZ": 0, + "UVW": 1, +} + +OUT_PIN_MODE = { + "ANALOG": 0, + "PWM": 1, +} + +PWM_FREQUENCY = { + "994.4HZ": 0, + "497.2HZ": 1, +} + +PWM_POLARITY = { + "HIGH": 0, + "LOW": 1, +} + +# Zero offset and analog start/stop are 12-bit positions over one revolution +RESOLUTION_12BIT = 4096 +MAX_POSITION_12BIT = RESOLUTION_12BIT - 1 +ANGLE_TO_POSITION_12 = RESOLUTION_12BIT / 360 + + +def _angle_to_position_12(value: Any) -> int: + value = cv.float_range(min=0, max=360)( + cv.float_with_unit("angle", "(°|deg)")(value) + ) + # 360° is not representable; clamp instead of wrapping to 0 + return min(round(value * ANGLE_TO_POSITION_12), MAX_POSITION_12BIT) + + +def _percent_to_position_12(value: Any) -> int: + value = cv.percentage(value) + return min(round(value * RESOLUTION_12BIT), MAX_POSITION_12BIT) + + +def position12(value: Any) -> int: + """A 12-bit position: a raw count, an angle (``45deg``, ``90°``) or a share of a turn (``25%``).""" + if isinstance(value, str) and value.endswith("%"): + return _percent_to_position_12(value) + if isinstance(value, str) and value.endswith(("°", "deg")): + return _angle_to_position_12(value) + return cv.int_range(min=0, max=MAX_POSITION_12BIT)(value) + + +def validate_hysteresis(value: Any) -> int: + value = cv.float_(value) + if value not in HYSTERESIS: + raise cv.Invalid( + f"Hysteresis must be one of {sorted(HYSTERESIS)} (LSBs), got {value}" + ) + return HYSTERESIS[value] + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MT6701I2CComponent), + cv.Optional(CONF_DIRECTION): cv.enum(DIRECTION, upper=True), + cv.Optional(CONF_ZERO_OFFSET): position12, + cv.Optional(CONF_HYSTERESIS): validate_hysteresis, + cv.Optional(CONF_OUTPUT_MODE): cv.enum(OUTPUT_MODE, upper=True), + cv.Optional(CONF_ABZ): cv.Schema( + { + cv.Optional(CONF_PULSES_PER_REVOLUTION): cv.int_range( + min=1, max=1024 + ), + cv.Optional(CONF_Z_PULSE_WIDTH): cv.enum(Z_PULSE_WIDTH, upper=True), + } + ), + cv.Optional(CONF_UVW): cv.Schema( + { + cv.Required(CONF_POLE_PAIRS): cv.int_range(min=1, max=16), + } + ), + cv.Optional(CONF_OUT_PIN): cv.Schema( + { + cv.Required(CONF_MODE): cv.enum(OUT_PIN_MODE, upper=True), + cv.Optional(CONF_PWM_FREQUENCY): cv.enum(PWM_FREQUENCY, upper=True), + cv.Optional(CONF_PWM_POLARITY): cv.enum(PWM_POLARITY, upper=True), + cv.Optional(CONF_ANALOG_START): position12, + cv.Optional(CONF_ANALOG_STOP): position12, + } + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x06)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + if (direction := config.get(CONF_DIRECTION)) is not None: + cg.add(var.set_direction(direction)) + if (zero_offset := config.get(CONF_ZERO_OFFSET)) is not None: + cg.add(var.set_zero_offset(zero_offset)) + if (hysteresis := config.get(CONF_HYSTERESIS)) is not None: + cg.add(var.set_hysteresis(hysteresis)) + if (output_mode := config.get(CONF_OUTPUT_MODE)) is not None: + cg.add(var.set_output_mode(output_mode)) + + if abz := config.get(CONF_ABZ): + if (ppr := abz.get(CONF_PULSES_PER_REVOLUTION)) is not None: + cg.add(var.set_abz_pulses_per_revolution(ppr)) + if (width := abz.get(CONF_Z_PULSE_WIDTH)) is not None: + cg.add(var.set_z_pulse_width(width)) + + if uvw := config.get(CONF_UVW): + cg.add(var.set_uvw_pole_pairs(uvw[CONF_POLE_PAIRS])) + + if out_pin := config.get(CONF_OUT_PIN): + cg.add(var.set_out_pin_mode(out_pin[CONF_MODE])) + if (freq := out_pin.get(CONF_PWM_FREQUENCY)) is not None: + cg.add(var.set_pwm_frequency(freq)) + if (pol := out_pin.get(CONF_PWM_POLARITY)) is not None: + cg.add(var.set_pwm_polarity(pol)) + if (start := out_pin.get(CONF_ANALOG_START)) is not None: + cg.add(var.set_analog_start(start)) + if (stop := out_pin.get(CONF_ANALOG_STOP)) is not None: + cg.add(var.set_analog_stop(stop)) + + +MT6701_SAVE_EEPROM_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(MT6701I2CComponent), + } +) + +automation.register_apply_action( + "mt6701_i2c.save_eeprom", + MT6701_SAVE_EEPROM_ACTION_SCHEMA, + automation.ApplyCall("save_eeprom()"), +) diff --git a/esphome/components/mt6701_i2c/mt6701_i2c.cpp b/esphome/components/mt6701_i2c/mt6701_i2c.cpp new file mode 100644 index 00000000000..cf1094ee225 --- /dev/null +++ b/esphome/components/mt6701_i2c/mt6701_i2c.cpp @@ -0,0 +1,184 @@ +#include "mt6701_i2c.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_i2c { + +ESPHOME_LOG_TAG(TAG, "mt6701_i2c"); + +// Registers apply_config_() may write, each read-modify-written once +static const uint8_t CONFIG_REGS[] PROGMEM = { + REG_ABZ_MUX_DIR, REG_RES_H, REG_ABZ_RES_L, REG_CONFIG_H, REG_ZERO_L, + REG_HYST_L, REG_OUT, REG_A_HIGH, REG_A_START_L, REG_A_STOP_L, +}; +static constexpr uint8_t CONFIG_REG_COUNT = sizeof(CONFIG_REGS); + +void MT6701I2CComponent::setup() { + uint16_t count; + if (!this->read_count(count)) { + this->mark_failed(LOG_STR("not responding on I2C")); + return; + } + this->apply_config_(); +} + +void MT6701I2CComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 (I2C):"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + if (this->direction_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Direction bit: %u", this->direction_); + } + if (this->zero_offset_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Zero offset: %u", this->zero_offset_); + } + if (this->hysteresis_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Hysteresis code: %u", this->hysteresis_); + } + if (this->output_mode_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Output mode: %s", this->output_mode_ != 0 ? LOG_STR_LITERAL("UVW") : LOG_STR_LITERAL("ABZ")); + } + if (this->abz_pulses_per_revolution_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " ABZ pulses/rev: %u", this->abz_pulses_per_revolution_); + } + if (this->z_pulse_width_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Z pulse width code: %u", this->z_pulse_width_); + } + if (this->uvw_pole_pairs_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " UVW pole pairs: %u", this->uvw_pole_pairs_); + } + if (this->out_pin_mode_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " Output pin mode: %s", + this->out_pin_mode_ != 0 ? LOG_STR_LITERAL("PWM") : LOG_STR_LITERAL("analog")); + } + if (this->pwm_frequency_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " PWM frequency code: %u", this->pwm_frequency_); + } + if (this->pwm_polarity_ != UNSET_U8) { + ESP_LOGCONFIG(TAG, " PWM polarity code: %u", this->pwm_polarity_); + } + if (this->analog_start_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Analog start: %u", this->analog_start_); + } + if (this->analog_stop_ != UNSET_U16) { + ESP_LOGCONFIG(TAG, " Analog stop: %u", this->analog_stop_); + } +} + +bool MT6701I2CComponent::read_count(uint16_t &count) { + // Single-byte reads, 0x03 first; datasheet 1.9 documents no burst read + uint8_t high; + uint8_t low; + if (this->read_register(REG_ANGLE_H, &high, 1) != i2c::ERROR_OK || + this->read_register(REG_ANGLE_L, &low, 1) != i2c::ERROR_OK) + return false; + count = encode_uint16(high, low) >> 2; + return true; +} + +bool MT6701I2CComponent::update_register_(uint8_t reg, uint8_t mask, uint8_t value) { + uint8_t current; + if (this->read_register(reg, ¤t, 1) != i2c::ERROR_OK) + return false; + uint8_t updated = (current & ~mask) | (value & mask); + if (updated == current) + return true; + return this->write_register(reg, &updated, 1) == i2c::ERROR_OK; +} + +void MT6701I2CComponent::apply_config_() { + // Collect the bits per register first; several options share one register + uint8_t masks[CONFIG_REG_COUNT] = {}; + uint8_t values[CONFIG_REG_COUNT] = {}; + auto add = [&masks, &values](uint8_t reg, uint8_t mask, uint8_t value) { + for (uint8_t i = 0; i < CONFIG_REG_COUNT; i++) { + if (progmem_read_byte(&CONFIG_REGS[i]) == reg) { + masks[i] |= mask; + values[i] |= value & mask; + return; + } + } + }; + + if (this->direction_ != UNSET_U8) + add(REG_ABZ_MUX_DIR, 0x02, this->direction_ << 1); + if (this->output_mode_ != UNSET_U8) + add(REG_ABZ_MUX_DIR, 0x40, (this->output_mode_ & 0x01) << 6); + if (this->zero_offset_ != UNSET_U16) { + add(REG_CONFIG_H, 0x0F, this->zero_offset_ >> 8); + add(REG_ZERO_L, 0xFF, this->zero_offset_ & 0xFF); + } + if (this->hysteresis_ != UNSET_U8) { + add(REG_CONFIG_H, 0x80, (this->hysteresis_ & 0x04) << 5); // HYST[2] -> bit7 + add(REG_HYST_L, 0xC0, (this->hysteresis_ & 0x03) << 6); // HYST[1:0] -> bits7:6 + } + if (this->abz_pulses_per_revolution_ != UNSET_U16) { + // ABZ_RES holds pulses per revolution minus 1 + uint16_t res = this->abz_pulses_per_revolution_ - 1; + add(REG_RES_H, 0x03, res >> 8); + add(REG_ABZ_RES_L, 0xFF, res & 0xFF); + } + if (this->z_pulse_width_ != UNSET_U8) + add(REG_CONFIG_H, 0x70, (this->z_pulse_width_ & 0x07) << 4); + if (this->uvw_pole_pairs_ != UNSET_U8) { + // UVW_RES holds pole pairs minus 1 + add(REG_RES_H, 0xF0, (this->uvw_pole_pairs_ - 1) << 4); + } + if (this->out_pin_mode_ != UNSET_U8) + add(REG_OUT, 0x20, (this->out_pin_mode_ & 0x01) << 5); + if (this->pwm_frequency_ != UNSET_U8) + add(REG_OUT, 0x80, (this->pwm_frequency_ & 0x01) << 7); + if (this->pwm_polarity_ != UNSET_U8) + add(REG_OUT, 0x40, (this->pwm_polarity_ & 0x01) << 6); + if (this->analog_start_ != UNSET_U16) { + add(REG_A_HIGH, 0x0F, this->analog_start_ >> 8); + add(REG_A_START_L, 0xFF, this->analog_start_ & 0xFF); + } + if (this->analog_stop_ != UNSET_U16) { + add(REG_A_HIGH, 0xF0, (this->analog_stop_ >> 8) << 4); + add(REG_A_STOP_L, 0xFF, this->analog_stop_ & 0xFF); + } + + bool ok = true; + for (uint8_t i = 0; i < CONFIG_REG_COUNT; i++) { + if (masks[i] != 0) + ok &= this->update_register_(progmem_read_byte(&CONFIG_REGS[i]), masks[i], values[i]); + } + if (!ok) { + // A half-configured chip must not be sampled or have its EEPROM saved + this->mark_failed(LOG_STR("configuration register write failed")); + } +} + +void MT6701I2CComponent::save_eeprom() { + if (this->is_failed()) { + // A failed component gets no timeouts, so the bus would stay suspended + ESP_LOGW(TAG, "Not programming EEPROM: component is marked failed"); + return; + } + if (this->suspend_sampling_) { + ESP_LOGW(TAG, "EEPROM programming already in progress"); + return; + } + ESP_LOGW(TAG, "Programming MT6701 EEPROM. The EEPROM has a limited number of write " + "cycles, so avoid doing this repeatedly. Supply must be 4.5-5.5 V."); + this->suspend_sampling_ = true; + + bool ok = this->write_byte(REG_EEPROM_KEY, EEPROM_KEY_VALUE) && this->write_byte(REG_EEPROM_CMD, EEPROM_CMD_VALUE); + if (!ok) { + ESP_LOGE(TAG, "Failed to start EEPROM programming"); + this->suspend_sampling_ = false; + return; + } + + // suspend_sampling_ keeps a second save out, so the timer needs no name + this->set_timeout(EEPROM_PROGRAM_DELAY_MS, [this]() { + this->suspend_sampling_ = false; + ESP_LOGI(TAG, "EEPROM programming finished. Power-cycle the MT6701 to verify the stored configuration"); + }); +} + +} // namespace esphome::mt6701_i2c diff --git a/esphome/components/mt6701_i2c/mt6701_i2c.h b/esphome/components/mt6701_i2c/mt6701_i2c.h new file mode 100644 index 00000000000..7d2d91d6986 --- /dev/null +++ b/esphome/components/mt6701_i2c/mt6701_i2c.h @@ -0,0 +1,79 @@ +#pragma once + +#include "esphome/components/mt6701/mt6701.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::mt6701_i2c { + +// Angle: D[13:6] at 0x03, D[5:0] in bits 7:2 of 0x04 +static constexpr uint8_t REG_ANGLE_H = 0x03; +static constexpr uint8_t REG_ANGLE_L = 0x04; + +// EEPROM programming (datasheet 8.2) needs more than 600 ms left alone afterwards +static constexpr uint8_t REG_EEPROM_KEY = 0x09; +static constexpr uint8_t REG_EEPROM_CMD = 0x0A; +static constexpr uint8_t EEPROM_KEY_VALUE = 0xB3; +static constexpr uint8_t EEPROM_CMD_VALUE = 0x05; +static constexpr uint32_t EEPROM_PROGRAM_DELAY_MS = 700; + +// Configuration registers; reserved bits make every write a read-modify-write +static constexpr uint8_t REG_ABZ_MUX_DIR = 0x29; // bit6 ABZ_MUX, bit1 DIR +static constexpr uint8_t REG_RES_H = 0x30; // bits7:4 UVW_RES, bits1:0 ABZ_RES[9:8] +static constexpr uint8_t REG_ABZ_RES_L = 0x31; // ABZ_RES[7:0] +static constexpr uint8_t REG_CONFIG_H = 0x32; // bit7 HYST[2], bits6:4 Z_PULSE_WIDTH, bits3:0 ZERO[11:8] +static constexpr uint8_t REG_ZERO_L = 0x33; // ZERO[7:0] +static constexpr uint8_t REG_HYST_L = 0x34; // bits7:6 HYST[1:0] +static constexpr uint8_t REG_OUT = 0x38; // bit7 PWM_FREQ, bit6 PWM_POL, bit5 OUT_MODE +static constexpr uint8_t REG_A_HIGH = 0x3E; // bits7:4 A_STOP[11:8], bits3:0 A_START[11:8] +static constexpr uint8_t REG_A_START_L = 0x3F; // A_START[7:0] +static constexpr uint8_t REG_A_STOP_L = 0x40; // A_STOP[7:0] + +/// MT6701 over I2C, with the configuration registers +class MT6701I2CComponent final : public mt6701::MT6701Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + // Options left unset are never written, so the chip keeps its EEPROM defaults + void set_direction(uint8_t direction) { this->direction_ = direction; } + void set_zero_offset(uint16_t position) { this->zero_offset_ = position; } + void set_hysteresis(uint8_t hysteresis) { this->hysteresis_ = hysteresis; } + void set_output_mode(uint8_t output_mode) { this->output_mode_ = output_mode; } + void set_abz_pulses_per_revolution(uint16_t pulses) { this->abz_pulses_per_revolution_ = pulses; } + void set_z_pulse_width(uint8_t width) { this->z_pulse_width_ = width; } + void set_uvw_pole_pairs(uint8_t pole_pairs) { this->uvw_pole_pairs_ = pole_pairs; } + void set_out_pin_mode(uint8_t out_pin_mode) { this->out_pin_mode_ = out_pin_mode; } + void set_pwm_frequency(uint8_t pwm_frequency) { this->pwm_frequency_ = pwm_frequency; } + void set_pwm_polarity(uint8_t pwm_polarity) { this->pwm_polarity_ = pwm_polarity; } + void set_analog_start(uint16_t position) { this->analog_start_ = position; } + void set_analog_stop(uint16_t position) { this->analog_stop_ = position; } + + /// Write the registers to the EEPROM: pauses sampling for the programming time. + /// The EEPROM wears, so only an explicit action calls this. + void save_eeprom(); + + protected: + bool read_count(uint16_t &count) override; + + void apply_config_(); + bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); + + // Unset options sit above their field's range + static constexpr uint16_t UNSET_U16 = 0xFFFF; + static constexpr uint8_t UNSET_U8 = 0xFF; + + uint16_t zero_offset_{UNSET_U16}; + uint16_t abz_pulses_per_revolution_{UNSET_U16}; + uint16_t analog_start_{UNSET_U16}; + uint16_t analog_stop_{UNSET_U16}; + uint8_t direction_{UNSET_U8}; + uint8_t hysteresis_{UNSET_U8}; + uint8_t output_mode_{UNSET_U8}; + uint8_t z_pulse_width_{UNSET_U8}; + uint8_t uvw_pole_pairs_{UNSET_U8}; + uint8_t out_pin_mode_{UNSET_U8}; + uint8_t pwm_frequency_{UNSET_U8}; + uint8_t pwm_polarity_{UNSET_U8}; +}; + +} // namespace esphome::mt6701_i2c diff --git a/esphome/components/mt6701_spi/__init__.py b/esphome/components/mt6701_spi/__init__.py new file mode 100644 index 00000000000..6f45e347b7d --- /dev/null +++ b/esphome/components/mt6701_spi/__init__.py @@ -0,0 +1,47 @@ +import esphome.codegen as cg +from esphome.components import spi +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from ..mt6701 import MT6701Component + +CODEOWNERS = ["@slimcdk"] +AUTO_LOAD = ["mt6701"] +DEPENDENCIES = ["spi"] +DOMAIN = "mt6701_spi" +MULTI_CONF = True + +mt6701_spi_ns = cg.esphome_ns.namespace("mt6701_spi") +MT6701SPIComponent = mt6701_spi_ns.class_( + "MT6701SPIComponent", MT6701Component, spi.SPIDevice +) + +CONF_MT6701_SPI_ID = "mt6701_spi_id" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MT6701SPIComponent), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend( + spi.spi_device_schema( + cs_pin_required=True, + default_data_rate=1_000_000, + default_mode="MODE1", + ) + ) +) + +# SSI only reads, so the bus needs its input line +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + "mt6701_spi", require_mosi=False, require_miso=True +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await spi.register_spi_device(var, config) diff --git a/esphome/components/mt6701_spi/binary_sensor/__init__.py b/esphome/components/mt6701_spi/binary_sensor/__init__.py new file mode 100644 index 00000000000..f06dc31aa5f --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/__init__.py @@ -0,0 +1,46 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_UPDATE_INTERVAL, + DEVICE_CLASS_PROBLEM, + ENTITY_CATEGORY_DIAGNOSTIC, +) +from esphome.types import ConfigType + +from .. import CONF_MT6701_SPI_ID, MT6701SPIComponent, mt6701_spi_ns + +DEPENDENCIES = ["mt6701_spi"] + +CONF_PUSH_BUTTON = "push_button" +CONF_TRACK_LOSS = "track_loss" + +MT6701SPIBinarySensors = mt6701_spi_ns.class_( + "MT6701SPIBinarySensors", cg.Component, cg.Parented.template(MT6701SPIComponent) +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(MT6701SPIBinarySensors), + cv.GenerateID(CONF_MT6701_SPI_ID): cv.use_id(MT6701SPIComponent), + # A press has to be seen between two reads, so the default is short + cv.Optional( + CONF_UPDATE_INTERVAL, default="50ms" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_PUSH_BUTTON): binary_sensor.binary_sensor_schema(), + cv.Optional(CONF_TRACK_LOSS): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_MT6701_SPI_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_PUSH_BUTTON, var.set_push_button_binary_sensor) + await binary_sensors(CONF_TRACK_LOSS, var.set_track_loss_binary_sensor) diff --git a/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp new file mode 100644 index 00000000000..9af88d056e2 --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.cpp @@ -0,0 +1,29 @@ +#include "mt6701_spi_binary_sensors.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi.binary_sensor"); + +void MT6701SPIBinarySensors::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_read_ < this->update_interval_) + return; + this->last_read_ = now; + if (!this->parent_->read_encoder()) + return; + const uint8_t status = this->parent_->get_status(); + if (this->push_button_binary_sensor_ != nullptr) + this->push_button_binary_sensor_->publish_state((status & 0x04) != 0); + if (this->track_loss_binary_sensor_ != nullptr) + this->track_loss_binary_sensor_->publish_state((status & 0x08) != 0); +} + +void MT6701SPIBinarySensors::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 status:\n Update Interval: %" PRIu32 " ms", this->update_interval_); + LOG_BINARY_SENSOR(" ", "Push button", this->push_button_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Track loss", this->track_loss_binary_sensor_); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h new file mode 100644 index 00000000000..646f6341c40 --- /dev/null +++ b/esphome/components/mt6701_spi/binary_sensor/mt6701_spi_binary_sensors.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/mt6701_spi/mt6701_spi.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::mt6701_spi { + +// Reads the encoder at its own interval and publishes the status flags of each frame +class MT6701SPIBinarySensors : public Component, public Parented { + SUB_BINARY_SENSOR(push_button) + SUB_BINARY_SENSOR(track_loss) + + public: + void set_update_interval(uint32_t update_interval) { this->update_interval_ = update_interval; } + void loop() override; + void dump_config() override; + + protected: + uint32_t update_interval_{50}; + uint32_t last_read_{0}; +}; + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/mt6701_spi.cpp b/esphome/components/mt6701_spi/mt6701_spi.cpp new file mode 100644 index 00000000000..493b7ded3f7 --- /dev/null +++ b/esphome/components/mt6701_spi/mt6701_spi.cpp @@ -0,0 +1,67 @@ +#include "mt6701_spi.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi"); + +// The 6-bit CRC (x^6 + x + 1) over 18 bits is crc8 with the polynomial shifted up by two +static constexpr uint8_t CRC6_AS_CRC8_POLY = 0x0C; + +bool decode_frame(const uint8_t frame[3], uint16_t &count, uint8_t &status) { + if (crc8(frame, 3, 0x00, CRC6_AS_CRC8_POLY, true) != 0) + return false; + const uint32_t bits = encode_uint24(frame[0], frame[1], frame[2]); + count = bits >> 10; + status = (bits >> 6) & 0x0F; + return true; +} + +ProbeResult probe_result(uint8_t valid_frames, bool any_nonzero) { + if (valid_frames < PROBE_MIN_VALID) + return ProbeResult::PROBE_RESULT_NO_ENCODER; + return any_nonzero ? ProbeResult::PROBE_RESULT_OK : ProbeResult::PROBE_RESULT_ALL_ZERO; +} + +void MT6701SPIComponent::setup() { + this->spi_setup(); + // All-zero frames only warn: an encoder at rest looks like a stuck-low data line + uint8_t valid = 0; + bool nonzero = false; + for (uint8_t i = 0; i < PROBE_FRAMES && (valid < PROBE_MIN_VALID || !nonzero); i++) { + uint16_t count; + if (this->read_count(count)) { + valid++; + nonzero |= count != 0 || this->status_ != 0; + } + } + switch (probe_result(valid, nonzero)) { + case ProbeResult::PROBE_RESULT_NO_ENCODER: + this->mark_failed(LOG_STR("no valid frame received, check the wiring and CS pin")); + break; + case ProbeResult::PROBE_RESULT_ALL_ZERO: + ESP_LOGW(TAG, "Every frame read zero: the data line may be stuck low"); + break; + case ProbeResult::PROBE_RESULT_OK: + break; + } +} + +void MT6701SPIComponent::dump_config() { + ESP_LOGCONFIG(TAG, "MT6701 (SSI/SPI):"); + LOG_PIN(" CS Pin: ", this->cs_); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +bool MT6701SPIComponent::read_count(uint16_t &count) { + uint8_t buffer[3] = {0, 0, 0}; + this->enable(); + this->read_array(buffer, 3); + this->disable(); + return decode_frame(buffer, count, this->status_); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/mt6701_spi.h b/esphome/components/mt6701_spi/mt6701_spi.h new file mode 100644 index 00000000000..fed7217a1e0 --- /dev/null +++ b/esphome/components/mt6701_spi/mt6701_spi.h @@ -0,0 +1,41 @@ +#pragma once + +#include "esphome/components/mt6701/mt6701.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::mt6701_spi { + +/// Decode a 24-bit SSI frame (14-bit angle, 4 status bits, 6-bit CRC); false when the CRC fails +bool decode_frame(const uint8_t frame[3], uint16_t &count, uint8_t &status); + +// Noise passes the 6-bit CRC 1 time in 64, so setup wants several good frames +static constexpr uint8_t PROBE_FRAMES = 10; +static constexpr uint8_t PROBE_MIN_VALID = 3; + +enum class ProbeResult : uint8_t { + PROBE_RESULT_NO_ENCODER, + PROBE_RESULT_ALL_ZERO, // a stuck-low data line, or an encoder at rest + PROBE_RESULT_OK, +}; +ProbeResult probe_result(uint8_t valid_frames, bool any_nonzero); + +/// MT6701 over SSI: read-only, but each frame carries the field, push button and track-loss flags. +/// Data shifts out on the rising clock edge, so it is sampled on the falling edge with the clock +/// idle low; a clock idling high reads an extra leading bit. +class MT6701SPIComponent final : public mt6701::MT6701Component, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + + /// The 4-bit status of the last good frame: bit3 track loss, bit2 push button, bits1:0 field strength + uint8_t get_status() const { return this->status_; } + + protected: + bool read_count(uint16_t &count) override; + + uint8_t status_{0}; +}; + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/text_sensor/__init__.py b/esphome/components/mt6701_spi/text_sensor/__init__.py new file mode 100644 index 00000000000..bbec4a40e4f --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_MAGNET +from esphome.types import ConfigType + +from .. import CONF_MT6701_SPI_ID, MT6701SPIComponent, mt6701_spi_ns + +DEPENDENCIES = ["mt6701_spi"] + +MT6701SPIFieldStatus = mt6701_spi_ns.class_( + "MT6701SPIFieldStatus", + text_sensor.TextSensor, + cg.PollingComponent, + cg.Parented.template(MT6701SPIComponent), +) + +CONFIG_SCHEMA = ( + text_sensor.text_sensor_schema( + MT6701SPIFieldStatus, + icon=ICON_MAGNET, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ) + .extend( + { + cv.GenerateID(CONF_MT6701_SPI_ID): cv.use_id(MT6701SPIComponent), + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = await text_sensor.new_text_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_MT6701_SPI_ID]) diff --git a/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp new file mode 100644 index 00000000000..5db30c4b077 --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.cpp @@ -0,0 +1,28 @@ +#include "mt6701_spi_field_status.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::mt6701_spi { + +ESPHOME_LOG_TAG(TAG, "mt6701_spi.text_sensor"); + +// Indexed by Mg[1:0]; 0b11 is reserved in the datasheet +PROGMEM_STRING_TABLE(FieldStatusStrings, "OK", "TOO_STRONG", "TOO_WEAK", "UNKNOWN"); + +void MT6701SPIFieldStatus::update() { + if (!this->parent_->read_encoder()) + return; + char text[FieldStatusStrings::MAX_LENGTH + 1]; + ESPHOME_strncpy_P(text, + reinterpret_cast(FieldStatusStrings::get_progmem_str( + this->parent_->get_status() & 0x03, FieldStatusStrings::LAST_INDEX)), + sizeof(text)); + this->publish_state(text); +} + +void MT6701SPIFieldStatus::dump_config() { + LOG_TEXT_SENSOR("", "MT6701 field status", this); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::mt6701_spi diff --git a/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h new file mode 100644 index 00000000000..200cc78a7fb --- /dev/null +++ b/esphome/components/mt6701_spi/text_sensor/mt6701_spi_field_status.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/mt6701_spi/mt6701_spi.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::mt6701_spi { + +// Reports the magnetic field strength the status bits Mg[1:0] carry +class MT6701SPIFieldStatus : public text_sensor::TextSensor, + public PollingComponent, + public Parented { + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::mt6701_spi diff --git a/tests/component_tests/mt6701/__init__.py b/tests/component_tests/mt6701/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml b/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml new file mode 100644 index 00000000000..135ee0087ac --- /dev/null +++ b/tests/component_tests/mt6701/config/mt6701_i2c_modes.yaml @@ -0,0 +1,19 @@ +esphome: + name: mt6701-modes-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2c: + - id: i2c_bus + sda: GPIO21 + scl: GPIO22 + +mt6701_i2c: + id: encoder_hub + i2c_id: i2c_bus + output_mode: ABZ + out_pin: + mode: ANALOG diff --git a/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml b/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml new file mode 100644 index 00000000000..a509bc481df --- /dev/null +++ b/tests/component_tests/mt6701/config/mt6701_i2c_modes_uvw_pwm.yaml @@ -0,0 +1,21 @@ +esphome: + name: mt6701-modes-uvw-pwm-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +i2c: + - id: i2c_bus + sda: GPIO21 + scl: GPIO22 + +mt6701_i2c: + id: encoder_hub + i2c_id: i2c_bus + output_mode: UVW + uvw: + pole_pairs: 7 + out_pin: + mode: PWM diff --git a/tests/component_tests/mt6701/test_mt6701.py b/tests/component_tests/mt6701/test_mt6701.py new file mode 100644 index 00000000000..05a9ff93286 --- /dev/null +++ b/tests/component_tests/mt6701/test_mt6701.py @@ -0,0 +1,95 @@ +"""Config-validation tests for the mt6701 encoder component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.mt6701_i2c import DIRECTION, position12, validate_hysteresis +import esphome.config_validation as cv + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # raw 12-bit counts pass through unchanged + (0, 0), + (2048, 2048), + (4095, 4095), + # angles are converted to counts + ("0deg", 0), + ("45deg", 512), + ("90°", 1024), + # a full turn is not representable and must clamp to the max, not wrap to 0 + ("360deg", 4095), + # percentages of a full revolution + ("25%", 1024), + ("50%", 2048), + ("100%", 4095), + ], +) +def test_position12_valid(value: int | str, expected: int) -> None: + assert position12(value) == expected + + +@pytest.mark.parametrize("value", [-1, 4096, 5000, "400deg", "-10deg"]) +def test_position12_rejects_out_of_range(value: int | str) -> None: + with pytest.raises(cv.Invalid): + position12(value) + + +@pytest.mark.parametrize( + ("lsb", "register_code"), + [(0, 4), (0.25, 5), (0.5, 6), (1, 0), (2, 1), (4, 2), (8, 3)], +) +def test_hysteresis_maps_lsb_to_register_code(lsb: float, register_code: int) -> None: + assert validate_hysteresis(lsb) == register_code + + +@pytest.mark.parametrize("value", [3, 5, 16]) +def test_hysteresis_rejects_unsupported_step(value: int) -> None: + with pytest.raises(cv.Invalid): + validate_hysteresis(value) + + +def test_direction_follows_datasheet_semantics() -> None: + # Datasheet section 8.1: DIR=1 makes the angle increase clockwise. + assert DIRECTION["CLOCKWISE"] == 1 + assert DIRECTION["COUNTERCLOCKWISE"] == 0 + + +@pytest.mark.parametrize( + ("config_file", "output_mode_call", "out_pin_call"), + [ + # ABZ and ANALOG are register code 0 + ( + "mt6701_i2c_modes.yaml", + "set_output_mode(0)", + "set_out_pin_mode(0)", + ), + # UVW and PWM are register code 1 + ( + "mt6701_i2c_modes_uvw_pwm.yaml", + "set_output_mode(1)", + "set_out_pin_mode(1)", + ), + ], +) +def test_enum_options_generate_register_codes( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + output_mode_call: str, + out_pin_call: str, +) -> None: + """cv.enum returns the key string; codegen must emit its register code. + + Both codes are pinned, so that hardcoding a constant or reading a different + field is caught. + """ + main_cpp = generate_main(component_config_path(config_file)) + + assert output_mode_call in main_cpp + assert out_pin_call in main_cpp diff --git a/tests/components/mt6701_i2c/common.yaml b/tests/components/mt6701_i2c/common.yaml new file mode 100644 index 00000000000..fbcf4512f08 --- /dev/null +++ b/tests/components/mt6701_i2c/common.yaml @@ -0,0 +1,33 @@ +mt6701_i2c: + id: mt6701_i2c_encoder + i2c_id: i2c_bus + address: 0x06 + direction: CLOCKWISE + zero_offset: 45deg + hysteresis: 1 + output_mode: ABZ + abz: + pulses_per_revolution: 1024 + z_pulse_width: 1LSB + uvw: + pole_pairs: 8 + out_pin: + mode: PWM + pwm_frequency: 994.4Hz + pwm_polarity: HIGH + analog_start: 0deg + analog_stop: 360deg + +sensor: + - platform: mt6701 + mt6701_id: mt6701_i2c_encoder + name: MT6701 I2C Angle + update_interval: 100ms + raw_count: + name: MT6701 I2C Raw Count + +button: + - platform: template + name: MT6701 I2C Save EEPROM + on_press: + - mt6701_i2c.save_eeprom: mt6701_i2c_encoder diff --git a/tests/components/mt6701_i2c/test.esp32-ard.yaml b/tests/components/mt6701_i2c/test.esp32-ard.yaml new file mode 100644 index 00000000000..dced81081e2 --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml b/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml new file mode 100644 index 00000000000..3837e73028f --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-c3-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-c3-idf.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp32-idf.yaml b/tests/components/mt6701_i2c/test.esp32-idf.yaml new file mode 100644 index 00000000000..a4ce3b5d391 --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.esp8266-ard.yaml b/tests/components/mt6701_i2c/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7639027368f --- /dev/null +++ b/tests/components/mt6701_i2c/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.host.yaml b/tests/components/mt6701_i2c/test.host.yaml new file mode 100644 index 00000000000..dd5d5faf9d7 --- /dev/null +++ b/tests/components/mt6701_i2c/test.host.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/host.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_i2c/test.rp2040-ard.yaml b/tests/components/mt6701_i2c/test.rp2040-ard.yaml new file mode 100644 index 00000000000..06082480a6a --- /dev/null +++ b/tests/components/mt6701_i2c/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + mt6701_i2c: !include common.yaml diff --git a/tests/components/mt6701_spi/__init__.py b/tests/components/mt6701_spi/__init__.py new file mode 100644 index 00000000000..7937e0202db --- /dev/null +++ b/tests/components/mt6701_spi/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The frame tests call the decoder directly; no bus is driven + manifest.dependencies = manifest.dependencies + ["spi"] diff --git a/tests/components/mt6701_spi/common.yaml b/tests/components/mt6701_spi/common.yaml new file mode 100644 index 00000000000..093ba44dfed --- /dev/null +++ b/tests/components/mt6701_spi/common.yaml @@ -0,0 +1,27 @@ +mt6701_spi: + id: mt6701_spi_encoder + spi_id: spi_bus + cs_pin: ${cs_pin} + +sensor: + - platform: mt6701 + mt6701_id: mt6701_spi_encoder + name: MT6701 SPI Angle + update_interval: 100ms + raw_count: + name: MT6701 SPI Raw Count + +binary_sensor: + - platform: mt6701_spi + mt6701_spi_id: mt6701_spi_encoder + update_interval: 20ms + push_button: + name: MT6701 SPI Button + track_loss: + name: MT6701 SPI Track Loss + +text_sensor: + - platform: mt6701_spi + mt6701_spi_id: mt6701_spi_encoder + name: MT6701 SPI Field Status + update_interval: 10s diff --git a/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp b/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp new file mode 100644 index 00000000000..43392601aca --- /dev/null +++ b/tests/components/mt6701_spi/mt6701_spi_frame_test.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/mt6701_spi/mt6701_spi.h" + +namespace esphome::mt6701_spi::testing { + +namespace { + +// The datasheet's CRC: x^6 + x + 1 over the 18 data bits, most significant bit first, zero seed +uint8_t crc6(uint32_t data18) { + uint8_t crc = 0; + for (int bit = 17; bit >= 0; bit--) { + const uint8_t in = ((data18 >> bit) & 1) ^ ((crc >> 5) & 1); + crc = ((crc << 1) & 0x3F) ^ (in ? 0x03 : 0); + } + return crc; +} + +// A frame as the chip sends it: 14 angle bits, 4 status bits, 6 CRC bits +std::array make_frame(uint16_t count, uint8_t status) { + const uint32_t data18 = (static_cast(count & 0x3FFF) << 4) | (status & 0x0F); + const uint32_t frame = (data18 << 6) | crc6(data18); + return {static_cast(frame >> 16), static_cast(frame >> 8), static_cast(frame)}; +} + +} // namespace + +TEST(MT6701SpiFrame, DecodesANonZeroReading) { + const auto frame = make_frame(0x1234, 0b0101); + uint16_t count = 0; + uint8_t status = 0; + ASSERT_TRUE(decode_frame(frame.data(), count, status)); + EXPECT_EQ(count, 0x1234); + EXPECT_EQ(status, 0b0101); +} + +TEST(MT6701SpiFrame, AZeroReadingIsValid) { + const auto frame = make_frame(0, 0); + EXPECT_EQ(frame, (std::array{0, 0, 0})); + uint16_t count = 1; + uint8_t status = 1; + ASSERT_TRUE(decode_frame(frame.data(), count, status)); + EXPECT_EQ(count, 0); + EXPECT_EQ(status, 0); +} + +TEST(MT6701SpiFrame, EveryAngleAndStatusRoundTrips) { + for (uint32_t count = 0; count < 16384; count += 37) { + for (uint8_t status = 0; status < 16; status++) { + const auto frame = make_frame(count, status); + uint16_t got_count = 0; + uint8_t got_status = 0; + ASSERT_TRUE(decode_frame(frame.data(), got_count, got_status)) << count << " " << status; + EXPECT_EQ(got_count, count); + EXPECT_EQ(got_status, status); + } + } +} + +TEST(MT6701SpiFrame, ACorruptedBitFailsTheCrc) { + const auto good = make_frame(0x2ACE, 0b1010); + for (int bit = 0; bit < 24; bit++) { + auto frame = good; + frame[2 - bit / 8] ^= 1 << (bit % 8); + uint16_t count = 0; + uint8_t status = 0; + EXPECT_FALSE(decode_frame(frame.data(), count, status)) << "bit " << bit; + } +} + +TEST(MT6701SpiFrame, StatusBitsAreTheFieldPushAndTrackFlags) { + uint16_t count = 0; + uint8_t status = 0; + // Mg[1:0] = 2 (field too weak), Mg[2] push button, Mg[3] loss of track + ASSERT_TRUE(decode_frame(make_frame(100, 0b1110).data(), count, status)); + EXPECT_EQ(status & 0x03, 2); + EXPECT_TRUE(status & 0x04); + EXPECT_TRUE(status & 0x08); +} + +TEST(MT6701SpiProbe, ThreeGoodFramesProveTheEncoder) { + EXPECT_EQ(probe_result(0, false), ProbeResult::PROBE_RESULT_NO_ENCODER); + EXPECT_EQ(probe_result(PROBE_MIN_VALID - 1, true), ProbeResult::PROBE_RESULT_NO_ENCODER); + EXPECT_EQ(probe_result(PROBE_MIN_VALID, true), ProbeResult::PROBE_RESULT_OK); + EXPECT_EQ(probe_result(PROBE_FRAMES, true), ProbeResult::PROBE_RESULT_OK); +} + +TEST(MT6701SpiProbe, AllZeroFramesOnlyWarn) { + EXPECT_EQ(probe_result(PROBE_MIN_VALID, false), ProbeResult::PROBE_RESULT_ALL_ZERO); + EXPECT_EQ(probe_result(PROBE_FRAMES, false), ProbeResult::PROBE_RESULT_ALL_ZERO); +} + +} // namespace esphome::mt6701_spi::testing diff --git a/tests/components/mt6701_spi/test.esp32-ard.yaml b/tests/components/mt6701_spi/test.esp32-ard.yaml new file mode 100644 index 00000000000..6d06dd7bc59 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-ard.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp32-c3-idf.yaml b/tests/components/mt6701_spi/test.esp32-c3-idf.yaml new file mode 100644 index 00000000000..c9de669b984 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-c3-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO7 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-c3-idf.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp32-idf.yaml b/tests/components/mt6701_spi/test.esp32-idf.yaml new file mode 100644 index 00000000000..642c4d90300 --- /dev/null +++ b/tests/components/mt6701_spi/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.esp8266-ard.yaml b/tests/components/mt6701_spi/test.esp8266-ard.yaml new file mode 100644 index 00000000000..e7b3a4401da --- /dev/null +++ b/tests/components/mt6701_spi/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO15 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + mt6701_spi: !include common.yaml diff --git a/tests/components/mt6701_spi/test.rp2040-ard.yaml b/tests/components/mt6701_spi/test.rp2040-ard.yaml new file mode 100644 index 00000000000..23435b8fb1f --- /dev/null +++ b/tests/components/mt6701_spi/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + mt6701_spi: !include common.yaml From 928cf7d69671b7f6971ece696cfa8bcf77915612 Mon Sep 17 00:00:00 2001 From: Michele Porelli Date: Sat, 17 Oct 2026 17:59:40 -0700 Subject: [PATCH 066/179] [libretiny] Emulate rtc preference storage on the Beken BK7231T, BK7231N and BK7238 with flash and the reset reason (#21007) --- .../components/libretiny/preference_backend.h | 10 ++ esphome/components/libretiny/preferences.cpp | 95 +++++++++++++++++-- esphome/components/libretiny/preferences.h | 11 ++- esphome/core/defines.h | 1 + esphome/preferences.py | 42 ++++++-- tests/unit_tests/test_preferences.py | 53 +++++++++++ 6 files changed, 192 insertions(+), 20 deletions(-) diff --git a/esphome/components/libretiny/preference_backend.h b/esphome/components/libretiny/preference_backend.h index f7f8279ac08..00d76d7d862 100644 --- a/esphome/components/libretiny/preference_backend.h +++ b/esphome/components/libretiny/preference_backend.h @@ -1,6 +1,8 @@ #pragma once #ifdef USE_LIBRETINY +#include "esphome/core/defines.h" + #include #include @@ -20,6 +22,14 @@ class LibreTinyPreferenceBackend final { uint32_t key; struct fdb_kvdb *db; struct fdb_blob *blob; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + // storage: rtc lives under its own FlashDB name prefix, so the database can drop every rtc entry + // at once after a boot that RTC memory would not have survived + bool rtc{false}; + bool is_rtc() const { return this->rtc; } +#else + static constexpr bool is_rtc() { return false; } +#endif }; class LibreTinyPreferences; diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index a868e5042e2..9d21e88b14b 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -4,6 +4,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include #include namespace esphome::libretiny { @@ -13,20 +14,58 @@ ESPHOME_LOG_TAG(TAG, "preferences"); struct NVSData { uint32_t key; SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.) +#ifdef USE_LIBRETINY_RTC_PREFERENCES + bool rtc; + bool is_rtc() const { return this->rtc; } +#else + static constexpr bool is_rtc() { return false; } +#endif }; static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// The FlashDB name of a key: its decimal digits, with a prefix for storage: rtc so one pass over the +// database finds every rtc entry. Without rtc storage is_rtc() is a constant false and the branch folds away. +static constexpr size_t KEY_NAME_SIZE = UINT32_MAX_STR_SIZE + 1; +static constexpr char RTC_KEY_PREFIX = 'r'; +static void key_name(char (&buf)[KEY_NAME_SIZE], uint32_t key, bool rtc) { + char *digits = buf; + if (rtc) + *digits++ = RTC_KEY_PREFIX; + uint32_to_str(std::span(digits, UINT32_MAX_STR_SIZE), key); +} + +#ifdef USE_LIBRETINY_RTC_PREFERENCES +// Whether this boot followed a reset that RTC memory survives on ESP32 and ESP8266. LibreTiny chips +// have no such memory, so storage: rtc keeps preferences in flash and uses this to forget them. +static bool boot_kept_rtc_preferences() { + switch (lt_get_reboot_reason()) { + case REBOOT_REASON_SOFTWARE: + case REBOOT_REASON_WATCHDOG: + case REBOOT_REASON_CRASH: + case REBOOT_REASON_SLEEP_GPIO: + case REBOOT_REASON_SLEEP_RTC: + case REBOOT_REASON_SLEEP_USB: + return true; + default: // power-on, brown-out, reset pin, debugger, unknown + return false; + } +} +#endif // USE_LIBRETINY_RTC_PREFERENCES + bool LibreTinyPreferenceBackend::save(const uint8_t *data, size_t len) { - // try find in pending saves and update that + // try find in pending saves and update that; a flash and an rtc entry may share a key for (auto &obj : s_pending_save) { - if (obj.key == this->key) { + if (obj.key == this->key && obj.is_rtc() == this->is_rtc()) { obj.data.set(data, len); return true; } } NVSData save{}; save.key = this->key; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + save.rtc = this->rtc; +#endif save.data.set(data, len); s_pending_save.push_back(std::move(save)); ESP_LOGVV(TAG, "s_pending_save: key: %" PRIu32 ", len: %zu", this->key, len); @@ -36,7 +75,7 @@ bool LibreTinyPreferenceBackend::save(const uint8_t *data, size_t len) { bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) { // try find in pending saves and load from that for (auto &obj : s_pending_save) { - if (obj.key == this->key) { + if (obj.key == this->key && obj.is_rtc() == this->is_rtc()) { if (obj.data.size() != len) { // size mismatch return false; @@ -46,8 +85,8 @@ bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) { } } - char key_str[UINT32_MAX_STR_SIZE]; - uint32_to_str(key_str, this->key); + char key_str[KEY_NAME_SIZE]; + key_name(key_str, this->key, this->is_rtc()); fdb_blob_make(this->blob, data, len); size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob); if (actual_len != len) { @@ -64,21 +103,57 @@ void LibreTinyPreferences::open() { fdb_err_t err = fdb_kvdb_init(&this->db, "esphome", "kvs", NULL, NULL); if (err != FDB_NO_ERR) { LT_E("fdb_kvdb_init(...) failed: %d", err); - } else { - LT_I("Preferences initialized"); + return; + } + LT_I("Preferences initialized"); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + if (!boot_kept_rtc_preferences()) + this->discard_rtc_entries_(); +#endif +} + +#ifdef USE_LIBRETINY_RTC_PREFERENCES +ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type, bool in_flash) { + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type, !in_flash))); +} + +// One pass over the database deletes every rtc entry. FlashDB only marks a deleted entry's status in place +// and its iterator steps by address, so deleting the current entry is safe. This runs before the logger +// exists, so it logs through LibreTiny; a failed delete is a flash fault the next write will report as well. +void LibreTinyPreferences::discard_rtc_entries_() { + uint16_t discarded = 0; + fdb_kv_iterator iterator; + fdb_kv_iterator_init(&iterator); + while (fdb_kv_iterate(&this->db, &iterator)) { + const char *name = iterator.curr_kv.name; + if (name[0] != RTC_KEY_PREFIX) + continue; + if (const fdb_err_t err = fdb_kv_del(&this->db, name); err != FDB_NO_ERR) { + LT_E("Failed to discard rtc preference %s: %d", name, err); + continue; + } + discarded++; + } + if (discarded != 0) { + LT_D("Discarded %u rtc preferences after a hard reset", discarded); } } +#endif ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type) { // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type))); } -LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type) { +LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type, bool rtc) { LibreTinyPreferenceBackend backend; backend.key = type; backend.db = &this->db; backend.blob = &this->blob; +#ifdef USE_LIBRETINY_RTC_PREFERENCES + backend.rtc = rtc; +#endif return backend; } @@ -97,8 +172,8 @@ bool LibreTinyPreferences::sync() { uint32_t last_key = 0; for (const auto &save : s_pending_save) { - char key_str[UINT32_MAX_STR_SIZE]; - uint32_to_str(key_str, save.key); + char key_str[KEY_NAME_SIZE]; + key_name(key_str, save.key, save.is_rtc()); ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str); if (this->is_changed_(&this->db, save, key_str)) { ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size()); diff --git a/esphome/components/libretiny/preferences.h b/esphome/components/libretiny/preferences.h index fd86c48b20d..4b3737f07fd 100644 --- a/esphome/components/libretiny/preferences.h +++ b/esphome/components/libretiny/preferences.h @@ -1,6 +1,7 @@ #pragma once #ifdef USE_LIBRETINY +#include "esphome/core/defines.h" #include "esphome/core/preference_backend.h" #include @@ -12,9 +13,14 @@ class LibreTinyPreferences final : public PreferencesMixin public: using PreferencesMixin::make_preference; void open(); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash); +#else + // Without rtc storage every preference lives in flash; inline so builds that don't use rtc pay nothing ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { return this->make_preference(length, type); } +#endif ESPPreferenceObject make_preference(size_t length, uint32_t type); /// One-shot read of a stored preference by key, without allocating a backend bool load_from_key(uint32_t type, uint8_t *data, size_t len); @@ -25,8 +31,11 @@ class LibreTinyPreferences final : public PreferencesMixin struct fdb_blob blob; protected: - LibreTinyPreferenceBackend make_backend_(uint32_t type); + LibreTinyPreferenceBackend make_backend_(uint32_t type, bool rtc = false); bool is_changed_(fdb_kvdb_t db, const NVSData &to_save, const char *key_str); +#ifdef USE_LIBRETINY_RTC_PREFERENCES + void discard_rtc_entries_(); +#endif }; void setup_preferences(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 935952d3b31..852ea3439a9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -717,6 +717,7 @@ #else #define USE_BK72XX_BLE_TRACKER #endif +#define USE_LIBRETINY_RTC_PREFERENCES #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_SUSPEND_LOOP #define USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/preferences.py b/esphome/preferences.py index c9a465d6252..b31dcecf874 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -3,8 +3,11 @@ Preferences can be stored either in flash (durable across power loss) or in RTC memory (fast, survives deep sleep and soft resets but not power loss). The flash-vs-RTC choice is only meaningful on platforms whose preferences backend -honors the ``in_flash`` flag — currently ESP32 and ESP8266. On other platforms -the value is accepted only as ``flash`` (the sole supported backend). +honors the ``in_flash`` flag — currently ESP32, ESP8266 and the Beken BK7231T, +BK7231N and BK7238. LibreTiny chips have no RTC memory: there ``rtc`` keeps the +preference in flash and discards it after a reset that RTC memory would not +survive. On other platforms the value is accepted only as ``flash`` (the sole +supported backend). Components include :func:`storage_schema` in their config and convert the chosen value with :func:`is_in_flash` when calling ``make_preference``. @@ -20,7 +23,7 @@ STORAGE_RTC = "rtc" def rtc_supported() -> bool: - """Whether the target has RTC-backed preferences (not ESP32-C2/C61); call at validation time.""" + """Whether the target has RTC-backed preferences (ESP8266, ESP32 except C2/C61, some Beken chips); call at validation time.""" if CORE.is_esp8266: return True if CORE.is_esp32: @@ -29,7 +32,26 @@ def rtc_supported() -> bool: # Mirrors the C++ SOC_RTC_MEM_SUPPORTED guard in the ESP32 backend return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) - return False + return _libretiny_rtc_supported() + + +def _libretiny_rtc_supported() -> bool: + """Whether this LibreTiny family accepts ``rtc``, emulated with flash plus the reset reason. + + The emulation is only as good as the family's ``lt_get_reboot_reason()``, so families are + listed once that reporting has been checked on hardware: a chip-enable reset and a power cut + both report a power-on, a software, watchdog or crash reset does not. + """ + if not CORE.is_libretiny: + return False + from esphome.components.libretiny import get_libretiny_family + from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231T, + FAMILY_BK7238, + ) + + return get_libretiny_family() in (FAMILY_BK7231T, FAMILY_BK7231N, FAMILY_BK7238) def _default_storage() -> str: @@ -58,15 +80,17 @@ def storage_schema(): def request_rtc_storage() -> None: - """Compile the RTC-backed storage into the ESP32 preferences backend. + """Compile the RTC-backed storage into the ESP32 or LibreTiny preferences backend. - The RTC storage region is left out of ESP32 builds unless something asks for - it, so unused builds don't reserve RTC memory. Call this from ``to_code`` - when a config option selects RTC storage. No-op on other platforms (ESP8266 - always has its RTC backend). + It is left out of ESP32 and LibreTiny builds unless something asks for it, so + unused builds don't reserve RTC memory or check the reset reason. Call this + from ``to_code`` when a config option selects RTC storage. No-op on other + platforms (ESP8266 always has its RTC backend). """ if CORE.is_esp32: cg.add_define("USE_ESP32_RTC_PREFERENCES") + elif _libretiny_rtc_supported(): + cg.add_define("USE_LIBRETINY_RTC_PREFERENCES") def validate_rtc_storage(value): diff --git a/tests/unit_tests/test_preferences.py b/tests/unit_tests/test_preferences.py index a1ab78bfbff..da56aa8d6fa 100644 --- a/tests/unit_tests/test_preferences.py +++ b/tests/unit_tests/test_preferences.py @@ -10,15 +10,26 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C3, VARIANT_ESP32C61, ) +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, + FAMILY_RTL8710B, + KEY_FAMILY, + KEY_LIBRETINY, +) import esphome.config_validation as cv from esphome.const import ( CONF_STORAGE, KEY_CORE, KEY_TARGET_PLATFORM, KEY_VARIANT, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, + PLATFORM_RTL87XX, ) from esphome.core import CORE @@ -191,3 +202,45 @@ def test_validate_storage_rejects_unknown_value() -> None: _set_esp32(VARIANT_ESP32) with pytest.raises(cv.Invalid): preferences.validate_storage("nvs") + + +def _set_libretiny(platform: str, family: str) -> None: + _set_platform(platform) + CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family} + + +@pytest.mark.parametrize("family", [FAMILY_BK7231T, FAMILY_BK7231N, FAMILY_BK7238]) +def test_rtc_allowed_on_tested_beken_families(family: str) -> None: + # Emulated with flash plus the reset reason, which was checked on hardware. + _set_libretiny(PLATFORM_BK72XX, family) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize( + ("platform", "family"), + [ + (PLATFORM_BK72XX, FAMILY_BK7251), + (PLATFORM_RTL87XX, FAMILY_RTL8710B), + ], +) +def test_rtc_rejected_on_other_libretiny_families(platform: str, family: str) -> None: + _set_libretiny(platform, family) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_default_storage_bk7231t_is_flash() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7231T) + assert _validate({})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +def test_request_rtc_storage_bk7231t_emits_define() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7231T) + preferences.request_rtc_storage() + assert "USE_LIBRETINY_RTC_PREFERENCES" in _define_names() + + +def test_request_rtc_storage_other_libretiny_family_is_a_no_op() -> None: + _set_libretiny(PLATFORM_BK72XX, FAMILY_BK7251) + preferences.request_rtc_storage() + assert "USE_LIBRETINY_RTC_PREFERENCES" not in _define_names() From bb9067acd719ffc59915668004e6492ea2b3e291 Mon Sep 17 00:00:00 2001 From: Igor Stadnyk <33496176+fallenmi@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:07:16 +0200 Subject: [PATCH 067/179] [tfluna] Recover from transient runtime reconfiguration failures (#20992) Co-authored-by: J. Nick Koston --- esphome/components/tfluna/tfluna.cpp | 64 ++++-- esphome/components/tfluna/tfluna.h | 6 + tests/components/tfluna/test_tfluna.cpp | 282 ++++++++++++++++++++++++ 3 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 tests/components/tfluna/test_tfluna.cpp diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp index e3d7045d8e6..706213024d6 100644 --- a/esphome/components/tfluna/tfluna.cpp +++ b/esphome/components/tfluna/tfluna.cpp @@ -42,31 +42,25 @@ void TFLuna::dump_config() { #endif } -void TFLuna::setup() { +bool TFLuna::configure_() { uint8_t mode; if (!this->read_byte(MODE_REGISTER, &mode)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } if (mode != MODE_TRIGGER) { if (!this->write_byte(MODE_REGISTER, MODE_TRIGGER)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } if (!this->write_byte(SAVE_REGISTER, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); - return; + return false; } } uint8_t raw_version[3]; if (!this->read_bytes(VERSION_REVISION_REGISTER, raw_version, sizeof(raw_version))) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); - return; + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return true; } else { snprintf(this->version_, sizeof(this->version_), "%d.%d.%d", raw_version[2], raw_version[1], raw_version[0]); #ifdef USE_TEXT_SENSOR @@ -75,12 +69,26 @@ void TFLuna::setup() { } #endif } + return true; +} + +void TFLuna::setup() { + if (!this->configure_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void TFLuna::reconfigure_() { + this->reconfigure_pending_ = !this->configure_(); + if (this->reconfigure_pending_) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } } bool TFLuna::read_data_() { uint8_t buf[8]; if (!this->read_bytes(DISTANCE_LOW_REGISTER, buf, sizeof(buf))) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } // Layout: @@ -125,34 +133,50 @@ void TFLuna::read_data_timeout_() { this->attempt_++; this->set_timeout(READ_RETRY_TIMEOUT_ID, READ_RETRY_MS, [this]() { this->read_data_timeout_(); }); } else { - this->status_set_warning("Hung device, restarting..."); + this->status_set_warning(LOG_STR("Hung device, restarting...")); this->restart(); } } } void TFLuna::update() { + if (this->is_failed()) { + return; + } this->cancel_timeout(READ_RETRY_TIMEOUT_ID); this->attempt_ = 0; + if (this->reconfigure_pending_) { + this->reconfigure_(); + if (this->reconfigure_pending_) { + return; + } + } if (!this->write_byte(TRIGGER_ONESHOT_REGISTER, 0x01)) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->read_data_timeout_(); } void TFLuna::factory_reset() { - if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) { return; } - this->status_set_warning("Factory reset issued; waiting for device to become ready"); - this->set_timeout(SETUP_TIMEOUT_ID, 100, [this]() { this->setup(); }); + if (!this->write_byte(RESTORE_FACTORY_DEFAULTS_REGISTER, 1)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->reconfigure_pending_ = false; + this->status_set_warning(LOG_STR("Factory reset issued; waiting for device to become ready")); + this->set_timeout(SETUP_TIMEOUT_ID, 100, [this]() { this->reconfigure_(); }); } void TFLuna::restart() { + if (this->is_failed()) { + return; + } if (!this->write_byte(SHUTDOWN_REBOOT_REGISTER, 0x02)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/tfluna/tfluna.h b/esphome/components/tfluna/tfluna.h index 0cc866ef1df..84de5f05d58 100644 --- a/esphome/components/tfluna/tfluna.h +++ b/esphome/components/tfluna/tfluna.h @@ -51,7 +51,13 @@ class TFLuna : public i2c::I2CDevice, public PollingComponent { void read_data_timeout_(); + [[nodiscard]] bool configure_(); + + void reconfigure_(); + uint8_t attempt_ = 0; + + bool reconfigure_pending_ = false; }; } // namespace esphome::tfluna diff --git a/tests/components/tfluna/test_tfluna.cpp b/tests/components/tfluna/test_tfluna.cpp new file mode 100644 index 00000000000..30236263c74 --- /dev/null +++ b/tests/components/tfluna/test_tfluna.cpp @@ -0,0 +1,282 @@ +#include + +#include +#include +#include "esphome/components/tfluna/tfluna.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::tfluna::testing { + +static constexpr uint8_t TIMESTAMP_LOW = 0x06; +static constexpr uint8_t VERSION = 0x0A; +static constexpr uint8_t SAVE = 0x20; +static constexpr uint8_t RESTART = 0x21; +static constexpr uint8_t MODE = 0x23; +static constexpr uint8_t MODE_TRIGGER = 0x01; +static constexpr uint8_t TRIGGER = 0x24; +static constexpr uint8_t RESET = 0x29; +static constexpr uint8_t NO_FAULT = 0xFF; // outside the register file + +struct Fault { + bool read; + uint8_t reg; +}; + +// Register file behind read_register (register byte, then read) and write_register +// (register byte + data, no read). A fault is one NACK, then the bus heals. +class RegisterBus final : public i2c::I2CBus { + public: + std::array regs{}; + uint8_t fail_read{NO_FAULT}; + uint8_t fail_write{NO_FAULT}; + unsigned mode_writes{0}; + unsigned save_writes{0}; + + i2c::ErrorCode write_readv(uint8_t /*address*/, const uint8_t *out, size_t out_len, uint8_t *in, + size_t in_len) override { + if (out_len == 0 || out[0] + (out_len - 1) + in_len > this->regs.size()) + return i2c::ERROR_INVALID_ARGUMENT; + const uint8_t reg = out[0]; + if (out_len > 1) { + if (reg == this->fail_write) { + this->fail_write = NO_FAULT; + return i2c::ERROR_NOT_ACKNOWLEDGED; + } + std::copy(out + 1, out + out_len, this->regs.begin() + reg); + this->mode_writes += reg == MODE; + this->save_writes += reg == SAVE; + if (reg == RESET) + this->regs[MODE] = 0; // fixture choice: defaults are not trigger mode + if (reg == TRIGGER) + this->regs[TIMESTAMP_LOW]++; // fixture choice: every trigger yields a new frame + } + if (in_len != 0) { + if (reg == this->fail_read) { + this->fail_read = NO_FAULT; + return i2c::ERROR_NOT_ACKNOWLEDGED; + } + std::copy_n(this->regs.begin() + reg, in_len, in); + } + return i2c::ERROR_OK; + } +}; + +class TFLunaTest : public ::testing::Test { + protected: + void SetUp() override { + // App lives for the whole binary; a timer another test left would fire on a dead object in run_timers() + ASSERT_TRUE(scheduler_idle()); + this->owns_scheduler_ = true; + this->bus_.regs[VERSION] = 3; + this->bus_.regs[VERSION + 1] = 2; + this->bus_.regs[VERSION + 2] = 1; + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x10); + } + + // Timeouts capture &device_ and running one publishes it to App; drop both before the fixture dies + void TearDown() override { + if (!this->owns_scheduler_) + return; // SetUp already failed; leave the other test's timers unrun + App.scheduler.cancel_timeout(&this->device_, uint32_t{0}); + App.scheduler.cancel_timeout(&this->device_, uint32_t{1}); + App.scheduler.call(millis()); + EXPECT_FALSE(App.scheduler.next_schedule_in(millis()).has_value()); + // Recycled items hold no callback; free them so a later App reset has none of ours to lose + App.scheduler.trim_freelist(); + LoopBlockingGuard clear{nullptr, nullptr, millis()}; + } + + // Real clock past the existing 100 ms reset timeout; no new device timing + // Timers are serviced like the main loop during the real wait instead of one long sleep + static void run_timers() { + const uint32_t start = millis(); + while (millis() - start < 110) { + App.scheduler.call(millis()); + delay(1); + } + App.scheduler.call(millis()); + } + + // Threaded hosts' next_schedule_in() skips timers still staged for the heap; merging them first runs no callback + static bool scheduler_idle() { + App.scheduler.process_to_add(); + return !App.scheduler.next_schedule_in(millis()).has_value(); + } + + void fail_at_(Fault fault) { (fault.read ? this->bus_.fail_read : this->bus_.fail_write) = fault.reg; } + + // One NACK at the read-mode, write-mode or SAVE step; fatal at boot, retried by the next update at runtime + void expect_boot_nack_marks_failed_(Fault fault) { + this->fail_at_(fault); + this->device_.setup(); + EXPECT_TRUE(this->device_.is_failed()); + } + + void expect_runtime_nack_recovers_(Fault fault) { + this->device_.setup(); + this->device_.factory_reset(); + this->fail_at_(fault); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); + } + + RegisterBus bus_; + TFLuna device_; + bool owns_scheduler_{false}; +}; + +// A timeout another suite left staged must block ownership before any callback can run +TEST_F(TFLunaTest, StagedForeignTimeoutBlocksOwnership) { + TFLuna foreign; + bool fired = false; + App.scheduler.set_timeout(&foreign, uint32_t{0}, 1000, [&fired]() { fired = true; }); + EXPECT_FALSE(scheduler_idle()); + EXPECT_FALSE(fired); + // Drop the timer before foreign and fired leave scope; no ASSERT may return early above this + EXPECT_TRUE(App.scheduler.cancel_timeout(&foreign, uint32_t{0})); + App.scheduler.call(millis()); + EXPECT_FALSE(fired); + EXPECT_TRUE(scheduler_idle()); +} + +TEST_F(TFLunaTest, BootSelectsTriggerModeAndSaves) { + this->device_.setup(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 1u); + EXPECT_EQ(this->bus_.save_writes, 1u); +} + +TEST_F(TFLunaTest, NormalFactoryResetRestoresTriggerMode) { + this->device_.setup(); + this->device_.factory_reset(); + EXPECT_TRUE(this->device_.status_has_warning()); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_EQ(this->bus_.save_writes, 2u); + this->device_.update(); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, ResetWriteNackSchedulesNothing) { + this->device_.setup(); + this->bus_.fail_write = RESET; + this->device_.factory_reset(); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + EXPECT_EQ(this->bus_.mode_writes, 1u); + this->device_.update(); // no reconfigure was left pending; the next good read clears the warning + EXPECT_EQ(this->bus_.mode_writes, 1u); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, DoublePressInWindowReconfiguresOnce) { + this->device_.setup(); + this->device_.factory_reset(); + this->device_.factory_reset(); + run_timers(); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); +} + +// Original: the first NACK marks the component failed, so Scheduler::call() drops the second reset's timeout +TEST_F(TFLunaTest, ResetWhileReconfigurePendingWaitsForWindowThenRecovers) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->device_.factory_reset(); + this->device_.update(); // inside the 100 ms window: no early reconfigure + EXPECT_EQ(this->bus_.mode_writes, 1u); + run_timers(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_EQ(this->bus_.mode_writes, 2u); + this->device_.update(); + EXPECT_EQ(this->bus_.mode_writes, 2u); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, ResetWriteNackKeepsPendingReconfigure) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->bus_.fail_write = RESET; + this->device_.factory_reset(); + run_timers(); + this->device_.update(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, UpdateSkipsPollingWhileReconfigureFails) { + this->device_.setup(); + this->device_.factory_reset(); + this->bus_.fail_read = MODE; + run_timers(); + this->bus_.fail_read = MODE; + this->device_.update(); + EXPECT_EQ(this->bus_.regs[TIMESTAMP_LOW], 0); // no trigger issued + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_EQ(this->bus_.regs[MODE], MODE_TRIGGER); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +TEST_F(TFLunaTest, RestartWriteNackWarnsThenNextReadClears) { + this->device_.setup(); + this->bus_.fail_write = RESTART; + this->device_.restart(); + EXPECT_FALSE(this->device_.is_failed()); + EXPECT_TRUE(this->device_.status_has_warning()); + this->device_.update(); + EXPECT_FALSE(this->device_.status_has_warning()); +} + +// A boot failure is permanent; runtime entry points must not touch the bus +TEST_F(TFLunaTest, FailedComponentSkipsRuntimeWrites) { + this->bus_.fail_read = MODE; + this->device_.setup(); + ASSERT_TRUE(this->device_.is_failed()); + this->device_.factory_reset(); + this->device_.restart(); + this->device_.update(); + EXPECT_TRUE(this->device_.is_failed()); + EXPECT_FALSE(this->device_.status_has_warning()); + EXPECT_EQ(this->bus_.regs[RESET], 0); + EXPECT_EQ(this->bus_.regs[RESTART], 0); + EXPECT_EQ(this->bus_.regs[TIMESTAMP_LOW], 0); + EXPECT_EQ(this->bus_.mode_writes, 0u); +} + +TEST_F(TFLunaTest, BootReadModeNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{true, MODE}); } + +TEST_F(TFLunaTest, BootWriteModeNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{false, MODE}); } + +TEST_F(TFLunaTest, BootSaveNackStillMarksFailed) { this->expect_boot_nack_marks_failed_(Fault{false, SAVE}); } + +TEST_F(TFLunaTest, RuntimeReadModeNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{true, MODE}); +} + +TEST_F(TFLunaTest, RuntimeWriteModeNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{false, MODE}); +} + +TEST_F(TFLunaTest, RuntimeSaveNackWarnsThenNextUpdateRecovers) { + this->expect_runtime_nack_recovers_(Fault{false, SAVE}); +} + +} // namespace esphome::tfluna::testing From e6f1ed8d510a9d8b5b3bae431df4d9f117c632aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:07:42 -1000 Subject: [PATCH 068/179] [tcp_uart] Expose the connection entities as platforms (#21009) --- esphome/components/tcp_uart/__init__.py | 29 +++-------------- esphome/components/tcp_uart/binary_sensor.py | 25 +++++++++++++++ esphome/components/tcp_uart/sensor.py | 31 +++++++++++++++++++ esphome/components/tcp_uart/tcp_uart.cpp | 6 ++++ esphome/components/tcp_uart/tcp_uart.h | 7 +++++ tests/components/tcp_uart/benchmark.yaml | 5 +-- tests/components/tcp_uart/common.yaml | 8 +++++ .../tcp_uart/test-client.esp32-idf.yaml | 8 +++++ .../integration/fixtures/tcp_uart_bridge.yaml | 4 +++ 9 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 esphome/components/tcp_uart/binary_sensor.py create mode 100644 esphome/components/tcp_uart/sensor.py diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index df9c1720ee6..1e120275602 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_DATA_BITS, CONF_HOST, CONF_PARITY, @@ -11,23 +10,16 @@ from esphome.components.const import ( CONF_STOP_BITS, ) import esphome.config_validation as cv -from esphome.const import ( - CONF_BAUD_RATE, - CONF_ID, - CONF_PORT, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, -) +from esphome.const import CONF_BAUD_RATE, CONF_ID, CONF_PORT from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DEPENDENCIES = ["network"] -AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["uart", "socket"] DOMAIN = "tcp_uart" MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_TCP_UART_ID = "tcp_uart_id" tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart") TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) @@ -46,15 +38,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -100,7 +83,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_parity(config[CONF_PARITY])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/binary_sensor.py b/esphome/components/tcp_uart/binary_sensor.py new file mode 100644 index 00000000000..8337973c837 --- /dev/null +++ b/esphome/components/tcp_uart/binary_sensor.py @@ -0,0 +1,25 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/tcp_uart/sensor.py b/esphome/components/tcp_uart/sensor.py new file mode 100644 index 00000000000..099ca8734e1 --- /dev/null +++ b/esphome/components/tcp_uart/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp index 3dead1fb65c..fa465aa55a8 100644 --- a/esphome/components/tcp_uart/tcp_uart.cpp +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -17,9 +17,11 @@ void TcpUart::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -38,7 +40,9 @@ void TcpUart::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -58,9 +62,11 @@ void TcpUart::sync_link_() { // Unread bytes of the last session stay readable while down, never into the next one. this->rx_start_ = this->rx_end_ = 0; } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h index 1e1d48e4c20..6ecfd1a0862 100644 --- a/esphome/components/tcp_uart/tcp_uart.h +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -23,7 +26,9 @@ class TcpUart : public uart::UARTComponent, public Component { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -64,7 +69,9 @@ class TcpUart : public uart::UARTComponent, public Component { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif #ifdef USE_SENSOR sensor::Sensor *disconnects_sensor_{nullptr}; uint32_t disconnects_{0}; diff --git a/tests/components/tcp_uart/benchmark.yaml b/tests/components/tcp_uart/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/tcp_uart/benchmark.yaml +++ b/tests/components/tcp_uart/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/tcp_uart/common.yaml b/tests/components/tcp_uart/common.yaml index 97ac0247da3..97bcd24f6a7 100644 --- a/tests/components/tcp_uart/common.yaml +++ b/tests/components/tcp_uart/common.yaml @@ -10,8 +10,16 @@ tcp_uart: allowed_ips: - 192.0.2.20 - 192.0.2.0/24 + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server connected: name: TCP UART Server Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server disconnects: name: TCP UART Server Disconnects diff --git a/tests/components/tcp_uart/test-client.esp32-idf.yaml b/tests/components/tcp_uart/test-client.esp32-idf.yaml index 74bd26a98bc..921ce0cbc1a 100644 --- a/tests/components/tcp_uart/test-client.esp32-idf.yaml +++ b/tests/components/tcp_uart/test-client.esp32-idf.yaml @@ -7,7 +7,15 @@ tcp_uart: host: 192.0.2.10 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: TCP UART Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 disconnects: name: TCP UART Disconnects diff --git a/tests/integration/fixtures/tcp_uart_bridge.yaml b/tests/integration/fixtures/tcp_uart_bridge.yaml index 12791e7dd46..74a8710b19e 100644 --- a/tests/integration/fixtures/tcp_uart_bridge.yaml +++ b/tests/integration/fixtures/tcp_uart_bridge.yaml @@ -20,6 +20,10 @@ tcp_uart: host: 127.0.0.1 port: 18125 reconnect_interval: 3s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: Bridge Connected From 42e849967a94eb8f82f6654b88cb142c05d8be3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:14 -1000 Subject: [PATCH 069/179] [bq76972] Expose the sensors as a sensor platform (#21006) --- esphome/components/bq76972/__init__.py | 60 +--------------- esphome/components/bq76972/bq76972.cpp | 8 +++ esphome/components/bq76972/bq76972.h | 9 ++- esphome/components/bq76972/sensor.py | 73 ++++++++++++++++++++ tests/components/bq76972/common.yaml | 96 ++++++++++++++------------ 5 files changed, 142 insertions(+), 104 deletions(-) create mode 100644 esphome/components/bq76972/sensor.py diff --git a/esphome/components/bq76972/__init__.py b/esphome/components/bq76972/__init__.py index 672d6428d26..46f674da269 100644 --- a/esphome/components/bq76972/__init__.py +++ b/esphome/components/bq76972/__init__.py @@ -1,18 +1,10 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import i2c, sensor +from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ADDRESS, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, - UNIT_CELSIUS, - UNIT_VOLT, -) +from esphome.const import CONF_ADDRESS from esphome.types import ConfigType -AUTO_LOAD = ["sensor"] CODEOWNERS = ["@limpkin"] DEPENDENCIES = ["i2c"] DOMAIN = "bq76972" @@ -25,37 +17,7 @@ BQ76972Component = bq76972_ns.class_( CONF_REG_DISABLED = "reg_disabled" CONF_CRC_ENABLED = "crc_enabled" -CONF_STACK_VOLTAGE = "stack_voltage" - -# Ordered as the chip reports them, starting at command 0x68 -CONF_TEMPERATURES = [ - "internal_temperature", - "cfetoff_temperature", - "dfetoff_temperature", - "alert_temperature", - "ts1_temperature", - "ts2_temperature", - "ts3_temperature", - "hdq_temperature", - "dchg_temperature", - "ddsg_temperature", -] - -CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)] - -voltage_sensor_schema = sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=3, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, -) - -temp_sensor_schema = sensor.sensor_schema( - unit_of_measurement=UNIT_CELSIUS, - accuracy_decimals=1, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, -) +CONF_BQ76972_ID = "bq76972_id" CONFIG_SCHEMA = ( cv.Schema( @@ -63,9 +25,6 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(BQ76972Component), cv.Optional(CONF_CRC_ENABLED, default=False): cv.boolean, cv.Optional(CONF_REG_DISABLED, default=False): cv.boolean, - cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema, - **{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES}, - **{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES}, } ) .extend(i2c.i2c_device_schema(0x08)) @@ -79,19 +38,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_crc_mode(config[CONF_CRC_ENABLED])) cg.add(var.set_reg_disable(config[CONF_REG_DISABLED])) - sensors = sensor.sub_sensors(config) - await sensors(CONF_STACK_VOLTAGE, var.set_stack_voltage_sensor) - - for idx, key in enumerate(CONF_TEMPERATURES): - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(var.set_temperature_sensor(sens, idx)) - - for idx, key in enumerate(CONF_CELL_VOLTAGES): - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(var.set_cell_sensor(sens, idx)) - BQ76972_PROGRAM_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( { diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp index ea229307d5c..9426fbb2526 100644 --- a/esphome/components/bq76972/bq76972.cpp +++ b/esphome/components/bq76972/bq76972.cpp @@ -172,10 +172,12 @@ const LogString *BQ76972Component::configure_() { const bool write_regs = !regs_disabled; bool write_thermistors = false; +#ifdef USE_SENSOR for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { if (i != BQ76972_TS1_INDEX && this->temperature_sensors_[i] != nullptr) write_thermistors = true; } +#endif if (write_regs || write_thermistors) { if (!this->enter_cfg_update_()) @@ -201,12 +203,14 @@ const LogString *BQ76972Component::write_config_(bool write_regs) { !this->write_subcommand_(BQ76972_MEM_REG0_CONFIG, &zero, 1)) return LOG_STR("Writing regulator config failed"); } +#ifdef USE_SENSOR for (size_t i = 1; i < BQ76972_NUM_TEMPERATURES; i++) { if (i == BQ76972_TS1_INDEX || this->temperature_sensors_[i] == nullptr) continue; if (!this->write_subcommand_(BQ76972_MEM_TEMP_PIN_CONFIG_BASE + i, &BQ76972_PIN_CONFIG_THERMISTOR, 1)) return LOG_STR("Writing thermistor pin config failed"); } +#endif return nullptr; } @@ -248,6 +252,7 @@ void BQ76972Component::program_i2c_address(uint8_t address) { ESP_LOGI(TAG, "I2C address set to 0x%02X", address); } +#ifdef USE_SENSOR template static bool any_set(const std::array &sensors) { for (auto *sensor : sensors) { if (sensor != nullptr) @@ -294,5 +299,8 @@ void BQ76972Component::update() { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } +#else +void BQ76972Component::update() {} +#endif } // namespace esphome::bq76972 diff --git a/esphome/components/bq76972/bq76972.h b/esphome/components/bq76972/bq76972.h index d27a4ce96f4..ccc4f45cfaf 100644 --- a/esphome/components/bq76972/bq76972.h +++ b/esphome/components/bq76972/bq76972.h @@ -1,8 +1,11 @@ #pragma once -#include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif #include @@ -19,9 +22,11 @@ class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { void set_crc_mode(bool crc_mode) { this->crc_mode_ = crc_mode; } void set_reg_disable(bool reg_disable) { this->reg_disable_ = reg_disable; } +#ifdef USE_SENSOR void set_stack_voltage_sensor(sensor::Sensor *s) { this->stack_voltage_sensor_ = s; } void set_cell_sensor(sensor::Sensor *s, size_t index) { this->cell_sensors_[index] = s; } void set_temperature_sensor(sensor::Sensor *s, size_t index) { this->temperature_sensors_[index] = s; } +#endif void program_i2c_address(uint8_t address); @@ -43,9 +48,11 @@ class BQ76972Component final : public PollingComponent, public i2c::I2CDevice { bool reg_disable_{false}; bool crc_mode_{false}; +#ifdef USE_SENSOR sensor::Sensor *stack_voltage_sensor_{nullptr}; std::array cell_sensors_{}; std::array temperature_sensors_{}; +#endif }; } // namespace esphome::bq76972 diff --git a/esphome/components/bq76972/sensor.py b/esphome/components/bq76972/sensor.py new file mode 100644 index 00000000000..0c7efc41235 --- /dev/null +++ b/esphome/components/bq76972/sensor.py @@ -0,0 +1,73 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_VOLT, +) +from esphome.types import ConfigType + +from . import CONF_BQ76972_ID, BQ76972Component + +DEPENDENCIES = ["bq76972"] + +CONF_STACK_VOLTAGE = "stack_voltage" + +# Ordered as the chip reports them, starting at command 0x68 +CONF_TEMPERATURES = [ + "internal_temperature", + "cfetoff_temperature", + "dfetoff_temperature", + "alert_temperature", + "ts1_temperature", + "ts2_temperature", + "ts3_temperature", + "hdq_temperature", + "dchg_temperature", + "ddsg_temperature", +] + +CONF_CELL_VOLTAGES = [f"cell_{i}_voltage" for i in range(1, 17)] + +voltage_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) + +temp_sensor_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_BQ76972_ID): cv.use_id(BQ76972Component), + cv.Optional(CONF_STACK_VOLTAGE): voltage_sensor_schema, + **{cv.Optional(key): voltage_sensor_schema for key in CONF_CELL_VOLTAGES}, + **{cv.Optional(key): temp_sensor_schema for key in CONF_TEMPERATURES}, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BQ76972_ID]) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_STACK_VOLTAGE, hub.set_stack_voltage_sensor) + + for idx, key in enumerate(CONF_TEMPERATURES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_temperature_sensor(sens, idx)) + + for idx, key in enumerate(CONF_CELL_VOLTAGES): + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(hub.set_cell_sensor(sens, idx)) diff --git a/tests/components/bq76972/common.yaml b/tests/components/bq76972/common.yaml index 4b2e62eb20b..7270a5ce03c 100644 --- a/tests/components/bq76972/common.yaml +++ b/tests/components/bq76972/common.yaml @@ -1,60 +1,64 @@ bq76972: - id: bq76972_id + id: bms i2c_id: i2c_bus address: 0x08 crc_enabled: true reg_disabled: true update_interval: 10s - stack_voltage: - name: "Pack Total Voltage" - internal_temperature: - name: "Temperature - Internal" - ts1_temperature: - name: "Temperature - Pin TS1" - ts2_temperature: - name: "Temperature - Pin TS2" - ts3_temperature: - name: "Temperature - Pin TS3" - hdq_temperature: - name: "Temperature - Pin HDQ" - cell_1_voltage: - name: "Cell 1 Voltage" - cell_2_voltage: - name: "Cell 2 Voltage" - cell_3_voltage: - name: "Cell 3 Voltage" - cell_4_voltage: - name: "Cell 4 Voltage" - cell_5_voltage: - name: "Cell 5 Voltage" - cell_6_voltage: - name: "Cell 6 Voltage" - cell_7_voltage: - name: "Cell 7 Voltage" - cell_8_voltage: - name: "Cell 8 Voltage" - cell_9_voltage: - name: "Cell 9 Voltage" - cell_10_voltage: - name: "Cell 10 Voltage" - cell_11_voltage: - name: "Cell 11 Voltage" - cell_12_voltage: - name: "Cell 12 Voltage" - cell_13_voltage: - name: "Cell 13 Voltage" - cell_14_voltage: - name: "Cell 14 Voltage" - cell_15_voltage: - name: "Cell 15 Voltage" - cell_16_voltage: - name: "Cell 16 Voltage" button: - platform: template name: "Program BQ I2C Address" on_press: - bq76972.program_i2c_address: - id: bq76972_id + id: bms address: 0x09 - bq76972.program_i2c_address: !lambda "return 0x08;" + +sensor: + - platform: bq76972 + bq76972_id: bms + stack_voltage: + name: "Pack Total Voltage" + internal_temperature: + name: "Temperature - Internal" + ts1_temperature: + name: "Temperature - Pin TS1" + ts2_temperature: + name: "Temperature - Pin TS2" + ts3_temperature: + name: "Temperature - Pin TS3" + hdq_temperature: + name: "Temperature - Pin HDQ" + cell_1_voltage: + name: "Cell 1 Voltage" + cell_2_voltage: + name: "Cell 2 Voltage" + cell_3_voltage: + name: "Cell 3 Voltage" + cell_4_voltage: + name: "Cell 4 Voltage" + cell_5_voltage: + name: "Cell 5 Voltage" + cell_6_voltage: + name: "Cell 6 Voltage" + cell_7_voltage: + name: "Cell 7 Voltage" + cell_8_voltage: + name: "Cell 8 Voltage" + cell_9_voltage: + name: "Cell 9 Voltage" + cell_10_voltage: + name: "Cell 10 Voltage" + cell_11_voltage: + name: "Cell 11 Voltage" + cell_12_voltage: + name: "Cell 12 Voltage" + cell_13_voltage: + name: "Cell 13 Voltage" + cell_14_voltage: + name: "Cell 14 Voltage" + cell_15_voltage: + name: "Cell 15 Voltage" + cell_16_voltage: + name: "Cell 16 Voltage" From e034662dc6cf1f82fc19bb15d5a9d9ef109da6f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:32 -1000 Subject: [PATCH 070/179] [uart_tcp] Expose the connection entities as platforms (#21008) --- esphome/components/uart_tcp/__init__.py | 23 ++------------ esphome/components/uart_tcp/binary_sensor.py | 25 +++++++++++++++ esphome/components/uart_tcp/sensor.py | 31 +++++++++++++++++++ esphome/components/uart_tcp/uart_tcp.cpp | 6 ++++ esphome/components/uart_tcp/uart_tcp.h | 7 +++++ tests/components/uart_tcp/benchmark.yaml | 5 +-- tests/components/uart_tcp/common.yaml | 8 +++++ .../uart_tcp/test-client.esp32-idf.yaml | 8 +++++ tests/components/uart_tcp/test.host.yaml | 8 +++++ .../integration/fixtures/uart_tcp_bridge.yaml | 8 +++-- 10 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 esphome/components/uart_tcp/binary_sensor.py create mode 100644 esphome/components/uart_tcp/sensor.py diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 9d62d48d7c2..c7eba4a26a5 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_HOST, CONF_RECONNECT_INTERVAL, CONF_ROLE, @@ -15,9 +14,6 @@ from esphome.const import ( CONF_ID, CONF_PORT, CONF_UART_ID, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, ) from esphome.core import CORE import esphome.final_validate as fv @@ -26,10 +22,10 @@ from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DOMAIN = "uart_tcp" DEPENDENCIES = ["network", "uart"] -AUTO_LOAD = ["binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["socket"] MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_UART_TCP_ID = "uart_tcp_id" uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp") UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice) @@ -43,15 +39,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -144,7 +131,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/binary_sensor.py b/esphome/components/uart_tcp/binary_sensor.py new file mode 100644 index 00000000000..c8d19319337 --- /dev/null +++ b/esphome/components/uart_tcp/binary_sensor.py @@ -0,0 +1,25 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/uart_tcp/sensor.py b/esphome/components/uart_tcp/sensor.py new file mode 100644 index 00000000000..ad44b56ad09 --- /dev/null +++ b/esphome/components/uart_tcp/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp index d634ac579f2..ca6c2204bdd 100644 --- a/esphome/components/uart_tcp/uart_tcp.cpp +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -16,9 +16,11 @@ void UartTcp::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -37,7 +39,9 @@ void UartTcp::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -57,9 +61,11 @@ void UartTcp::sync_link_() { // The driver kept whatever arrived while the link was down. this->discard_uart_(); } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/uart_tcp/uart_tcp.h b/esphome/components/uart_tcp/uart_tcp.h index 256e059133a..6080cc95514 100644 --- a/esphome/components/uart_tcp/uart_tcp.h +++ b/esphome/components/uart_tcp/uart_tcp.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -22,7 +25,9 @@ class UartTcp final : public Component, public uart::UARTDevice { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -53,7 +58,9 @@ class UartTcp final : public Component, public uart::UARTDevice { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif // Loop start time of the last socket-to-UART write; sizes the next paced write. uint32_t last_write_ms_{0}; #ifdef USE_SENSOR diff --git a/tests/components/uart_tcp/benchmark.yaml b/tests/components/uart_tcp/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/uart_tcp/benchmark.yaml +++ b/tests/components/uart_tcp/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/uart_tcp/common.yaml b/tests/components/uart_tcp/common.yaml index fd34757d0ee..cf86dc9da89 100644 --- a/tests/components/uart_tcp/common.yaml +++ b/tests/components/uart_tcp/common.yaml @@ -10,7 +10,15 @@ uart_tcp: allowed_ips: - 192.168.1.10 - 192.168.1.0/24 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test-client.esp32-idf.yaml b/tests/components/uart_tcp/test-client.esp32-idf.yaml index 5ba900347c4..6bce693f854 100644 --- a/tests/components/uart_tcp/test-client.esp32-idf.yaml +++ b/tests/components/uart_tcp/test-client.esp32-idf.yaml @@ -12,7 +12,15 @@ uart_tcp: host: 192.0.2.20 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test.host.yaml b/tests/components/uart_tcp/test.host.yaml index a0b82c568df..b793ae144c3 100644 --- a/tests/components/uart_tcp/test.host.yaml +++ b/tests/components/uart_tcp/test.host.yaml @@ -8,7 +8,15 @@ uart_tcp: uart_id: uart_bus host: 127.0.0.1 port: 44502 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/integration/fixtures/uart_tcp_bridge.yaml b/tests/integration/fixtures/uart_tcp_bridge.yaml index 7ad28316f75..cf6c8fad64b 100644 --- a/tests/integration/fixtures/uart_tcp_bridge.yaml +++ b/tests/integration/fixtures/uart_tcp_bridge.yaml @@ -24,11 +24,15 @@ uart_tcp: port: 18126 allowed_ips: - 127.0.0.1 - connected: - name: Bridge Connected - id: denied_bridge uart_id: denied_bus role: server port: 18127 allowed_ips: - 192.0.2.1 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge + connected: + name: Bridge Connected From 1197f22f0067961440d07af468c59a38a6e7f420 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:49 -1000 Subject: [PATCH 071/179] [fusb302b] Expose the sensors as platforms (#21004) --- esphome/components/fusb302b/__init__.py | 37 +----------------- esphome/components/fusb302b/sensor.py | 45 ++++++++++++++++++++++ esphome/components/fusb302b/text_sensor.py | 27 +++++++++++++ tests/components/fusb302b/common.yaml | 20 +++++++--- 4 files changed, 88 insertions(+), 41 deletions(-) create mode 100644 esphome/components/fusb302b/sensor.py create mode 100644 esphome/components/fusb302b/text_sensor.py diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index 26bf49b28e3..a1770605498 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -1,30 +1,21 @@ from esphome import automation, pins import esphome.codegen as cg -from esphome.components import i2c, sensor, text_sensor +from esphome.components import i2c import esphome.config_validation as cv from esphome.const import ( - CONF_CURRENT, CONF_INTERRUPT_PIN, CONF_ON_CONNECT, CONF_ON_DISCONNECT, CONF_ON_ERROR, CONF_VOLTAGE, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_VOLTAGE, - ENTITY_CATEGORY_DIAGNOSTIC, - ICON_FLASH, - STATE_CLASS_MEASUREMENT, - UNIT_AMPERE, - UNIT_VOLT, ) from esphome.types import ConfigType -AUTO_LOAD = ["sensor", "text_sensor"] CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] DOMAIN = "fusb302b" -CONF_CONTRACT = "contract" +CONF_FUSB302B_ID = "fusb302b_id" CONF_ON_POWER_READY = "on_power_ready" CONF_ON_REQUEST_FAILED = "on_request_failed" CONF_REQUEST_VOLTAGE = "request_voltage" @@ -47,24 +38,6 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(FUSB302B), cv.Required(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pullup_pin_schema, cv.Required(CONF_REQUEST_VOLTAGE): validate_voltage, - cv.Optional(CONF_CONTRACT): text_sensor.text_sensor_schema( - icon=ICON_FLASH, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_CURRENT): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}), cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}), cv.Optional(CONF_ON_POWER_READY): automation.validate_automation({}), @@ -101,12 +74,6 @@ async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config, interrupt_pin) cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) - sensors = sensor.sub_sensors(config) - await sensors(CONF_VOLTAGE, var.set_voltage_sensor) - await sensors(CONF_CURRENT, var.set_current_sensor) - text_sensors = text_sensor.sub_text_sensors(config) - await text_sensors(CONF_CONTRACT, var.set_contract_text_sensor) - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/fusb302b/sensor.py b/esphome/components/fusb302b/sensor.py new file mode 100644 index 00000000000..34dd35a60c7 --- /dev/null +++ b/esphome/components/fusb302b/sensor.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_CURRENT, + CONF_VOLTAGE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_VOLT, +) +from esphome.types import ConfigType + +from . import CONF_FUSB302B_ID, FUSB302B + +DEPENDENCIES = ["fusb302b"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_FUSB302B_ID): cv.use_id(FUSB302B), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_FUSB302B_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, hub.set_voltage_sensor) + await sensors(CONF_CURRENT, hub.set_current_sensor) diff --git a/esphome/components/fusb302b/text_sensor.py b/esphome/components/fusb302b/text_sensor.py new file mode 100644 index 00000000000..ee18af469e4 --- /dev/null +++ b/esphome/components/fusb302b/text_sensor.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_FLASH +from esphome.types import ConfigType + +from . import CONF_FUSB302B_ID, FUSB302B + +DEPENDENCIES = ["fusb302b"] + +CONF_CONTRACT = "contract" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_FUSB302B_ID): cv.use_id(FUSB302B), + cv.Optional(CONF_CONTRACT): text_sensor.text_sensor_schema( + icon=ICON_FLASH, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_FUSB302B_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_CONTRACT, hub.set_contract_text_sensor) diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml index e2c4ee87ca7..60c8cc615e9 100644 --- a/tests/components/fusb302b/common.yaml +++ b/tests/components/fusb302b/common.yaml @@ -3,12 +3,6 @@ fusb302b: i2c_id: i2c_bus interrupt_pin: ${interrupt_pin} request_voltage: 20 - contract: - name: PD Contract - voltage: - name: PD Voltage - current: - name: PD Current on_connect: - logger.log: "Source attached" on_disconnect: @@ -26,3 +20,17 @@ fusb302b: - fusb302b.request_voltage: id: pd voltage: !lambda "return 9;" + +sensor: + - platform: fusb302b + fusb302b_id: pd + voltage: + name: PD Voltage + current: + name: PD Current + +text_sensor: + - platform: fusb302b + fusb302b_id: pd + contract: + name: PD Contract From 3b7847d951a9cc70a8153885df2d254b600424c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:13:49 -1000 Subject: [PATCH 072/179] [components] Create SPI devices with new_spi_component (#20920) --- esphome/components/adc128s102/__init__.py | 5 +---- esphome/components/ads1118/__init__.py | 5 +---- esphome/components/atm90e26/sensor.py | 5 +---- esphome/components/cc1101/__init__.py | 4 +--- esphome/components/cs5460a/sensor.py | 4 +--- esphome/components/honeywellabp/sensor.py | 5 +---- esphome/components/mcp3008/__init__.py | 5 +---- esphome/components/sx126x/__init__.py | 4 +--- esphome/components/sx127x/__init__.py | 4 +--- 9 files changed, 9 insertions(+), 32 deletions(-) diff --git a/esphome/components/adc128s102/__init__.py b/esphome/components/adc128s102/__init__.py index 125b4113c57..56df70a170c 100644 --- a/esphome/components/adc128s102/__init__.py +++ b/esphome/components/adc128s102/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -20,6 +19,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/ads1118/__init__.py b/esphome/components/ads1118/__init__.py index 98129ecbea3..e7cc1b4f45a 100644 --- a/esphome/components/ads1118/__init__.py +++ b/esphome/components/ads1118/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@solomondg1"] @@ -26,6 +25,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/atm90e26/sensor.py b/esphome/components/atm90e26/sensor.py index 5a6b667535f..ed116faaba0 100644 --- a/esphome/components/atm90e26/sensor.py +++ b/esphome/components/atm90e26/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CURRENT, CONF_FORWARD_ACTIVE_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_LINE_FREQUENCY, CONF_POWER, CONF_POWER_FACTOR, @@ -125,9 +124,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 2660d5bbde7..5375d6bf396 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -274,9 +274,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) for opt in CONFIG_MAP: key = opt.schema diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 54e452efc88..eab99fde147 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -103,9 +103,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) cg.add(var.set_samples(config[CONF_SAMPLES])) cg.add(var.set_phase_offset(config[CONF_PHASE_OFFSET])) diff --git a/esphome/components/honeywellabp/sensor.py b/esphome/components/honeywellabp/sensor.py index d7e7cf29a4e..2549610b8ed 100644 --- a/esphome/components/honeywellabp/sensor.py +++ b/esphome/components/honeywellabp/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, spi import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PRESSURE, CONF_TEMPERATURE, DEVICE_CLASS_PRESSURE, @@ -52,9 +51,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) sensors = sensor.sub_sensors(config) if await sensors(CONF_PRESSURE, var.set_pressure_sensor): diff --git a/esphome/components/mcp3008/__init__.py b/esphome/components/mcp3008/__init__.py index f29050f9fe5..aa3fb5c64ef 100644 --- a/esphome/components/mcp3008/__init__.py +++ b/esphome/components/mcp3008/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -22,6 +21,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 075aad534e7..3af042ea72b 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -238,9 +238,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) if CONF_ON_PACKET in config: await automation.build_automation( var.get_packet_trigger(), diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 4b54c646338..08660415a62 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -221,9 +221,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + var = await spi.new_spi_component(config) if CONF_ON_PACKET in config: await automation.build_automation( var.get_packet_trigger(), From ec9a207f42c7869d64daa1c49c95a68e3ef23681 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:15:21 -1000 Subject: [PATCH 073/179] [components] Create UART devices with new_uart_component (#20919) --- esphome/components/apc1/sensor.py | 5 +---- esphome/components/bl0906/sensor.py | 4 +--- esphome/components/bl0939/sensor.py | 5 +---- esphome/components/bl0940/sensor.py | 5 +---- esphome/components/bl0942/sensor.py | 5 +---- esphome/components/cm1106/sensor.py | 5 +---- esphome/components/cse7761/sensor.py | 5 +---- esphome/components/cse7766/sensor.py | 5 +---- esphome/components/daly_bms/__init__.py | 6 ++---- esphome/components/dfplayer/__init__.py | 6 ++---- esphome/components/dfrobot_sen0395/__init__.py | 4 +--- esphome/components/dfrobot_sen0609/__init__.py | 4 +--- esphome/components/gcja5/sensor.py | 5 +---- esphome/components/gps/__init__.py | 4 +--- esphome/components/hc8/sensor.py | 4 +--- esphome/components/hlk_fm22x/__init__.py | 5 +---- esphome/components/hlw8032/sensor.py | 5 +---- esphome/components/hydreon_rgxx/sensor.py | 5 +---- esphome/components/kamstrup_kmp/sensor.py | 5 +---- esphome/components/ld2410/__init__.py | 4 +--- esphome/components/ld2410s/__init__.py | 5 +---- esphome/components/ld2412/__init__.py | 6 ++---- esphome/components/ld2420/__init__.py | 5 +---- esphome/components/ld2450/__init__.py | 6 ++---- esphome/components/ld2460/__init__.py | 6 ++---- esphome/components/ld6002b/__init__.py | 6 ++---- esphome/components/mhz19/sensor.py | 4 +--- esphome/components/micronova/__init__.py | 5 +---- esphome/components/mitsubishi_cn105/__init__.py | 4 +--- esphome/components/mk2pvrouter/__init__.py | 6 ++---- esphome/components/pipsolar/__init__.py | 5 +---- esphome/components/pm1006/sensor.py | 5 +---- esphome/components/pmsx003/sensor.py | 5 +---- esphome/components/pylontech/__init__.py | 5 +---- esphome/components/pzem004t/sensor.py | 5 +---- esphome/components/rd03d/__init__.py | 6 ++---- esphome/components/rdm6300/__init__.py | 6 ++---- esphome/components/rf_bridge/__init__.py | 5 +---- esphome/components/sds011/sensor.py | 5 +---- esphome/components/seeed_mr60bha2/__init__.py | 5 +---- esphome/components/seeed_mr60fda2/__init__.py | 5 +---- esphome/components/senseair/sensor.py | 4 +--- esphome/components/serial_proxy/__init__.py | 6 ++---- esphome/components/sim800l/__init__.py | 6 ++---- esphome/components/sm300d2/sensor.py | 5 +---- esphome/components/sml/__init__.py | 6 ++---- esphome/components/smt100/sensor.py | 5 +---- esphome/components/sun_gtil2/__init__.py | 5 +---- esphome/components/systa_bus/__init__.py | 5 +---- esphome/components/t6615/sensor.py | 5 +---- esphome/components/teleinfo/__init__.py | 5 +---- esphome/components/tmc22xx/__init__.py | 4 +--- esphome/components/tuya/__init__.py | 6 ++---- esphome/components/uart_tcp/__init__.py | 4 +--- esphome/components/ufm01/__init__.py | 4 +--- esphome/components/uponor_smatrix/__init__.py | 6 ++---- esphome/components/vbus/__init__.py | 5 +---- esphome/components/zwave_proxy/__init__.py | 6 ++---- 58 files changed, 73 insertions(+), 220 deletions(-) diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py index b64a49903f2..f3f7e0203f9 100644 --- a/esphome/components/apc1/sensor.py +++ b/esphome/components/apc1/sensor.py @@ -7,7 +7,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ECO2, CONF_HUMIDITY, - CONF_ID, CONF_PM_0_3UM, CONF_PM_0_5UM, CONF_PM_1_0, @@ -165,9 +164,7 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 25c50555dbc..d8e2f092c06 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -147,9 +147,7 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FREQUENCY, var.set_frequency_sensor) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index 5e33759dea4..45a2e6e983d 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_MODE, CONF_VOLTAGE, @@ -112,9 +111,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if (mode := config[CONF_MODE]) != DEFAULT_MODE: cg.add(var.set_mode(mode)) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 1cf7e8e6e38..962abf636d1 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CURRENT, CONF_ENERGY, CONF_EXTERNAL_TEMPERATURE, - CONF_ID, CONF_INTERNAL_TEMPERATURE, CONF_POWER, CONF_REFERENCE_VOLTAGE, @@ -227,9 +226,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/bl0942/sensor.py b/esphome/components/bl0942/sensor.py index 397471cf7a9..a5e6ffb64db 100644 --- a/esphome/components/bl0942/sensor.py +++ b/esphome/components/bl0942/sensor.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CURRENT, CONF_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_LINE_FREQUENCY, CONF_POWER, CONF_RESET, @@ -97,9 +96,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index fa179f9667d..43c1f44d3d1 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_BASELINE, CONF_CO2, CONF_CYCLE, - CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, ICON_MOLECULE_CO2, STATE_CLASS_MEASUREMENT, @@ -68,9 +67,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: """Code generation entry point.""" - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 1618cb43962..17c23242f7f 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -3,7 +3,6 @@ from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( CONF_FREQUENCY, - CONF_ID, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -121,9 +120,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_CURRENT_INPUT]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config, config[CONF_CURRENT_INPUT]) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/cse7766/sensor.py b/esphome/components/cse7766/sensor.py index 86b6965e3a2..d5162230db0 100644 --- a/esphome/components/cse7766/sensor.py +++ b/esphome/components/cse7766/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_APPARENT_POWER, CONF_CURRENT, CONF_ENERGY, - CONF_ID, CONF_POWER, CONF_POWER_FACTOR, CONF_REACTIVE_POWER, @@ -94,9 +93,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/daly_bms/__init__.py b/esphome/components/daly_bms/__init__.py index 01d0eef857f..e2234985d43 100644 --- a/esphome/components/daly_bms/__init__.py +++ b/esphome/components/daly_bms/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.const import CONF_ADDRESS from esphome.types import ConfigType CODEOWNERS = ["@s1lvi0"] @@ -37,7 +37,5 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 69b745df9cf..719a76ff07d 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -5,7 +5,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_LOOP import esphome.config_validation as cv -from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME +from esphome.const import CONF_DEVICE, CONF_FILE, CONF_VOLUME DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] @@ -51,9 +51,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index d4e7aed91d8..801c2654a01 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -42,9 +42,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) automation.register_apply_action( diff --git a/esphome/components/dfrobot_sen0609/__init__.py b/esphome/components/dfrobot_sen0609/__init__.py index 76ca362949a..d588d70efd1 100644 --- a/esphome/components/dfrobot_sen0609/__init__.py +++ b/esphome/components/dfrobot_sen0609/__init__.py @@ -79,7 +79,5 @@ def final_validate_mode(keys: dict[str, str]) -> Callable[[ConfigType], None]: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], RUN_MODES[config[CONF_MODE]]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, RUN_MODES[config[CONF_MODE]]) diff --git a/esphome/components/gcja5/sensor.py b/esphome/components/gcja5/sensor.py index 49907443ff9..85110dafc25 100644 --- a/esphome/components/gcja5/sensor.py +++ b/esphome/components/gcja5/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_1_0, CONF_PM_2_5, CONF_PM_10_0, @@ -113,9 +112,7 @@ TYPES = { async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) for key, funcName in TYPES.items(): if key in config: diff --git a/esphome/components/gps/__init__.py b/esphome/components/gps/__init__.py index cc77dff496b..be1b13b70e9 100644 --- a/esphome/components/gps/__init__.py +++ b/esphome/components/gps/__init__.py @@ -96,9 +96,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("gps", require_rx=True async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) # Pre-create all sensor variables so automations that reference # sibling sensors don't deadlock waiting for unregistered IDs. diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 10074fbf10f..7ef0ce14c50 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -51,9 +51,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index 72d3cc2ada3..a013fe884d3 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -4,7 +4,6 @@ from esphome.components import uart import esphome.config_validation as cv from esphome.const import ( CONF_DIRECTION, - CONF_ID, CONF_NAME, CONF_ON_ENROLLMENT_DONE, CONF_ON_ENROLLMENT_FAILED, @@ -90,9 +89,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/hlw8032/sensor.py b/esphome/components/hlw8032/sensor.py index f2a8ffb2161..09cfe7320b4 100644 --- a/esphome/components/hlw8032/sensor.py +++ b/esphome/components/hlw8032/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_APPARENT_POWER, CONF_CURRENT, CONF_CURRENT_RESISTOR, - CONF_ID, CONF_POWER, CONF_POWER_FACTOR, CONF_VOLTAGE, @@ -75,9 +74,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index 222a5308ae1..eeab5303922 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_MODEL, CONF_MOISTURE, CONF_RESOLUTION, @@ -140,10 +139,8 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add_define( "HYDREON_RGXX_PROTOCOL_LIST(F, sep)", diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index f6c236b72df..ae00f86e264 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_COMMAND, CONF_CUSTOM, CONF_FLOW, - CONF_ID, CONF_POWER, CONF_VOLUME, DEVICE_CLASS_EMPTY, @@ -113,9 +112,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) # Standard sensors for key in [ diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index e2728aa73c7..a03d87d9168 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -72,9 +72,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) CALIBRATION_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py index ecc90455d1c..f4e09cc1867 100644 --- a/esphome/components/ld2410s/__init__.py +++ b/esphome/components/ld2410s/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -37,6 +36,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/ld2412/__init__.py b/esphome/components/ld2412/__init__.py index fd63f78c3d4..9a1772e780d 100644 --- a/esphome/components/ld2412/__init__.py +++ b/esphome/components/ld2412/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.const import CONF_THROTTLE from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -43,6 +43,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/ld2420/__init__.py b/esphome/components/ld2420/__init__.py index 87a54d278a7..b92e84d5965 100644 --- a/esphome/components/ld2420/__init__.py +++ b/esphome/components/ld2420/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@descipher"] @@ -36,6 +35,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 066ace871f8..dfca220b6a7 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -3,7 +3,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE +from esphome.const import CONF_ON_DATA, CONF_THROTTLE from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -63,7 +63,5 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld2460/__init__.py b/esphome/components/ld2460/__init__.py index 88c3a173efe..84cba297190 100644 --- a/esphome/components/ld2460/__init__.py +++ b/esphome/components/ld2460/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.const import CONF_ON_DATA from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] @@ -41,7 +41,5 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index c7529f93149..48017a8f00e 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_WAKEUP_PIN +from esphome.const import CONF_WAKEUP_PIN from esphome.types import ConfigType from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE @@ -62,9 +62,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if wakeup_pin_config := config.get(CONF_WAKEUP_PIN): pin = await cg.gpio_pin_expression(wakeup_pin_config) diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 4daeef5059c..5c5b24f6f48 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -73,9 +73,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index ff06d0b913d..3745b1084ba 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -4,7 +4,6 @@ from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.cpp_generator import MockObj @@ -106,9 +105,7 @@ async def to_code_micronova_listener( async def to_code(config: ConfigType) -> None: enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) - var = cg.new_Pvariable(config[CONF_ID], enable_rx_pin) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, enable_rx_pin) CORE.add_job(_final_step) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 442869eec53..ba51bd3a806 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -90,9 +90,7 @@ async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add( var.set_telemetry_request_min_interval( config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] diff --git a/esphome/components/mk2pvrouter/__init__.py b/esphome/components/mk2pvrouter/__init__.py index 7df1f7104f5..3d84c40c9ed 100644 --- a/esphome/components/mk2pvrouter/__init__.py +++ b/esphome/components/mk2pvrouter/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TAG +from esphome.const import CONF_TAG from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -65,6 +65,4 @@ async def register_mk2pvrouter_listener(mk2pvrouter: MockObj, var: MockObj) -> N async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/pipsolar/__init__.py b/esphome/components/pipsolar/__init__.py index 54337b0b130..77b97b24719 100644 --- a/esphome/components/pipsolar/__init__.py +++ b/esphome/components/pipsolar/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -29,6 +28,4 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/pm1006/sensor.py b/esphome/components/pm1006/sensor.py index 98c64066761..fc1bec8296b 100644 --- a/esphome/components/pm1006/sensor.py +++ b/esphome/components/pm1006/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_2_5, CONF_UPDATE_INTERVAL, DEVICE_CLASS_PM25, @@ -58,9 +57,7 @@ FINAL_VALIDATE_SCHEMA = validate_interval_uart async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index 723b99a5040..55f0c0678b2 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_PM_0_3UM, CONF_PM_0_5UM, CONF_PM_1_0, @@ -328,9 +327,7 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add(var.set_type(config[CONF_TYPE])) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index e15f8f04c82..3fc194da057 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -6,7 +6,6 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -108,7 +107,5 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/pzem004t/sensor.py b/esphome/components/pzem004t/sensor.py index 2b19cf84985..9a2b73ea4cc 100644 --- a/esphome/components/pzem004t/sensor.py +++ b/esphome/components/pzem004t/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_CURRENT, CONF_ENERGY, - CONF_ID, CONF_POWER, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, @@ -65,9 +64,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/rd03d/__init__.py b/esphome/components/rd03d/__init__.py index 96bf4ffd743..ff2eaebad59 100644 --- a/esphome/components/rd03d/__init__.py +++ b/esphome/components/rd03d/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.const import CONF_THROTTLE from esphome.types import ConfigType CODEOWNERS = ["@jasstrong"] @@ -41,9 +41,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if CONF_TRACKING_MODE in config: cg.add(var.set_tracking_mode(config[CONF_TRACKING_MODE])) diff --git a/esphome/components/rdm6300/__init__.py b/esphome/components/rdm6300/__init__.py index 86eb2cd7380..18d8e551f95 100644 --- a/esphome/components/rdm6300/__init__.py +++ b/esphome/components/rdm6300/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_TAG, CONF_TRIGGER_ID +from esphome.const import CONF_ON_TAG, CONF_TRIGGER_ID from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -37,9 +37,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index e967335c180..e970df9faa8 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CODE, CONF_DURATION, CONF_HIGH, - CONF_ID, CONF_LENGTH, CONF_LOW, CONF_PROTOCOL, @@ -70,9 +69,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index fe1481df9a5..93a2b9b0212 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_PM_2_5, CONF_PM_10_0, CONF_RX_ONLY, @@ -85,9 +84,7 @@ async def to_code(config: ConfigType) -> None: # Pop update_interval before register_component so it doesn't generate # a set_update_interval call — sds011 handles this via set_update_interval_min update_interval = config.pop(CONF_UPDATE_INTERVAL, None) - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if update_interval is not None: cg.add(var.set_update_interval_min(update_interval)) diff --git a/esphome/components/seeed_mr60bha2/__init__.py b/esphome/components/seeed_mr60bha2/__init__.py index 7a85567510a..bf91e4b1e27 100644 --- a/esphome/components/seeed_mr60bha2/__init__.py +++ b/esphome/components/seeed_mr60bha2/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] @@ -38,6 +37,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/seeed_mr60fda2/__init__.py b/esphome/components/seeed_mr60fda2/__init__.py index cc227415831..081581683a2 100644 --- a/esphome/components/seeed_mr60fda2/__init__.py +++ b/esphome/components/seeed_mr60fda2/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] @@ -39,6 +38,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 2494e9a6d1f..5222d56a106 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -49,9 +49,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 6c3327e556f..35b65f6bae3 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -19,7 +19,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.usb_uart import is_usb_uart_channel import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_NAME, CONF_UART_ID +from esphome.const import CONF_NAME, CONF_UART_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv @@ -94,9 +94,7 @@ async def _add_serial_proxy_count_define() -> None: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) cg.add(cg.App.register_serial_proxy(var)) cg.add(var.set_name(config[CONF_NAME])) cg.add(var.set_port_type(config[CONF_PORT_TYPE])) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index efc742610d9..2500e4684bf 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_MESSAGE +from esphome.const import CONF_MESSAGE DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] @@ -66,10 +66,8 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/sm300d2/sensor.py b/esphome/components/sm300d2/sensor.py index 4245283ab48..b63db068e63 100644 --- a/esphome/components/sm300d2/sensor.py +++ b/esphome/components/sm300d2/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CO2, CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_PM_2_5, CONF_PM_10_0, CONF_TEMPERATURE, @@ -95,9 +94,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index 35ca231f023..587cb13662b 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -5,7 +5,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.const import CONF_ON_DATA from esphome.types import ConfigType CODEOWNERS = ["@alengwenus"] @@ -50,9 +50,7 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/smt100/sensor.py b/esphome/components/smt100/sensor.py index fba10eab9ae..115434ee9ac 100644 --- a/esphome/components/smt100/sensor.py +++ b/esphome/components/smt100/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_COUNTS, CONF_DIELECTRIC_CONSTANT, - CONF_ID, CONF_MOISTURE, CONF_PERMITTIVITY, CONF_TEMPERATURE, @@ -79,10 +78,8 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) cg.add_define("USE_LINE_READER") - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_COUNTS, var.set_counts_sensor) diff --git a/esphome/components/sun_gtil2/__init__.py b/esphome/components/sun_gtil2/__init__.py index 61643cf4883..b66f7075bea 100644 --- a/esphome/components/sun_gtil2/__init__.py +++ b/esphome/components/sun_gtil2/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] @@ -27,6 +26,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py index ae44017112b..ee193c4d3a2 100644 --- a/esphome/components/systa_bus/__init__.py +++ b/esphome/components/systa_bus/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -39,6 +38,4 @@ async def register_systa_bus_listener(systa_bus: MockObj, var: MockObj) -> None: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/t6615/sensor.py b/esphome/components/t6615/sensor.py index 4b055516e79..42223b373ab 100644 --- a/esphome/components/t6615/sensor.py +++ b/esphome/components/t6615/sensor.py @@ -3,7 +3,6 @@ from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( CONF_CO2, - CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, @@ -44,9 +43,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 5a6a01f6695..1353b6a912f 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@0hax", "@FredM67"] @@ -53,6 +52,4 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_HISTORICAL_MODE]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config, config[CONF_HISTORICAL_MODE]) diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index ce06cb82e3c..a3ac518d8eb 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -162,9 +162,7 @@ def final_validate(config: ConfigType) -> ConfigType: async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await stepper.register_stepper(var, config) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index c05db51cdf9..51db405512c 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -2,7 +2,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID +from esphome.const import CONF_SENSOR_DATAPOINT, CONF_TIME_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -83,9 +83,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if (time_id := config.get(CONF_TIME_ID)) is not None: time_ = await cg.get_variable(time_id) cg.add(var.set_time_id(time_)) diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index c7eba4a26a5..4a69029e6f7 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -116,9 +116,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if config[CONF_ROLE] == "server": socket.require_tcp_listener() cg.add(var.set_server(True)) diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index 1846958e591..ced3e3d0af5 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -66,9 +66,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) if _get_data().clear_action_used: cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index 5dc32259ea2..e23f4157a7e 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID, CONF_TIME_ID +from esphome.const import CONF_ADDRESS, CONF_TIME_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -66,9 +66,7 @@ UPONOR_SMATRIX_DEVICE_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: cg.add_global(uponor_smatrix_ns.using) - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) if time_id := config.get(CONF_TIME_ID): time_ = await cg.get_variable(time_id) diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index 533acfbbb92..92709915ad4 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] @@ -40,6 +39,4 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index e451fe79f4d..7ed4ec4b59e 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI +from esphome.const import CONF_POWER_SAVE_MODE, CONF_WIFI import esphome.final_validate as fv from esphome.types import ConfigType @@ -38,7 +38,5 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) cg.add_define("USE_ZWAVE_PROXY") From ec642f928f7c1d0b8806316d6c6ecb8072ecc37b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:15:36 -1000 Subject: [PATCH 074/179] [i2c] Add little-endian and signed byte decoding helpers (#20938) --- esphome/components/i2c/i2c.cpp | 5 ++ esphome/components/i2c/i2c.h | 18 ++++ esphome/core/helpers.h | 37 ++++++-- tests/components/core/test_encode_value.cpp | 48 +++++++++++ tests/components/i2c/test_i2c_le.cpp | 96 +++++++++++++++++++++ 5 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 tests/components/core/test_encode_value.cpp create mode 100644 tests/components/i2c/test_i2c_le.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 61382ccf21e..9d079f9f89e 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -94,6 +94,11 @@ bool I2CDevice::write_byte_16(uint8_t a_register, uint16_t data) const { return this->bus_->write_readv(this->address_, buffer, sizeof(buffer), nullptr, 0) == ERROR_OK; } +bool I2CDevice::write_byte_16_le(uint8_t a_register, uint16_t data) const { + const uint8_t buffer[3] = {a_register, static_cast(data), static_cast(data >> 8)}; + return this->bus_->write_readv(this->address_, buffer, sizeof(buffer), nullptr, 0) == ERROR_OK; +} + I2CRegister &I2CRegister::operator=(uint8_t value) { this->parent_->write_register(this->register_, &value, 1); return *this; diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index 6a9a6533436..d5690b92063 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -280,6 +280,21 @@ class I2CDevice { bool read_byte_16(uint8_t a_register, uint16_t *data) { return read_bytes_16(a_register, data, 1); } + /// Read len 16-bit values stored least significant byte first; no conversion is compiled on little-endian hosts. + bool read_bytes_16_le(uint8_t a_register, uint16_t *data, uint8_t len) { + if (this->read_register(a_register, reinterpret_cast(data), len * 2) != ERROR_OK) + return false; + for (size_t i = 0; i < len; i++) + data[i] = convert_little_endian(data[i]); + return true; + } + bool read_bytes_16_le(uint8_t a_register, int16_t *data, uint8_t len) { + return this->read_bytes_16_le(a_register, reinterpret_cast(data), len); + } + + /// Read a 16-bit value stored least significant byte first. + bool read_byte_16_le(uint8_t a_register, uint16_t *data) { return this->read_bytes_16_le(a_register, data, 1); } + bool write_bytes(uint8_t a_register, const uint8_t *data, uint8_t len) const { return write_register(a_register, data, len) == ERROR_OK; } @@ -298,6 +313,9 @@ class I2CDevice { bool write_byte_16(uint8_t a_register, uint16_t data) const; + /// Write a 16-bit value least significant byte first. + bool write_byte_16_le(uint8_t a_register, uint16_t data) const; + protected: uint8_t address_{0x00}; ///< store the address of the device on the bus I2CBus *bus_{nullptr}; ///< pointer to I2CBus instance diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 2989655a29a..c3e752c9690 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1015,19 +1015,42 @@ template constexpr int32_t sign_extend(uint32_t value) { } /// Encode a value from its constituent bytes (from most to least significant) in an array with length sizeof(T). -template::value, int> = 0> constexpr T encode_value(const uint8_t *bytes) { - T val = 0; - for (size_t i = 0; i < sizeof(T); i++) { - val <<= 8; - val |= bytes[i]; +/// A signed T takes the two's complement of the assembled bits. +template::value, int> = 0> constexpr T encode_value(const uint8_t *bytes) { + if constexpr (sizeof(T) == 1) { + return static_cast(bytes[0]); + } else { + std::make_unsigned_t val = 0; + for (size_t i = 0; i < sizeof(T); i++) { + val <<= 8; + val |= bytes[i]; + } + return static_cast(val); } - return val; } /// Encode a value from its constituent bytes (from most to least significant) in an std::array with length sizeof(T). -template::value, int> = 0> +template::value, int> = 0> constexpr T encode_value(const std::array bytes) { return encode_value(bytes.data()); } +/// Encode a value from its constituent bytes (from least to most significant) in an array with length sizeof(T). +/// A signed T takes the two's complement of the assembled bits. +template::value, int> = 0> +constexpr T encode_value_le(const uint8_t *bytes) { + // Loops here grow 16-bit callers by up to 48 bytes on ESP8266 through different inlining + if constexpr (sizeof(T) == 1) { + return static_cast(bytes[0]); + } else if constexpr (sizeof(T) == 2) { + return static_cast(encode_uint16(bytes[1], bytes[0])); + } else { + std::make_unsigned_t val = 0; + for (size_t i = sizeof(T); i > 0; i--) { + val <<= 8; + val |= bytes[i - 1]; + } + return static_cast(val); + } +} /// Decode a value into its constituent bytes (from most to least significant). template::value, int> = 0> constexpr std::array decode_value(T val) { diff --git a/tests/components/core/test_encode_value.cpp b/tests/components/core/test_encode_value.cpp new file mode 100644 index 00000000000..c76ae50935a --- /dev/null +++ b/tests/components/core/test_encode_value.cpp @@ -0,0 +1,48 @@ +#include + +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static constexpr uint8_t BYTES[] = {0x12, 0x34, 0x56, 0x78}; + +static_assert(encode_value_le(BYTES) == 0x3412); +static_assert(encode_value(BYTES) == 0x1234); + +TEST(EncodeValue, LittleEndianUnsigned) { + EXPECT_EQ(encode_value_le(BYTES), 0x12); + EXPECT_EQ(encode_value_le(BYTES + 1), 0x5634); + EXPECT_EQ(encode_value_le(BYTES), 0x78563412u); +} + +TEST(EncodeValue, BigEndianSigned) { + static constexpr uint8_t MINUS_ONE[] = {0xFF, 0xFF, 0xFF, 0xFF}; + static constexpr uint8_t MIN16[] = {0x80, 0x00}; + static constexpr uint8_t MIN32[] = {0x80, 0x00, 0x00, 0x00}; + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MINUS_ONE), -1); + EXPECT_EQ(encode_value(MIN16), INT16_MIN); + EXPECT_EQ(encode_value(MIN32), INT32_MIN); +} + +TEST(EncodeValue, LittleEndianSigned) { + static constexpr uint8_t MIN16[] = {0x00, 0x80}; + static constexpr uint8_t MAX16[] = {0xFF, 0x7F}; + static constexpr uint8_t MINUS_TWO[] = {0xFE, 0xFF}; + static constexpr uint8_t MINUS_256[] = {0x00, 0xFF, 0xFF, 0xFF}; + EXPECT_EQ(encode_value_le(MIN16), INT16_MIN); + EXPECT_EQ(encode_value_le(MAX16), INT16_MAX); + EXPECT_EQ(encode_value_le(MINUS_TWO), -2); + EXPECT_EQ(encode_value_le(MINUS_256), -256); +} + +TEST(EncodeValue, BoolStillCompiles) { + static constexpr uint8_t ONE[] = {0x01}; + static_assert(encode_value(ONE)); + static_assert(encode_value_le(ONE)); +} + +} // namespace esphome::core::testing diff --git a/tests/components/i2c/test_i2c_le.cpp b/tests/components/i2c/test_i2c_le.cpp new file mode 100644 index 00000000000..973490f28d3 --- /dev/null +++ b/tests/components/i2c/test_i2c_le.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include +#include + +#include "esphome/components/i2c/i2c.h" + +namespace esphome::i2c::testing { + +class MemoryBus : public I2CBus { + public: + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + if (this->fail) + return ERROR_NOT_ACKNOWLEDGED; + if (write_count == 0) + return ERROR_INVALID_ARGUMENT; + uint8_t reg = write_buffer[0]; + for (size_t i = 1; i < write_count; i++) + this->memory[reg + i - 1] = write_buffer[i]; + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = this->memory[reg + i]; + this->last_write_count = write_count; + return ERROR_OK; + } + + std::array memory{}; + size_t last_write_count{0}; + bool fail{false}; +}; + +class I2CLittleEndianTest : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(0x40); + } + MemoryBus bus_; + I2CDevice device_; +}; + +TEST_F(I2CLittleEndianTest, ReadByte16LittleEndian) { + this->bus_.memory[4] = 0x34; + this->bus_.memory[5] = 0x12; + uint16_t value = 0; + ASSERT_TRUE(this->device_.read_byte_16_le(4, &value)); + EXPECT_EQ(value, 0x1234); + ASSERT_TRUE(this->device_.read_byte_16(4, &value)); + EXPECT_EQ(value, 0x3412); +} + +TEST_F(I2CLittleEndianTest, ReadBytes16LittleEndian) { + const uint8_t raw[6] = {0x34, 0x12, 0xFF, 0xFF, 0x00, 0x80}; + std::copy(raw, raw + 6, this->bus_.memory.begin() + 8); + uint16_t values[3] = {}; + ASSERT_TRUE(this->device_.read_bytes_16_le(8, values, 3)); + EXPECT_EQ(values[0], 0x1234); + EXPECT_EQ(values[1], 0xFFFF); + EXPECT_EQ(values[2], 0x8000); + int16_t signed_values[3] = {}; + ASSERT_TRUE(this->device_.read_bytes_16_le(8, signed_values, 3)); + EXPECT_EQ(signed_values[0], 0x1234); + EXPECT_EQ(signed_values[1], -1); + EXPECT_EQ(signed_values[2], INT16_MIN); +} + +TEST_F(I2CLittleEndianTest, ReadBytes16FailureReturnsFalse) { + this->bus_.fail = true; + int16_t values[2] = {}; + EXPECT_FALSE(this->device_.read_bytes_16_le(0, values, 2)); +} + +TEST_F(I2CLittleEndianTest, ReadFailureReturnsFalse) { + this->bus_.fail = true; + uint16_t value = 0xBEEF; + EXPECT_FALSE(this->device_.read_byte_16_le(0, &value)); + EXPECT_EQ(value, 0xBEEF); +} + +TEST_F(I2CLittleEndianTest, WriteByte16LittleEndian) { + ASSERT_TRUE(this->device_.write_byte_16_le(2, 0xA1B2)); + EXPECT_EQ(this->bus_.last_write_count, 3u); + EXPECT_EQ(this->bus_.memory[2], 0xB2); + EXPECT_EQ(this->bus_.memory[3], 0xA1); + uint16_t value = 0; + ASSERT_TRUE(this->device_.read_byte_16_le(2, &value)); + EXPECT_EQ(value, 0xA1B2); +} + +TEST_F(I2CLittleEndianTest, WriteFailureReturnsFalse) { + this->bus_.fail = true; + EXPECT_FALSE(this->device_.write_byte_16_le(2, 0xA1B2)); +} + +} // namespace esphome::i2c::testing From c1687aba4c441a2783cd36745311d0acb1f2bcf6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:16:21 -1000 Subject: [PATCH 075/179] [components] Let core report communication failures in dump_config (5/5) (#20874) --- esphome/components/shtcx/shtcx.cpp | 6 +--- esphome/components/sps30/sps30.cpp | 31 ++----------------- esphome/components/sps30/sps30.h | 10 ------ .../components/ssd1306_i2c/ssd1306_i2c.cpp | 7 +---- esphome/components/ssd1306_i2c/ssd1306_i2c.h | 2 -- .../components/ssd1327_i2c/ssd1327_i2c.cpp | 7 +---- esphome/components/ssd1327_i2c/ssd1327_i2c.h | 2 -- esphome/components/st7567_i2c/st7567_i2c.cpp | 7 +---- esphome/components/st7567_i2c/st7567_i2c.h | 2 -- esphome/components/stcc4/stcc4.cpp | 12 ++----- esphome/components/sts3x/sts3x.cpp | 7 ++--- esphome/components/stts22h/stts22h.cpp | 3 -- esphome/components/sx126x/sx126x.cpp | 8 ++--- esphome/components/sx127x/sx127x.cpp | 11 ++----- esphome/components/sx1509/sx1509.cpp | 9 ++---- esphome/components/sy6970/sy6970.cpp | 6 +--- esphome/components/tc74/tc74.cpp | 5 +-- esphome/components/tca8418/tca8418.cpp | 15 +++------ esphome/components/tca9555/tca9555.cpp | 7 ++--- esphome/components/tcs34725/tcs34725.cpp | 13 +++----- .../components/tlc59208f/tlc59208f_output.cpp | 19 +++--------- esphome/components/tmc22xx/tmc22xx.cpp | 3 -- esphome/components/tmp102/tmp102.cpp | 5 +-- esphome/components/tmp1075/tmp1075.cpp | 6 ++-- esphome/components/tmp117/tmp117.cpp | 7 ++--- esphome/components/tsl2561/tsl2561.cpp | 8 ++--- esphome/components/tsl2591/tsl2591.cpp | 4 +-- esphome/components/ttp229_lsf/ttp229_lsf.cpp | 11 +------ esphome/components/ttp229_lsf/ttp229_lsf.h | 4 --- esphome/components/veml3235/veml3235.cpp | 11 ++----- esphome/components/veml7700/veml7700.cpp | 7 +---- .../waveshare_io_ch32v003.cpp | 6 +--- esphome/components/xgzp68xx/xgzp68xx.cpp | 7 ++--- esphome/components/xl9535/xl9535.cpp | 6 +--- 34 files changed, 54 insertions(+), 220 deletions(-) diff --git a/esphome/components/shtcx/shtcx.cpp b/esphome/components/shtcx/shtcx.cpp index 68f2c4ea21e..814e09162ec 100644 --- a/esphome/components/shtcx/shtcx.cpp +++ b/esphome/components/shtcx/shtcx.cpp @@ -29,8 +29,7 @@ void SHTCXComponent::setup() { uint16_t device_id_register; if (!this->write_command(SHTCX_COMMAND_READ_ID_REGISTER) || !this->read_data(&device_id_register, 1)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -53,9 +52,6 @@ void SHTCXComponent::dump_config() { " Model: %s (%04x)", LOG_STR_ARG(shtcx_type_to_string(this->type_)), this->sensor_id_); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); diff --git a/esphome/components/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index 3a2ebb14c39..42628e64c91 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -29,15 +29,13 @@ void SPS30Component::setup() { this->set_timeout(500, [this]() { /// Firmware version identification if (!this->get_register(SPS30_CMD_GET_FIRMWARE_VERSION, raw_firmware_version_, 1)) { - this->error_code_ = FIRMWARE_VERSION_READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read firmware version")); return; } /// Serial number identification uint16_t raw_serial_number[8]; if (!this->get_register(SPS30_CMD_GET_SERIAL_NUMBER, raw_serial_number, 8, 1)) { - this->error_code_ = SERIAL_NUMBER_READ_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read serial number")); return; } @@ -75,31 +73,6 @@ void SPS30Component::setup() { void SPS30Component::dump_config() { ESP_LOGCONFIG(TAG, "SPS30:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case MEASUREMENT_INIT_FAILED: - ESP_LOGW(TAG, "Measurement Initialization failed"); - break; - case SERIAL_NUMBER_REQUEST_FAILED: - ESP_LOGW(TAG, "Unable to request serial number"); - break; - case SERIAL_NUMBER_READ_FAILED: - ESP_LOGW(TAG, "Unable to read serial number"); - break; - case FIRMWARE_VERSION_REQUEST_FAILED: - ESP_LOGW(TAG, "Unable to request firmware version"); - break; - case FIRMWARE_VERSION_READ_FAILED: - ESP_LOGW(TAG, "Unable to read firmware version"); - break; - default: - ESP_LOGW(TAG, "Unknown setup error"); - break; - } - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Serial number: %s\n" diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index 10b89c844bc..c1e47f089ac 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -42,16 +42,6 @@ class SPS30Component final : public PollingComponent, public sensirion_common::S bool start_continuous_measurement_(); - enum ErrorCode : uint8_t { - COMMUNICATION_FAILED, - FIRMWARE_VERSION_REQUEST_FAILED, - FIRMWARE_VERSION_READ_FAILED, - SERIAL_NUMBER_REQUEST_FAILED, - SERIAL_NUMBER_READ_FAILED, - MEASUREMENT_INIT_FAILED, - UNKNOWN - } error_code_{UNKNOWN}; - sensor::Sensor *pm_1_0_sensor_{nullptr}; sensor::Sensor *pm_2_5_sensor_{nullptr}; sensor::Sensor *pm_4_0_sensor_{nullptr}; diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp index 1cdfed9f502..7081d766e32 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp @@ -10,8 +10,7 @@ void I2CSSD1306::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -32,10 +31,6 @@ void I2CSSD1306::dump_config() { LOG_I2C_DEVICE(this); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CSSD1306::command(uint8_t value) { this->write_byte(0x00, value); } void HOT I2CSSD1306::write_display_data() { diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.h b/esphome/components/ssd1306_i2c/ssd1306_i2c.h index 54c7d862870..e77f4464f5f 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.h +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.h @@ -14,8 +14,6 @@ class I2CSSD1306 final : public ssd1306_base::SSD1306, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::ssd1306_i2c diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp index 98328c800c1..11cf3296a15 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp @@ -10,8 +10,7 @@ void I2CSSD1327::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -23,10 +22,6 @@ void I2CSSD1327::dump_config() { ESP_LOGCONFIG(TAG, " Model: %s", this->model_str_()); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CSSD1327::command(uint8_t value) { this->write_byte(0x00, value); } void HOT I2CSSD1327::write_display_data() { diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.h b/esphome/components/ssd1327_i2c/ssd1327_i2c.h index 75f854d3da3..7c4ae3bec11 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.h +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.h @@ -14,8 +14,6 @@ class I2CSSD1327 final : public ssd1327_base::SSD1327, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::ssd1327_i2c diff --git a/esphome/components/st7567_i2c/st7567_i2c.cpp b/esphome/components/st7567_i2c/st7567_i2c.cpp index 657a60f9549..ad672270a8a 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.cpp +++ b/esphome/components/st7567_i2c/st7567_i2c.cpp @@ -10,8 +10,7 @@ void I2CST7567::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ST7567::setup(); @@ -28,10 +27,6 @@ void I2CST7567::dump_config() { LOG_I2C_DEVICE(this); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_UPDATE_INTERVAL(this); - - if (this->error_code_ == COMMUNICATION_FAILED) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void I2CST7567::command(uint8_t value) { this->write_byte(0x00, value); } diff --git a/esphome/components/st7567_i2c/st7567_i2c.h b/esphome/components/st7567_i2c/st7567_i2c.h index eea3068e030..f17ec419785 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.h +++ b/esphome/components/st7567_i2c/st7567_i2c.h @@ -14,8 +14,6 @@ class I2CST7567 final : public st7567_base::ST7567, public i2c::I2CDevice { protected: void command(uint8_t value) override; void write_display_data() override; - - enum ErrorCode { NONE = 0, COMMUNICATION_FAILED } error_code_{NONE}; }; } // namespace esphome::st7567_i2c diff --git a/esphome/components/stcc4/stcc4.cpp b/esphome/components/stcc4/stcc4.cpp index ac7a998e5d6..a5e47584eb0 100644 --- a/esphome/components/stcc4/stcc4.cpp +++ b/esphome/components/stcc4/stcc4.cpp @@ -77,8 +77,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) (uint64_t(raw_product_id[4]) << 16) | raw_product_id[5]; ESP_LOGD(TAG, "Product ID: 0x%08" PRIX32 ", Serial: 0x%016" PRIX64, product_id, serial_number); if (product_id != STCC4_PRODUCT_ID) { - ESP_LOGE(TAG, "Unsupported product ID"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Unsupported product ID")); return; } @@ -106,8 +105,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) // Start continuous measurement if (!this->write_command(STCC4_CMD_START_CONTINUOUS_MEASUREMENT)) { - ESP_LOGE(TAG, "Failed to start continuous measurement"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Failed to start continuous measurement")); return; } this->schedule_continuous_update_(false); @@ -123,8 +121,7 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) return; } - ESP_LOGE(TAG, "Failed to stop continuous measurements and read product ID"); - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + this->mark_failed(LOG_STR("Failed to stop continuous measurements and read product ID")); } void STCC4Component::finish_setup_() { @@ -143,9 +140,6 @@ void STCC4Component::finish_setup_() { void STCC4Component::dump_config() { ESP_LOGCONFIG(TAG, "STCC4:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - } ESP_LOGCONFIG(TAG, " Measurement mode: %s", this->measurement_mode_ == MeasurementMode::CONTINUOUS ? LOG_STR_LITERAL("Continuous (1s)") : LOG_STR_LITERAL("Single shot")); diff --git a/esphome/components/sts3x/sts3x.cpp b/esphome/components/sts3x/sts3x.cpp index 8b97b2f4997..fd9569b951d 100644 --- a/esphome/components/sts3x/sts3x.cpp +++ b/esphome/components/sts3x/sts3x.cpp @@ -18,13 +18,13 @@ static const uint16_t STS3X_COMMAND_FETCH_DATA = 0xE000; void STS3XComponent::setup() { if (!this->write_command(STS3X_COMMAND_READ_SERIAL_NUMBER)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint16_t raw_serial_number[2]; if (!this->read_data(raw_serial_number, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint32_t serial_number = (uint32_t(raw_serial_number[0]) << 16); @@ -33,9 +33,6 @@ void STS3XComponent::setup() { void STS3XComponent::dump_config() { ESP_LOGCONFIG(TAG, "STS3x:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "STS3x", this); diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index 5132b2a3aa5..fa96c6a0227 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -37,9 +37,6 @@ void STTS22HComponent::dump_config() { LOG_SENSOR("", "STTS22H", this); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } float STTS22HComponent::read_temperature_() { diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 0bac0eb0466..6c95fe5f7a6 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -164,7 +164,7 @@ void SX126x::configure() { this->read_register_(REG_VERSION_STRING, (uint8_t *) this->version_, sizeof(this->version_)); this->version_[sizeof(this->version_) - 1] = '\0'; if (strncmp(this->version_, "SX126", 5) != 0 && strncmp(this->version_, "LLCC68", 6) != 0) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unsupported chip version")); return; } @@ -494,8 +494,7 @@ void SX126x::wait_busy_() { uint32_t start = millis(); while (this->busy_pin_->digital_read()) { if (millis() - start > BUSY_TIMEOUT_MS) { - ESP_LOGE(TAG, "Wait busy timeout"); - this->mark_failed(); + this->mark_failed(LOG_STR("Wait busy timeout")); break; } } @@ -562,9 +561,6 @@ void SX126x::dump_config() { if (this->whitening_enable_) { ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->whitening_initial_); } - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring SX126x failed"); - } } } // namespace esphome::sx126x diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index fb816094c29..f48e8de3a35 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -81,7 +81,7 @@ void SX127x::configure() { // check silicon version to make sure hw is ok if (this->read_register_(REG_VERSION) != 0x12) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unsupported chip version")); return; } @@ -367,8 +367,7 @@ void SX127x::run_image_cal() { uint32_t start = millis(); while (this->read_register_(REG_IMAGE_CAL) & IMAGE_CAL_RUNNING) { if (millis() - start > 20) { - ESP_LOGE(TAG, "Image cal failure"); - this->mark_failed(); + this->mark_failed(LOG_STR("Image cal failure")); break; } } @@ -390,8 +389,7 @@ void SX127x::set_mode_(uint8_t modulation, uint8_t mode) { break; } if (millis() - start > 20) { - ESP_LOGE(TAG, "Set mode failure"); - this->mark_failed(); + this->mark_failed(LOG_STR("Set mode failure")); return; } } @@ -508,9 +506,6 @@ void SX127x::dump_config() { this->preamble_polarity_, this->preamble_size_, this->preamble_detect_, this->preamble_errors_); } } - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring SX127x failed"); - } } } // namespace esphome::sx127x diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 169d35331f8..d883982bcd5 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -9,18 +9,18 @@ ESPHOME_LOG_TAG(TAG, "sx1509"); void SX1509Component::setup() { ESP_LOGV(TAG, " Resetting devices"); if (!this->write_byte(REG_RESET, 0x12)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->write_byte(REG_RESET, 0x34); uint16_t data; if (!this->read_byte_16(REG_INTERRUPT_MASK_A, &data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (data != 0xFF00) { - this->mark_failed(); + this->mark_failed(LOG_STR("Unexpected reset value")); return; } clock_(INTERNAL_CLOCK_2MHZ); @@ -31,9 +31,6 @@ void SX1509Component::setup() { void SX1509Component::dump_config() { ESP_LOGCONFIG(TAG, "SX1509:"); - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up SX1509 failed!"); - } LOG_I2C_DEVICE(this); } diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 5e1611ea558..754cfb14ca8 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -60,8 +60,7 @@ void SY6970Component::setup() { // Try to read chip ID uint8_t reg_value; if (!this->read_byte(SY6970_REG_DEVICE_ID, ®_value)) { - ESP_LOGE(TAG, "Failed to communicate with SY6970"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -116,9 +115,6 @@ void SY6970Component::dump_config() { LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Communication with SY6970 failed!"); - } } void SY6970Component::update() { diff --git a/esphome/components/tc74/tc74.cpp b/esphome/components/tc74/tc74.cpp index 98428cd1225..7bba2b7fd75 100644 --- a/esphome/components/tc74/tc74.cpp +++ b/esphome/components/tc74/tc74.cpp @@ -16,7 +16,7 @@ static const uint8_t TC74_DATA_READY_MASK = 0x40; void TC74Component::setup() { uint8_t config_reg; if (this->read_register(TC74_REGISTER_CONFIGURATION, &config_reg, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->data_ready_ = config_reg & TC74_DATA_READY_MASK; @@ -27,9 +27,6 @@ void TC74Component::update() { this->read_temperature_(); } void TC74Component::dump_config() { LOG_SENSOR("", "TC74", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, "Connection with TC74 failed!"); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp index 7add67e9b09..204bc18c1e5 100644 --- a/esphome/components/tca8418/tca8418.cpp +++ b/esphome/components/tca8418/tca8418.cpp @@ -15,21 +15,18 @@ void TCA8418Component::setup() { // The configuration register reads back, so use it to check the device is there. uint8_t config; if (!this->read_byte(TCA8418_REG_CFG, &config)) { - ESP_LOGE(TAG, "Failed to read from device - check wiring / address"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to read from device - check wiring / address")); return; } if (!this->configure_pins_()) { - ESP_LOGE(TAG, "Failed to configure pins"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to configure pins")); return; } // Report key events, and keep the interrupt asserted while events remain queued. if (!this->write_byte(TCA8418_REG_CFG, TCA8418_CFG_KEY_INT_EN | TCA8418_CFG_INT_CFG)) { - ESP_LOGE(TAG, "Failed to write the configuration register"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to write the configuration register")); return; } @@ -43,8 +40,7 @@ void TCA8418Component::setup() { // Discard anything the device queued before it was configured, and clear // every interrupt flag: leaving one set holds the interrupt output asserted. if (!this->drain_events_(false)) { - ESP_LOGE(TAG, "Failed to clear the event queue"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to clear the event queue")); return; } @@ -162,9 +158,6 @@ void TCA8418Component::dump_config() { } LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } } // namespace esphome::tca8418 diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 7a59c403f31..2b234983a20 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -16,11 +16,11 @@ ESPHOME_LOG_TAG(TAG, "tca9555"); void TCA9555Component::setup() { if (!this->read_gpio_modes_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->read_gpio_outputs_()) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -36,9 +36,6 @@ void TCA9555Component::dump_config() { ESP_LOGCONFIG(TAG, "TCA9555:"); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 8e79ac1c162..979137a9729 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -19,23 +19,23 @@ static const uint8_t TCS34725_REGISTER_CRGBDATAL = TCS34725_COMMAND_BIT | 0x14; void TCS34725Component::setup() { uint8_t id; if (this->read_register(TCS34725_REGISTER_ID, &id, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->write_config_register_(TCS34725_REGISTER_ATIME, this->integration_reg_) != i2c::ERROR_OK || this->write_config_register_(TCS34725_REGISTER_CONTROL, this->gain_reg_) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->write_config_register_(TCS34725_REGISTER_ENABLE, 0x01) != i2c::ERROR_OK) { // Power on (internal oscillator on) - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } delay(3); if (this->write_config_register_(TCS34725_REGISTER_ENABLE, 0x03) != i2c::ERROR_OK) { // Power on (internal oscillator on) + RGBC ADC Enable - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -43,9 +43,6 @@ void TCS34725Component::setup() { void TCS34725Component::dump_config() { ESP_LOGCONFIG(TAG, "TCS34725:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Clear Channel", this->clear_sensor_); @@ -292,7 +289,7 @@ void TCS34725Component::update() { set_gain((TCS34725Gain) gain_reg_val_new); if (this->write_config_register_(TCS34725_REGISTER_ATIME, this->integration_reg_) != i2c::ERROR_OK || this->write_config_register_(TCS34725_REGISTER_CONTROL, this->gain_reg_) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); ESP_LOGW(TAG, "TCS34725I update timing failed!"); } else { this->integration_time_ = integration_time_next; diff --git a/esphome/components/tlc59208f/tlc59208f_output.cpp b/esphome/components/tlc59208f/tlc59208f_output.cpp index d76845b4f03..f73dcbd611b 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.cpp +++ b/esphome/components/tlc59208f/tlc59208f_output.cpp @@ -65,32 +65,27 @@ void TLC59208FOutput::setup() { // Reset all devices on the bus if (this->bus_->write_readv(TLC59208F_SWRST_ADDR >> 1, TLC59208F_SWRST_SEQ, sizeof TLC59208F_SWRST_SEQ, nullptr, 0) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "RESET failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("RESET failed")); return; } // Auto increment registers, and respond to all-call address if (!this->write_byte(TLC59208F_REG_MODE1, TLC59208F_MODE1_AI2 | TLC59208F_MODE1_ALLCALL)) { - ESP_LOGE(TAG, "MODE1 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("MODE1 failed")); return; } if (!this->write_byte(TLC59208F_REG_MODE2, this->mode_)) { - ESP_LOGE(TAG, "MODE2 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("MODE2 failed")); return; } // Set all 3 outputs to be individually controlled // TODO: think of a way to support group dimming if (!this->write_byte(TLC59208F_REG_LEDOUT0, (LDR_PWM << 6) | (LDR_PWM << 4) | (LDR_PWM << 2) | (LDR_PWM << 0))) { - ESP_LOGE(TAG, "LEDOUT0 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("LEDOUT0 failed")); return; } if (!this->write_byte(TLC59208F_REG_LEDOUT1, (LDR_PWM << 6) | (LDR_PWM << 4) | (LDR_PWM << 2) | (LDR_PWM << 0))) { - ESP_LOGE(TAG, "LEDOUT1 failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("LEDOUT1 failed")); return; } delayMicroseconds(500); @@ -103,10 +98,6 @@ void TLC59208FOutput::dump_config() { "TLC59208F:\n" " Mode: 0x%02X", this->mode_); - - if (this->is_failed()) { - ESP_LOGE(TAG, "Setting up TLC59208F failed!"); - } } void TLC59208FOutput::loop() { diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 7bb756819bd..f68496bd0ca 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -256,9 +256,6 @@ void TMC22XXStepper::dump_config() { LOG_PIN(" INDEX Pin: ", this->index_pin_); LOG_PIN(" DIAG Pin: ", this->diag_pin_); LOG_STEPPER(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void TMC22XXStepper::loop() { diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index 06d94f1ba86..7639c00cf95 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -96,7 +96,7 @@ void TMP102Component::setup_configured_() { #endif if (!this->write_configuration_()) { this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SETUP_FAILED); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } #ifdef USE_TMP102_NUMBER @@ -123,9 +123,6 @@ void TMP102Component::setup_configured_() { void TMP102Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP102:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); #ifdef USE_TMP102_BINARY_SENSOR diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 9b152c1ad34..0cb2456cbd3 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -20,7 +20,7 @@ void TMP1075Sensor::setup() { uint8_t cfg; if (!this->read_byte(REG_CFGR, &cfg)) { ESP_LOGE(TAG, "'%s' - unable to read", this->name_.c_str()); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -42,10 +42,8 @@ void TMP1075Sensor::update() { void TMP1075Sensor::dump_config() { LOG_SENSOR("", "TMP1075 Sensor", this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " limit low : %.4f °C\n" " limit high : %.4f °C\n" diff --git a/esphome/components/tmp117/tmp117.cpp b/esphome/components/tmp117/tmp117.cpp index 5925b297c2b..3c76e098b1b 100644 --- a/esphome/components/tmp117/tmp117.cpp +++ b/esphome/components/tmp117/tmp117.cpp @@ -25,22 +25,19 @@ void TMP117Component::update() { } void TMP117Component::setup() { if (!this->write_config_(this->config_)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } int16_t data; if (!this->read_data_(&data)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void TMP117Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP117:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_SENSOR(" ", "Temperature", this); } diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index d1d1d66a265..f887b7d32ca 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -18,13 +18,13 @@ static const uint8_t TSL2561_REGISTER_DATA_1 = 0x0E; void TSL2561Sensor::setup() { uint8_t id; if (!this->tsl2561_read_byte(TSL2561_REGISTER_ID, &id)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } uint8_t timing; if (!this->tsl2561_read_byte(TSL2561_REGISTER_TIMING, &timing)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -40,10 +40,6 @@ void TSL2561Sensor::dump_config() { LOG_SENSOR("", "TSL2561", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } - int gain = this->gain_ == TSL2561_GAIN_1X ? 1 : 16; ESP_LOGCONFIG(TAG, " Gain: %dx\n" diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index 493f4ad3627..46b0f4c8054 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -73,10 +73,8 @@ void TSL2591Component::dump_config() { ESP_LOGCONFIG(TAG, "TSL2591:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->is_failed()) return; - } ESP_LOGCONFIG(TAG, " Name: %s", this->name_); TSL2591ComponentGain raw_gain = this->component_gain_; diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.cpp b/esphome/components/ttp229_lsf/ttp229_lsf.cpp index 7ed06c6d6f2..bf5c3f67284 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.cpp +++ b/esphome/components/ttp229_lsf/ttp229_lsf.cpp @@ -8,22 +8,13 @@ ESPHOME_LOG_TAG(TAG, "ttp229_lsf"); void TTP229LSFComponent::setup() { uint8_t data[2]; if (this->read(data, 2) != i2c::ERROR_OK) { - this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } void TTP229LSFComponent::dump_config() { ESP_LOGCONFIG(TAG, "ttp229:"); LOG_I2C_DEVICE(this); - switch (this->error_code_) { - case COMMUNICATION_FAILED: - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - break; - case NONE: - default: - break; - } } void TTP229LSFComponent::loop() { uint16_t touched = 0; diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.h b/esphome/components/ttp229_lsf/ttp229_lsf.h index 50e2baa7f7a..e221ded0855 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.h +++ b/esphome/components/ttp229_lsf/ttp229_lsf.h @@ -26,10 +26,6 @@ class TTP229LSFComponent final : public Component, public i2c::I2CDevice { protected: std::vector channels_{}; - enum ErrorCode { - NONE = 0, - COMMUNICATION_FAILED, - } error_code_{NONE}; }; } // namespace esphome::ttp229_lsf diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 3a856696782..09340f3bdee 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -21,16 +21,14 @@ static const uint8_t MAX_ADJUSTMENTS_PER_UPDATE = 2; void VEML3235Sensor::setup() { uint8_t device_id[] = {0, 0}; if (!this->refresh_config_reg()) { - ESP_LOGE(TAG, "Unable to write configuration"); - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to write configuration")); return; } if ((this->read_register(ID_REG, device_id, sizeof device_id) != i2c::ERROR_OK)) { - ESP_LOGE(TAG, "Unable to read ID"); - this->mark_failed(); + this->mark_failed(LOG_STR("Unable to read ID")); } else if (device_id[0] != DEVICE_ID) { ESP_LOGE(TAG, "Incorrect device ID - expected 0x%.2x, read 0x%.2x", DEVICE_ID, device_id[0]); - this->mark_failed(); + this->mark_failed(LOG_STR("Incorrect device ID")); } } @@ -187,9 +185,6 @@ void VEML3235Sensor::dump_config() { LOG_SENSOR("", "VEML3235", this); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } LOG_UPDATE_INTERVAL(this); ESP_LOGCONFIG(TAG, " Auto-gain enabled: %s", YESNO(this->auto_gain_)); if (this->auto_gain_) { diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 236b4bb2a98..210e68705bb 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -80,8 +80,7 @@ static const char *get_gain_str(Gain gain) { void VEML7700Component::setup() { auto err = this->configure_(); if (err != i2c::ERROR_OK) { - ESP_LOGW(TAG, "Sensor configuration failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Sensor configuration failed")); } else { this->state_ = State::INITIAL_SETUP_COMPLETED; } @@ -109,10 +108,6 @@ void VEML7700Component::dump_config() { LOG_SENSOR(" ", "FAKE_IR channel lux", this->fake_infrared_sensor_); LOG_SENSOR(" ", "Actual gain", this->actual_gain_sensor_); LOG_SENSOR(" ", "Actual integration time", this->actual_integration_time_sensor_); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } void VEML7700Component::update() { diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 159390086f5..0347cfa083c 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -20,8 +20,7 @@ void WaveshareIOCH32V003Component::setup() { bool step2 = this->write_gpio_outputs_(); if (!step1 || !step2) { - ESP_LOGE(TAG, "Failed to initialize Waveshare IO expander"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to initialize Waveshare IO expander")); return; } @@ -46,9 +45,6 @@ void WaveshareIOCH32V003Component::loop() { this->reset_pin_cache_(); } void WaveshareIOCH32V003Component::dump_config() { ESP_LOGCONFIG(TAG, "WaveshareIO:"); LOG_I2C_DEVICE(this) - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } uint16_t WaveshareIOCH32V003Component::get_adc_value() { diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index b184a855f6f..8ab42d24fe0 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -93,11 +93,11 @@ void XGZP68XXComponent::setup() { // Display some sample bits to confirm we are talking to the sensor if (i2c::ErrorCode::ERROR_OK != this->read_register(SYSCONFIG_ADDRESS, &config1, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (i2c::ErrorCode::ERROR_OK != this->read_register(PCONFIG_ADDRESS, &config2, 1)) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } ESP_LOGD(TAG, "sys_config 0x%x, p_config 0x%x", config1, config2); @@ -111,9 +111,6 @@ void XGZP68XXComponent::dump_config() { ESP_LOGCONFIG(TAG, " Oversampling: %s", oversampling_to_str(this->pressure_oversampling_)); } LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, " Connection failed"); - } LOG_UPDATE_INTERVAL(this); } diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index 228274b3211..9be536353c8 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -9,7 +9,7 @@ void XL9535Component::setup() { // Check to see if the device can read from the register uint8_t port = 0; if (this->read_register(XL9535_INPUT_PORT_0_REGISTER, &port, 1) != i2c::ERROR_OK) { - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } @@ -17,10 +17,6 @@ void XL9535Component::setup() { void XL9535Component::dump_config() { ESP_LOGCONFIG(TAG, "XL9535:"); LOG_I2C_DEVICE(this); - - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - } } bool XL9535Component::digital_read(uint8_t pin) { From 74bcec34ad22e136bd9169050f2e47e148f9f5f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:18:36 -1000 Subject: [PATCH 076/179] [i2c] Add I2CDevice::update_register for register read-modify-write (#20854) --- esphome/components/i2c/i2c.cpp | 9 ++ esphome/components/i2c/i2c.h | 5 ++ .../i2c/test_i2c_update_register.cpp | 82 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 tests/components/i2c/test_i2c_update_register.cpp diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 9d079f9f89e..8aa1fbe3cf3 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -75,6 +75,15 @@ bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { return true; } +bool I2CDevice::update_register(uint8_t a_register, uint8_t mask, uint8_t value) { + uint8_t data; + if (!this->read_byte(a_register, &data)) + return false; + const uint8_t updated = (data & ~mask) | (value & mask); + ESP_LOGV(TAG, "Register 0x%02X: 0x%02X -> 0x%02X", a_register, data, updated); + return this->write_byte(a_register, updated); +} + bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) const { const size_t size = 1 + len * 2; SmallBufferWithHeapFallback<17> buffer_alloc(size); // Register plus up to 8 words diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index d5690b92063..1c3137c6d5f 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -316,6 +316,11 @@ class I2CDevice { /// Write a 16-bit value least significant byte first. bool write_byte_16_le(uint8_t a_register, uint16_t data) const; + /// Read a register, replace the bits in @p mask with those of @p value and write it back. + /// The write always happens; bits of @p value outside @p mask are ignored. Logs both values at VERBOSE. + /// @return false if the read or the write failed (nothing is written after a failed read) + bool update_register(uint8_t a_register, uint8_t mask, uint8_t value); + protected: uint8_t address_{0x00}; ///< store the address of the device on the bus I2CBus *bus_{nullptr}; ///< pointer to I2CBus instance diff --git a/tests/components/i2c/test_i2c_update_register.cpp b/tests/components/i2c/test_i2c_update_register.cpp new file mode 100644 index 00000000000..f973a726cad --- /dev/null +++ b/tests/components/i2c/test_i2c_update_register.cpp @@ -0,0 +1,82 @@ +#include + +#include +#include + +#include "esphome/components/i2c/i2c.h" + +namespace esphome::i2c::testing { + +static constexpr uint8_t ADDRESS = 0x40; +static constexpr uint8_t REG = 0x12; + +class RegisterBus final : public I2CBus { + public: + std::array regs{}; + std::vector writes; + bool fail_read{false}; + bool fail_write{false}; + + ErrorCode write_readv(uint8_t address, const uint8_t *out, size_t out_size, uint8_t *in, size_t in_size) override { + EXPECT_EQ(address, ADDRESS); + if (out_size == 0) + return ERROR_TIMEOUT; + const uint8_t reg = out[0]; + if (in_size > 0) { + if (this->fail_read) + return ERROR_TIMEOUT; + for (size_t i = 0; i < in_size; i++) + in[i] = this->regs[reg + i]; + return ERROR_OK; + } + if (this->fail_write) + return ERROR_TIMEOUT; + this->writes.push_back(reg); + for (size_t i = 1; i < out_size; i++) + this->regs[reg + i - 1] = out[i]; + return ERROR_OK; + } +}; + +class UpdateRegisterTest : public ::testing::Test { + protected: + void SetUp() override { + this->device_.set_i2c_bus(&this->bus_); + this->device_.set_i2c_address(ADDRESS); + } + + RegisterBus bus_; + I2CDevice device_; +}; + +TEST_F(UpdateRegisterTest, ReplacesOnlyMaskedBits) { + this->bus_.regs[REG] = 0b1010'0101; + EXPECT_TRUE(this->device_.update_register(REG, 0b0000'1111, 0b0000'0011)); + EXPECT_EQ(this->bus_.regs[REG], 0b1010'0011); +} + +TEST_F(UpdateRegisterTest, IgnoresValueBitsOutsideMask) { + this->bus_.regs[REG] = 0x00; + EXPECT_TRUE(this->device_.update_register(REG, 0x0F, 0xFF)); + EXPECT_EQ(this->bus_.regs[REG], 0x0F); +} + +TEST_F(UpdateRegisterTest, WritesEvenWhenUnchanged) { + this->bus_.regs[REG] = 0x3C; + EXPECT_TRUE(this->device_.update_register(REG, 0x0C, 0x0C)); + EXPECT_EQ(this->bus_.regs[REG], 0x3C); + EXPECT_EQ(this->bus_.writes, std::vector{REG}); +} + +TEST_F(UpdateRegisterTest, FailedReadWritesNothing) { + this->bus_.fail_read = true; + EXPECT_FALSE(this->device_.update_register(REG, 0xFF, 0x01)); + EXPECT_TRUE(this->bus_.writes.empty()); +} + +TEST_F(UpdateRegisterTest, FailedWriteReturnsFalse) { + this->bus_.fail_write = true; + EXPECT_FALSE(this->device_.update_register(REG, 0xFF, 0x01)); +} + +} // namespace esphome::i2c::testing From 32dc3b85351dd5ffc90fac187f9e70c040bf2deb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:19:24 -1000 Subject: [PATCH 077/179] [http_request] Test ota.http_request.flash with every field (#20829) --- tests/components/http_request/common.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/components/http_request/common.yaml b/tests/components/http_request/common.yaml index 62d0a7941aa..f20bd1a23f0 100644 --- a/tests/components/http_request/common.yaml +++ b/tests/components/http_request/common.yaml @@ -42,6 +42,20 @@ button: md5: 0123456789abcdef0123456789abcdef url: http://my.ha.net:8123/local/esphome/firmware.bin + - ota.http_request.flash: + id: http_request_ota + md5_url: !lambda 'return std::string("http://my.ha.net:8123/local/esphome/firmware.md5");' + username: user + password: !lambda 'return std::string("pass");' + url: http://my.ha.net:8123/local/esphome/firmware.bin + + - ota.http_request.flash: + id: http_request_ota + md5: !lambda 'return std::string("0123456789abcdef0123456789abcdef");' + username: !lambda 'return std::string("user");' + password: pass + url: !lambda 'return std::string("http://my.ha.net:8123/local/esphome/firmware.bin");' + - logger.log: "This message should be not displayed (reboot)" update: From 68fa7b9847075aab6ecf58008a047d574093c53b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:20:00 -1000 Subject: [PATCH 078/179] [wifi] Test the wifi.connected and wifi.enabled conditions (#20841) --- tests/components/wifi/common.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/wifi/common.yaml b/tests/components/wifi/common.yaml index 10a8a61c661..63bd820e673 100644 --- a/tests/components/wifi/common.yaml +++ b/tests/components/wifi/common.yaml @@ -14,6 +14,14 @@ esphome: condition: wifi.ap_active then: - logger.log: "WiFi AP is active!" + - if: + condition: wifi.connected + then: + - logger.log: "WiFi is connected!" + - if: + condition: wifi.enabled + then: + - logger.log: "WiFi is enabled!" - wifi.roam wifi: From 626d84bc641007476d8eec710904e6aabb13cae2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:20:52 -1000 Subject: [PATCH 079/179] [sensor][number] Test in_range with every bound combination (#20847) --- tests/components/template/common-base.yaml | 32 ++++ .../fixtures/in_range_conditions.yaml | 158 ++++++++++++++++++ tests/integration/test_in_range_conditions.py | 62 +++++++ 3 files changed, 252 insertions(+) create mode 100644 tests/integration/fixtures/in_range_conditions.yaml create mode 100644 tests/integration/test_in_range_conditions.py diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index f87a4e0320e..76a5e0b1a2f 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -76,6 +76,38 @@ esphome: date: "2021-01-01" binary_sensor: + - platform: template + id: template_sensor_above + condition: + sensor.in_range: + id: template_template_sens + above: 10.0 + - platform: template + id: template_sensor_between + condition: + sensor.in_range: + id: template_template_sens + above: 10.0 + below: 30.0 + - platform: template + id: template_number_above + condition: + number.in_range: + id: template_number + above: 10.0 + - platform: template + id: template_number_below + condition: + number.in_range: + id: template_number + below: 90.0 + - platform: template + id: template_number_between + condition: + number.in_range: + id: template_number + above: 10.0 + below: 90.0 - platform: template id: template_some_binary_sensor name: "Garage Door Open" diff --git a/tests/integration/fixtures/in_range_conditions.yaml b/tests/integration/fixtures/in_range_conditions.yaml new file mode 100644 index 00000000000..f7f636aa584 --- /dev/null +++ b/tests/integration/fixtures/in_range_conditions.yaml @@ -0,0 +1,158 @@ +esphome: + name: test-in-range-conditions + +host: + +api: + actions: + - action: run_cases + then: + - script.execute: + id: check + label: neg + value: !lambda "return -10.0f;" + - script.wait: check + - script.execute: + id: check + label: low + value: !lambda "return 0.0f;" + - script.wait: check + - script.execute: + id: check + label: mid + value: !lambda "return 5.0f;" + - script.wait: check + - script.execute: + id: check + label: high + value: !lambda "return 10.0f;" + - script.wait: check + - script.execute: + id: check + label: over + value: !lambda "return 20.0f;" + - script.wait: check + - script.execute: + id: check + label: nan + value: !lambda "return NAN;" + - script.wait: check + - script.execute: + id: check + label: neginf + value: !lambda "return -INFINITY;" + - script.wait: check + - script.execute: + id: check + label: posinf + value: !lambda "return INFINITY;" + - script.wait: check + - logger.log: "INRANGE done" + +logger: + level: DEBUG + +sensor: + - platform: template + id: range_sensor + name: Range Sensor + +number: + - platform: template + id: range_number + name: Range Number + optimistic: true + min_value: -100 + max_value: 100 + step: 0.5 + +script: + - id: check + parameters: + label: string + value: float + then: + # Set the states directly so NaN and infinities reach the conditions unchanged + - lambda: |- + id(range_sensor).publish_state(value); + id(range_number).publish_state(value); + - if: + condition: + sensor.in_range: + id: range_sensor + above: 0 + then: + - logger.log: + format: "INRANGE %s sensor_above 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_above 0" + args: [label.c_str()] + - if: + condition: + sensor.in_range: + id: range_sensor + below: 10 + then: + - logger.log: + format: "INRANGE %s sensor_below 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_below 0" + args: [label.c_str()] + - if: + condition: + sensor.in_range: + id: range_sensor + above: 0 + below: 10 + then: + - logger.log: + format: "INRANGE %s sensor_both 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s sensor_both 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + above: 0 + then: + - logger.log: + format: "INRANGE %s number_above 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_above 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + below: 10 + then: + - logger.log: + format: "INRANGE %s number_below 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_below 0" + args: [label.c_str()] + - if: + condition: + number.in_range: + id: range_number + above: 0 + below: 10 + then: + - logger.log: + format: "INRANGE %s number_both 1" + args: [label.c_str()] + else: + - logger.log: + format: "INRANGE %s number_both 0" + args: [label.c_str()] diff --git a/tests/integration/test_in_range_conditions.py b/tests/integration/test_in_range_conditions.py new file mode 100644 index 00000000000..667f7fe43d4 --- /dev/null +++ b/tests/integration/test_in_range_conditions.py @@ -0,0 +1,62 @@ +"""Test sensor.in_range and number.in_range against a fixed truth table.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Bounds in the fixture: above 0, below 10, both inclusive. A NaN state is never in range. +# Columns: above only, below only, both bounds. +EXPECTED = { + "neg": (0, 1, 0), # -10 + "low": (1, 1, 1), # 0, the lower bound + "mid": (1, 1, 1), # 5 + "high": (1, 1, 1), # 10, the upper bound + "over": (1, 0, 0), # 20 + "nan": (0, 0, 0), + "neginf": (0, 1, 0), + "posinf": (1, 0, 0), +} +CONDITIONS = ("above", "below", "both") +RESULT_RE = re.compile(r"INRANGE (\w+) (sensor|number)_(above|below|both) ([01])") + + +@pytest.mark.asyncio +async def test_in_range_conditions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Each in_range condition gives the same result for sensors and numbers.""" + loop = asyncio.get_running_loop() + results: dict[tuple[str, str, str], int] = {} + done = loop.create_future() + + def check_output(line: str) -> None: + if match := RESULT_RE.search(line): + label, entity, condition, value = match.groups() + results[(label, entity, condition)] = int(value) + elif "INRANGE done" in line and not done.done(): + done.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service = next((s for s in services if s.name == "run_cases"), None) + assert service is not None, "run_cases action not found" + await client.execute_service(service, {}) + await asyncio.wait_for(done, timeout=10.0) + + expected = { + (label, entity, condition): row[index] + for label, row in EXPECTED.items() + for entity in ("sensor", "number") + for index, condition in enumerate(CONDITIONS) + } + assert results == expected From 21362f8c92ab8e8d5002569ef508e0a98c9afc6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:21:18 -1000 Subject: [PATCH 080/179] [ethernet] Test the ethernet.connected condition (#20844) --- tests/components/ethernet/test-w5500.esp32-idf.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/components/ethernet/test-w5500.esp32-idf.yaml b/tests/components/ethernet/test-w5500.esp32-idf.yaml index c99dfaf5281..ebd7aa360ab 100644 --- a/tests/components/ethernet/test-w5500.esp32-idf.yaml +++ b/tests/components/ethernet/test-w5500.esp32-idf.yaml @@ -38,6 +38,11 @@ button: - platform: template name: "Disable Ethernet" on_press: + - if: + condition: + ethernet.connected: + then: + - logger.log: "Disabling connected Ethernet" - ethernet.disable: binary_sensor: From 34e6d3ec9c75a21efb5ba73ea0587f9aeca678e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:21:36 -1000 Subject: [PATCH 081/179] [pylontech] Restore the CONF_ID import (#21013) --- esphome/components/pylontech/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 3fc194da057..ed18556002f 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -6,6 +6,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv +from esphome.const import CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType From a2754f655c1f5d297cd163ed27798e38bcd391e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:06 -1000 Subject: [PATCH 082/179] [dfrobot_sen0609][http_request] Use ESPHOME_strstr_P (#20825) --- .../dfrobot_sen0609/dfrobot_sen0609.cpp | 18 +++++------------- .../http_request/http_request_arduino.cpp | 3 ++- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp index e1a1dbe89d0..802142bb8de 100644 --- a/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp +++ b/esphome/components/dfrobot_sen0609/dfrobot_sen0609.cpp @@ -64,15 +64,6 @@ static bool starts_with(const char *str, ESPHOME_PGM_P prefix) { return ESPHOME_strncmp_P(str, prefix, ESPHOME_strlen_P(prefix)) == 0; } -static bool contains(const char *str, ESPHOME_PGM_P needle) { - const size_t len = ESPHOME_strlen_P(needle); - for (; *str != '\0'; str++) { - if (ESPHOME_strncmp_P(str, needle, len) == 0) - return true; - } - return false; -} - // Splits "a,b,c*" in place; returns the number of fields stored in `fields` static uint8_t split_fields(char *str, char **fields, uint8_t max_fields) { uint8_t count = 0; @@ -94,9 +85,9 @@ static uint8_t split_fields(char *str, char **fields, uint8_t max_fields) { static bool answer_means_done(Operation operation, const char *line) { switch (operation) { case Operation::OPERATION_STOP: - return contains(line, ESPHOME_PSTR("stopped already")); + return ESPHOME_strstr_P(line, ESPHOME_PSTR("stopped already")) != nullptr; case Operation::OPERATION_START: - return contains(line, ESPHOME_PSTR("started already")); + return ESPHOME_strstr_P(line, ESPHOME_PSTR("started already")) != nullptr; case Operation::OPERATION_SAVE: return starts_with(line, ESPHOME_PSTR("no parameter")); default: @@ -261,11 +252,12 @@ void DFRobotSEN0609Component::handle_line_(char *line) { this->finish_operation_(); } else if (starts_with(line, ESPHOME_PSTR("Response "))) { this->parse_response_(line + 9); - } else if (contains(line, ESPHOME_PSTR("not stopped"))) { + } else if (ESPHOME_strstr_P(line, ESPHOME_PSTR("not stopped")) != nullptr) { // Stop the sensor before the next attempt this->stopped_ = false; this->fail_operation_(); - } else if (starts_with(line, ESPHOME_PSTR("Error")) || contains(line, ESPHOME_PSTR("not recognized"))) { + } else if (starts_with(line, ESPHOME_PSTR("Error")) || + ESPHOME_strstr_P(line, ESPHOME_PSTR("not recognized")) != nullptr) { ESP_LOGW(TAG, "Command '%s' rejected: %s", this->command_, line); this->fail_operation_(); } else { diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 2062122adf6..814d598132e 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -10,6 +10,7 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" // Include BearSSL error constants for TLS failure diagnostics #ifdef USE_ESP8266 @@ -39,7 +40,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons const uint32_t start = millis(); - bool secure = strstr(url, "https:") != nullptr; + bool secure = ESPHOME_strstr_P(url, ESPHOME_PSTR("https:")) != nullptr; container->set_secure(secure); container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms)); From af5efabdc6313eb5a841c54796afc351b7426977 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:22 -1000 Subject: [PATCH 083/179] [light] Test light.toggle with a transition length (#20832) --- tests/components/light/common.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index c1e58c673f6..95b587df45d 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -83,6 +83,12 @@ esphome: call.perform(); - light.toggle: test_binary_light + - light.toggle: + id: test_monochromatic_light + transition_length: 500ms + - light.toggle: + id: test_monochromatic_light + transition_length: !lambda "return 250;" - light.turn_off: test_rgb_light - if: condition: From 5df6c65cc249adef35af836f089eb7a5a91f4fa8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:23:35 -1000 Subject: [PATCH 084/179] [api] Test api.connected without options and with a lambda (#20835) --- tests/components/api/common-base.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 7f6cb83f16c..e64ba88f112 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -5,6 +5,17 @@ esphome: condition: api.connected: state_subscription_only: true + - if: + condition: + api.connected: + then: + - logger.log: "API client connected" + - if: + condition: + api.connected: + state_subscription_only: !lambda "return true;" + then: + - logger.log: "API client subscribed to states" - homeassistant.event: event: esphome.button_pressed data: From 1dbad7b951d9d215706a71751b3eaaf5e3e7d8ec Mon Sep 17 00:00:00 2001 From: Lorenzo Prosseda Date: Sun, 18 Oct 2026 01:37:58 +0000 Subject: [PATCH 085/179] [airton] Add support to climate for Airton IR A/C units (#7753) Co-authored-by: Arjan <44190435+vingerha@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/airton/__init__.py | 2 + esphome/components/airton/airton.cpp | 319 ++++++++++++++++++ esphome/components/airton/airton.h | 137 ++++++++ esphome/components/airton/climate.py | 70 ++++ esphome/components/airton/select/__init__.py | 37 ++ esphome/components/airton/select/direction.h | 17 + esphome/components/airton/switch/__init__.py | 41 +++ .../components/airton/switch/airton_switch.h | 19 ++ tests/components/airton/common-bare.yaml | 13 + tests/components/airton/common.yaml | 34 ++ tests/components/airton/test.esp32-idf.yaml | 4 + tests/components/airton/test.esp8266-ard.yaml | 4 + 13 files changed, 700 insertions(+) create mode 100644 esphome/components/airton/__init__.py create mode 100644 esphome/components/airton/airton.cpp create mode 100644 esphome/components/airton/airton.h create mode 100644 esphome/components/airton/climate.py create mode 100644 esphome/components/airton/select/__init__.py create mode 100644 esphome/components/airton/select/direction.h create mode 100644 esphome/components/airton/switch/__init__.py create mode 100644 esphome/components/airton/switch/airton_switch.h create mode 100644 tests/components/airton/common-bare.yaml create mode 100644 tests/components/airton/common.yaml create mode 100644 tests/components/airton/test.esp32-idf.yaml create mode 100644 tests/components/airton/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 0def07d0237..19f1194bd95 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -30,6 +30,9 @@ esphome/components/airthings_ble/* @jeromelaban esphome/components/airthings_wave_base/* @jeromelaban @ncareau esphome/components/airthings_wave_mini/* @ncareau esphome/components/airthings_wave_plus/* @jeromelaban @precurse +esphome/components/airton/* @Procsiab +esphome/components/airton/select/* @Procsiab +esphome/components/airton/switch/* @Procsiab esphome/components/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/alpha3/* @jan-hofmeier esphome/components/am2315c/* @swoboda1337 diff --git a/esphome/components/airton/__init__.py b/esphome/components/airton/__init__.py new file mode 100644 index 00000000000..675b3186690 --- /dev/null +++ b/esphome/components/airton/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@Procsiab"] +DOMAIN = "airton" diff --git a/esphome/components/airton/airton.cpp b/esphome/components/airton/airton.cpp new file mode 100644 index 00000000000..a20ddc510c2 --- /dev/null +++ b/esphome/components/airton/airton.cpp @@ -0,0 +1,319 @@ +#include "airton.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::airton { + +ESPHOME_LOG_TAG(TAG, "airton.climate"); + +// Distinct from the climate's own RESTORE_STATE_VERSION, which shares the entity's preference key +static constexpr uint32_t RESTORE_SETTINGS_VERSION = 0x2E86A1F3UL; + +// From IRutils.h of IRremoteESP8266 library +static uint8_t checksum(const uint8_t *frame) { + return static_cast(0x7F - sum_bytes(frame, AIRTON_STATE_FRAME_SIZE - 1)) ^ 0x2C; +} + +void AirtonClimate::setup() { + climate_ir::ClimateIR::setup(); + // The flap position is the one source of truth for the swing mode the climate state restored + this->settings_pref_ = this->make_entity_preference(RESTORE_SETTINGS_VERSION); + this->settings_pref_.load(&this->settings_); + this->sync_swing_mode_(); + this->publish_settings_(); + this->publish_state(); +} + +void AirtonClimate::publish_settings_() { +#ifdef USE_SWITCH + if (this->sleep_mode_switch_ != nullptr) + this->sleep_mode_switch_->publish_state(this->settings_.sleep_state); + if (this->display_switch_ != nullptr) + this->display_switch_->publish_state(this->settings_.display_state); +#endif +#ifdef USE_SELECT + if (this->vertical_direction_select_ != nullptr) + this->vertical_direction_select_->publish_state(static_cast(this->settings_.vertical_direction)); +#endif +} + +void AirtonClimate::save_settings_() { this->settings_pref_.save(&this->settings_); } + +void AirtonClimate::sync_swing_mode_() { + this->swing_mode = this->settings_.vertical_direction == VerticalDirection::VERTICAL_DIRECTION_SWING + ? climate::CLIMATE_SWING_VERTICAL + : climate::CLIMATE_SWING_OFF; +} + +// The unit has no sleep mode while it only fans or picks the mode itself +bool AirtonClimate::sleep_mode_allowed_() const { + return this->mode != climate::CLIMATE_MODE_FAN_ONLY && this->mode != climate::CLIMATE_MODE_HEAT_COOL; +} + +// Each setter stores and saves before it publishes: an automation on the entity runs inside +// publish_state() and may set the value again, which must not be undone afterwards +bool AirtonClimate::set_sleep_mode_state(bool state) { + state = state && this->sleep_mode_allowed_(); + const bool changed = state != this->settings_.sleep_state; + if (changed) { + this->settings_.sleep_state = state; + this->save_settings_(); + } +#ifdef USE_SWITCH + if (this->sleep_mode_switch_ != nullptr) + this->sleep_mode_switch_->publish_state(state); +#endif + return changed; +} + +bool AirtonClimate::set_display_state(bool state) { + const bool changed = state != this->settings_.display_state; + if (changed) { + this->settings_.display_state = state; + this->save_settings_(); + } +#ifdef USE_SWITCH + if (this->display_switch_ != nullptr) + this->display_switch_->publish_state(state); +#endif + return changed; +} + +bool AirtonClimate::set_vertical_direction_state(VerticalDirection direction) { + const bool changed = direction != this->settings_.vertical_direction; + if (changed) { + this->settings_.vertical_direction = direction; + this->sync_swing_mode_(); + this->save_settings_(); + } +#ifdef USE_SELECT + if (this->vertical_direction_select_ != nullptr) + this->vertical_direction_select_->publish_state(static_cast(direction)); +#endif + return changed; +} + +void AirtonClimate::send_settings() { + this->transmit_state(); + this->publish_state(); +} + +void AirtonClimate::control(const climate::ClimateCall &call) { + // ClimateIR::control() sends and publishes the whole state afterwards + // Only a change of swing mode moves the flap, so a repeated swing-off keeps a fixed position + if (auto swing_mode = call.get_swing_mode(); swing_mode.has_value() && *swing_mode != this->swing_mode) { + this->set_vertical_direction_state(*swing_mode == climate::CLIMATE_SWING_VERTICAL + ? VerticalDirection::VERTICAL_DIRECTION_SWING + : VerticalDirection::VERTICAL_DIRECTION_OFF); + } + ClimateIR::control(call); +} + +void AirtonClimate::transmit_state() { + // Entering fan-only or automatic mode drops sleep mode, as the remote does + if (!this->sleep_mode_allowed_()) + this->set_sleep_mode_state(false); + // Sampled valid state + // Power: On, Mode: 2 (Dry), Fan: 1 (Quiet), Temp: 20C, Swing(V): On, Econo: Off, Turbo: Off, Light: On, Health: On, + // Sleep: Off. 0x74C461041A11D3 + // The turbo bit above the fan speed stays clear, as the remote sends it + uint8_t remote_state[AIRTON_STATE_FRAME_SIZE] = { + AIRTON_HEADER_BYTE0, + AIRTON_HEADER_BYTE1, + static_cast(this->operation_mode_() | (this->fan_speed_() << 4)), + this->temperature_(), + static_cast(this->settings_.vertical_direction), + this->operation_settings_(), + 0, + }; + remote_state[6] = checksum(remote_state); + + ESP_LOGV(TAG, "Sending: %02X %02X %02X %02X %02X %02X %02X", remote_state[6], remote_state[5], remote_state[4], + remote_state[3], remote_state[2], remote_state[1], remote_state[0]); + + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + data->set_carrier_frequency(AIRTON_IR_FREQUENCY); + data->mark(AIRTON_HEADER_MARK); + data->space(AIRTON_HEADER_SPACE); + for (uint8_t payload_byte : remote_state) { + for (uint8_t payload_bit_cursor = 0; payload_bit_cursor < 8; payload_bit_cursor++) { + data->mark(AIRTON_BIT_MARK); + bool bit = payload_byte & (1 << payload_bit_cursor); + data->space(bit ? AIRTON_ONE_SPACE : AIRTON_ZERO_SPACE); + } + } + + data->mark(AIRTON_BIT_MARK); + data->space(AIRTON_MESSAGE_SPACE); + + transmit.perform(); +} + +uint8_t AirtonClimate::operation_mode_() { + uint8_t operating_mode = 0b1000; // First bit is for power state + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + operating_mode |= AIRTON_MODE_COOL; + break; + case climate::CLIMATE_MODE_DRY: + operating_mode |= AIRTON_MODE_DRY; + break; + case climate::CLIMATE_MODE_HEAT: + operating_mode |= AIRTON_MODE_HEAT; + break; + case climate::CLIMATE_MODE_HEAT_COOL: + operating_mode |= AIRTON_MODE_AUTO; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + operating_mode |= AIRTON_MODE_FAN; + break; + case climate::CLIMATE_MODE_OFF: + default: + operating_mode = 0b0111 & this->previous_mode_; // Set previous mode with power state bit off + } + this->previous_mode_ = operating_mode; + return operating_mode; +} + +uint8_t AirtonClimate::fan_speed_() { + uint8_t fan_speed; + switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { + case climate::CLIMATE_FAN_LOW: + fan_speed = AIRTON_FAN_1; + break; + case climate::CLIMATE_FAN_MEDIUM: + fan_speed = AIRTON_FAN_3; + break; + case climate::CLIMATE_FAN_HIGH: + fan_speed = AIRTON_FAN_5; + break; + case climate::CLIMATE_FAN_AUTO: + default: + fan_speed = AIRTON_FAN_AUTO; + } + return fan_speed; +} + +uint8_t AirtonClimate::temperature_() { + switch (this->mode) { + case climate::CLIMATE_MODE_HEAT_COOL: + return AIRTON_AUTO_MODE_TEMP - AIRTON_TEMP_MIN; + default: + return static_cast(roundf(clamp(this->target_temperature, AIRTON_TEMP_MIN, AIRTON_TEMP_MAX))) - + AIRTON_TEMP_MIN; + } +} + +// The bits of this packet's byte have the following meanings (from MSB to LSB) +// Light, Health, Unknown, HeatOn, Unknown, NotAutoOn, Sleep, Econo +uint8_t AirtonClimate::operation_settings_() { + uint8_t settings = 0; + if (this->mode == climate::CLIMATE_MODE_HEAT) { // Set heating bit if on the corresponding mode + settings |= (1 << 4); + } + if (this->settings_.display_state) { + settings |= (1 << 7); + } + if (this->settings_.sleep_state) { + settings |= (1 << 1); + } + settings |= 0b01000000; // Health, as the remote always sends it + if (this->mode != climate::CLIMATE_MODE_HEAT_COOL) { + settings |= 0b00000100; // NotAutoOn: clear only while the unit picks the mode itself + } + return settings; +} + +void AirtonClimate::parse_state_frame_(const uint8_t *frame) { + uint8_t mode = frame[2]; + // A later "off" repeats the mode the remote last sent, as the remote itself does + this->previous_mode_ = mode & 0b00001111; + if (mode & 0b00001000) { // The power bit + switch (mode & 0b00000111) { + case AIRTON_MODE_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + case AIRTON_MODE_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + case AIRTON_MODE_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + case AIRTON_MODE_AUTO: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + break; + case AIRTON_MODE_FAN: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + } + } else { + this->mode = climate::CLIMATE_MODE_OFF; + } + uint8_t fan_mode = (frame[2] & 0b01110000) >> 4; + + switch (fan_mode) { + case AIRTON_FAN_1: + case AIRTON_FAN_2: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + case AIRTON_FAN_3: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case AIRTON_FAN_4: + case AIRTON_FAN_5: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + case AIRTON_FAN_AUTO: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + } + + this->target_temperature = (frame[3] & 0b00001111) + AIRTON_TEMP_MIN; + + const uint8_t direction = frame[4] & 0b00001111; + if (direction <= static_cast(VerticalDirection::VERTICAL_DIRECTION_DOWN)) { + this->set_vertical_direction_state(static_cast(direction)); + } else { + ESP_LOGV(TAG, "Unknown vertical direction %u", direction); + } + this->set_display_state((frame[5] & 0b10000000) != 0); + this->set_sleep_mode_state((frame[5] & 0b00000010) != 0); + + this->publish_state(); +} + +bool AirtonClimate::on_receive(remote_base::RemoteReceiveData data) { + uint8_t remote_state[AIRTON_STATE_FRAME_SIZE] = {}; + if (!data.expect_item(AIRTON_HEADER_MARK, AIRTON_HEADER_SPACE)) { + ESP_LOGV(TAG, "Wrong header encoding detected!"); + return false; + } + for (int i = 0; i < AIRTON_STATE_FRAME_SIZE; i++) { + for (int j = 0; j < 8; j++) { + if (data.expect_item(AIRTON_BIT_MARK, AIRTON_ONE_SPACE)) { + remote_state[i] |= 1 << j; + } else if (!data.expect_item(AIRTON_BIT_MARK, AIRTON_ZERO_SPACE)) { + ESP_LOGV(TAG, "Wrong modulation encoding for: Byte %d, bit %d", i, j); + return false; + } + } + } + + if (remote_state[0] != AIRTON_HEADER_BYTE0 || remote_state[1] != AIRTON_HEADER_BYTE1) { + ESP_LOGV(TAG, "Wrong header contents: %02X %02X", remote_state[1], remote_state[0]); + return false; + } + const uint8_t expected = checksum(remote_state); + if (remote_state[AIRTON_STATE_FRAME_SIZE - 1] != expected) { + ESP_LOGV(TAG, "Checksum error: calculated %02X, received %02X", expected, + remote_state[AIRTON_STATE_FRAME_SIZE - 1]); + return false; + } + ESP_LOGV(TAG, "Received: %02X %02X %02X %02X %02X %02X %02X", remote_state[6], remote_state[5], remote_state[4], + remote_state[3], remote_state[2], remote_state[1], remote_state[0]); + this->parse_state_frame_(remote_state); + return true; +} + +} // namespace esphome::airton diff --git a/esphome/components/airton/airton.h b/esphome/components/airton/airton.h new file mode 100644 index 00000000000..46762f421a8 --- /dev/null +++ b/esphome/components/airton/airton.h @@ -0,0 +1,137 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/core/defines.h" +#include "esphome/core/preferences.h" + +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif + +namespace esphome::airton { + +// Values for Airton SMVH09B-2A2A3NH IR Controllers +static constexpr uint8_t AIRTON_TEMP_MIN = 16; // Celsius +static constexpr uint8_t AIRTON_TEMP_MAX = 31; // Celsius +static constexpr uint8_t AIRTON_AUTO_MODE_TEMP = 25; // The fixed setpoint the unit uses in auto mode + +static constexpr uint8_t AIRTON_MODE_AUTO = 0b000; +static constexpr uint8_t AIRTON_MODE_COOL = 0b001; +static constexpr uint8_t AIRTON_MODE_HEAT = 0b100; +static constexpr uint8_t AIRTON_MODE_DRY = 0b010; +static constexpr uint8_t AIRTON_MODE_FAN = 0b011; + +static constexpr uint8_t AIRTON_FAN_AUTO = 0b000; +static constexpr uint8_t AIRTON_FAN_1 = 0b001; +static constexpr uint8_t AIRTON_FAN_2 = 0b010; +static constexpr uint8_t AIRTON_FAN_3 = 0b011; +static constexpr uint8_t AIRTON_FAN_4 = 0b100; +static constexpr uint8_t AIRTON_FAN_5 = 0b101; + +static constexpr uint32_t AIRTON_IR_FREQUENCY = 38000; +static constexpr uint32_t AIRTON_HEADER_MARK = 6630; +static constexpr uint32_t AIRTON_HEADER_SPACE = 3350; +static constexpr uint32_t AIRTON_BIT_MARK = 400; +static constexpr uint32_t AIRTON_ONE_SPACE = 1260; +static constexpr uint32_t AIRTON_ZERO_SPACE = 430; +static constexpr uint32_t AIRTON_MESSAGE_SPACE = 100000; + +static constexpr uint8_t AIRTON_STATE_FRAME_SIZE = 7; +// Every frame starts with these two bytes +static constexpr uint8_t AIRTON_HEADER_BYTE0 = 0xD3; +static constexpr uint8_t AIRTON_HEADER_BYTE1 = 0x11; + +// Position of the air outlet flap; the values are the nibble the frame carries and the index +// of the option in the vertical direction select +enum class VerticalDirection : uint8_t { + VERTICAL_DIRECTION_OFF = 0, + VERTICAL_DIRECTION_SWING = 1, + VERTICAL_DIRECTION_UP = 2, + VERTICAL_DIRECTION_MIDDLE_UP = 3, + VERTICAL_DIRECTION_MIDDLE = 4, + VERTICAL_DIRECTION_MIDDLE_DOWN = 5, + VERTICAL_DIRECTION_DOWN = 6, +}; + +// The on/off settings the switch platform exposes +enum class AirtonSetting : uint8_t { + AIRTON_SETTING_SLEEP_MODE, + AIRTON_SETTING_DISPLAY, +}; + +// The settings the unit keeps that the climate state does not carry; saved to flash when they change +struct AirtonSettings { + bool sleep_state; + bool display_state; + VerticalDirection vertical_direction; +}; + +class AirtonClimate : public climate_ir::ClimateIR { + public: + AirtonClimate() + : climate_ir::ClimateIR(AIRTON_TEMP_MIN, AIRTON_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + void setup() override; + void control(const climate::ClimateCall &call) override; + + // The direction the settings start from until a saved one is restored in setup() + void set_default_vertical_direction(VerticalDirection direction) { this->settings_.vertical_direction = direction; } + + // The setters update the state, publish their entity and save; they return whether anything changed. + // A change made here (switch, select, action) is then sent with send_settings(); a frame from the + // remote only reports what the unit already did. + bool set_sleep_mode_state(bool state); + bool set_display_state(bool state); + bool set_vertical_direction_state(VerticalDirection direction); + void send_settings(); + void set_setting(AirtonSetting setting, bool state) { + const bool changed = setting == AirtonSetting::AIRTON_SETTING_DISPLAY ? this->set_display_state(state) + : this->set_sleep_mode_state(state); + if (changed) + this->send_settings(); + } + +#ifdef USE_SWITCH + void set_sleep_mode_switch(switch_::Switch *sw) { this->sleep_mode_switch_ = sw; } + void set_display_switch(switch_::Switch *sw) { this->display_switch_ = sw; } +#endif +#ifdef USE_SELECT + void set_vertical_direction_select(select::Select *sel) { this->vertical_direction_select_ = sel; } +#endif + + protected: + void publish_settings_(); + void save_settings_(); + void sync_swing_mode_(); + bool sleep_mode_allowed_() const; + + void transmit_state() override; + uint8_t operation_mode_(); + uint8_t fan_speed_(); + uint8_t temperature_(); + uint8_t operation_settings_(); + + bool on_receive(remote_base::RemoteReceiveData data) override; + void parse_state_frame_(const uint8_t *frame); + +#ifdef USE_SWITCH + switch_::Switch *sleep_mode_switch_{nullptr}; + switch_::Switch *display_switch_{nullptr}; +#endif +#ifdef USE_SELECT + select::Select *vertical_direction_select_{nullptr}; +#endif + ESPPreferenceObject settings_pref_; + // The unit's own defaults: display lit, no sleep mode, flap still + AirtonSettings settings_{false, true, VerticalDirection::VERTICAL_DIRECTION_OFF}; + // The last mode sent, so turning off repeats it with the power bit cleared + uint8_t previous_mode_{0}; +}; + +} // namespace esphome::airton diff --git a/esphome/components/airton/climate.py b/esphome/components/airton/climate.py new file mode 100644 index 00000000000..fdfef45aaa4 --- /dev/null +++ b/esphome/components/airton/climate.py @@ -0,0 +1,70 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import climate_ir +import esphome.config_validation as cv +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@Procsiab"] + +airton_ns = cg.esphome_ns.namespace("airton") +AirtonClimate = airton_ns.class_("AirtonClimate", climate_ir.ClimateIR) + +CONF_AIRTON_ID = "airton_id" +CONF_SLEEP_MODE = "sleep_mode" +CONF_VERTICAL_DIRECTION = "vertical_direction" + +VerticalDirection = airton_ns.enum("VerticalDirection", is_class=True) +# In the order of the enum, so the option index is the direction value +VERTICAL_DIRECTIONS = { + "off": VerticalDirection.VERTICAL_DIRECTION_OFF, + "swing": VerticalDirection.VERTICAL_DIRECTION_SWING, + "up": VerticalDirection.VERTICAL_DIRECTION_UP, + "middle-up": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_UP, + "middle": VerticalDirection.VERTICAL_DIRECTION_MIDDLE, + "middle-down": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_DOWN, + "down": VerticalDirection.VERTICAL_DIRECTION_DOWN, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(AirtonClimate).extend( + { + cv.Optional(CONF_VERTICAL_DIRECTION, default="off"): cv.enum( + VERTICAL_DIRECTIONS + ), + } +) + +AIRTON_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(AirtonClimate), + } +) + +for _name, _call in ( + ( + "climate.airton.display_on", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_DISPLAY, true)", + ), + ( + "climate.airton.display_off", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_DISPLAY, false)", + ), + ( + "climate.airton.sleep_on", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_SLEEP_MODE, true)", + ), + ( + "climate.airton.sleep_off", + "set_setting(esphome::airton::AirtonSetting::AIRTON_SETTING_SLEEP_MODE, false)", + ), +): + automation.register_apply_action( + _name, AIRTON_ACTION_SCHEMA, automation.ApplyCall(_call) + ) + + +async def to_code(config: ConfigType) -> None: + var = await climate_ir.new_climate_ir(config) + # The default until a saved setting is restored in setup(); off is the member's initial value + if (direction := config[CONF_VERTICAL_DIRECTION]) != "off": + cg.add(var.set_default_vertical_direction(direction)) diff --git a/esphome/components/airton/select/__init__.py b/esphome/components/airton/select/__init__.py new file mode 100644 index 00000000000..1772701611a --- /dev/null +++ b/esphome/components/airton/select/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import ( + CONF_AIRTON_ID, + CONF_VERTICAL_DIRECTION, + VERTICAL_DIRECTIONS, + AirtonClimate, + airton_ns, +) + +CODEOWNERS = ["@Procsiab"] + +VerticalDirectionSelect = airton_ns.class_("VerticalDirectionSelect", select.Select) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AIRTON_ID): cv.use_id(AirtonClimate), + cv.Optional(CONF_VERTICAL_DIRECTION): select.select_schema( + VerticalDirectionSelect, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_AIRTON_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_VERTICAL_DIRECTION, + hub.set_vertical_direction_select, + options=list(VERTICAL_DIRECTIONS), + ) diff --git a/esphome/components/airton/select/direction.h b/esphome/components/airton/select/direction.h new file mode 100644 index 00000000000..4697385c161 --- /dev/null +++ b/esphome/components/airton/select/direction.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../airton.h" + +namespace esphome::airton { + +class VerticalDirectionSelect : public select::Select, public Parented { + protected: + // The options are in the order of the enum, so the index is the direction; the parent publishes the state + void control(size_t index) override { + if (this->parent_->set_vertical_direction_state(static_cast(index))) + this->parent_->send_settings(); + } +}; + +} // namespace esphome::airton diff --git a/esphome/components/airton/switch/__init__.py b/esphome/components/airton/switch/__init__.py new file mode 100644 index 00000000000..94cbb5c8c4a --- /dev/null +++ b/esphome/components/airton/switch/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import CONF_DISPLAY, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import CONF_AIRTON_ID, CONF_SLEEP_MODE, AirtonClimate, airton_ns + +CODEOWNERS = ["@Procsiab"] + +AirtonSwitch = airton_ns.class_("AirtonSwitch", switch.Switch) +AirtonSetting = airton_ns.enum("AirtonSetting", is_class=True) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AIRTON_ID): cv.use_id(AirtonClimate), + cv.Optional(CONF_DISPLAY): switch.switch_schema( + AirtonSwitch, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="DISABLED", + ), + cv.Optional(CONF_SLEEP_MODE): switch.switch_schema( + AirtonSwitch, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="DISABLED", + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_AIRTON_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches( + CONF_DISPLAY, hub.set_display_switch, AirtonSetting.AIRTON_SETTING_DISPLAY + ) + await switches( + CONF_SLEEP_MODE, + hub.set_sleep_mode_switch, + AirtonSetting.AIRTON_SETTING_SLEEP_MODE, + ) diff --git a/esphome/components/airton/switch/airton_switch.h b/esphome/components/airton/switch/airton_switch.h new file mode 100644 index 00000000000..d887bacad64 --- /dev/null +++ b/esphome/components/airton/switch/airton_switch.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../airton.h" + +namespace esphome::airton { + +// One of the unit's on/off settings; the parent sends the state and publishes it back +class AirtonSwitch : public switch_::Switch, public Parented { + public: + explicit AirtonSwitch(AirtonSetting setting) : setting_(setting) {} + + protected: + void write_state(bool state) override { this->parent_->set_setting(this->setting_, state); } + + AirtonSetting setting_; +}; + +} // namespace esphome::airton diff --git a/tests/components/airton/common-bare.yaml b/tests/components/airton/common-bare.yaml new file mode 100644 index 00000000000..e2529bbf155 --- /dev/null +++ b/tests/components/airton/common-bare.yaml @@ -0,0 +1,13 @@ +# The climate with USE_SWITCH defined by another platform and USE_SELECT not defined at all; the ESP8266 build +# covers this path and the ESP32 build the full one +climate: + - platform: airton + id: airton_bare + name: Airton Climate + transmitter_id: xmitr + receiver_id: rcvr + +switch: + - platform: template + name: Unrelated Switch + optimistic: true diff --git a/tests/components/airton/common.yaml b/tests/components/airton/common.yaml new file mode 100644 index 00000000000..b5b053f3734 --- /dev/null +++ b/tests/components/airton/common.yaml @@ -0,0 +1,34 @@ +esphome: + on_boot: + then: + - climate.airton.display_on: airton_climate + - climate.airton.display_off: + id: airton_climate + - climate.airton.sleep_on: airton_climate + - climate.airton.sleep_off: airton_climate + +sensor: + - platform: template + id: airton_sensor + lambda: "return 21;" + +climate: + - platform: airton + id: airton_climate + name: Airton Climate + transmitter_id: xmitr + receiver_id: rcvr + sensor: airton_sensor + vertical_direction: middle + +switch: + - platform: airton + sleep_mode: + name: Sleep Mode + display: + name: Display Light + +select: + - platform: airton + vertical_direction: + name: Vertical Direction diff --git a/tests/components/airton/test.esp32-idf.yaml b/tests/components/airton/test.esp32-idf.yaml new file mode 100644 index 00000000000..9e2cd6bed7e --- /dev/null +++ b/tests/components/airton/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + airton: !include common.yaml diff --git a/tests/components/airton/test.esp8266-ard.yaml b/tests/components/airton/test.esp8266-ard.yaml new file mode 100644 index 00000000000..3b62daa505f --- /dev/null +++ b/tests/components/airton/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + airton: !include common-bare.yaml From ee117886b90829b58ab6e203a78c7da1fd300341 Mon Sep 17 00:00:00 2001 From: Bofr5555 <65888788+Bofr5555@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:42:55 +0200 Subject: [PATCH 086/179] [vevor_dtx] Add Vevor weather station component (#17224) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/vevor_dtx/__init__.py | 48 ++++ esphome/components/vevor_dtx/binary_sensor.py | 26 +++ esphome/components/vevor_dtx/sensor.py | 107 +++++++++ esphome/components/vevor_dtx/text_sensor.py | 24 ++ esphome/components/vevor_dtx/vevor_dtx.cpp | 159 +++++++++++++ esphome/components/vevor_dtx/vevor_dtx.h | 63 ++++++ tests/components/vevor_dtx/__init__.py | 16 ++ tests/components/vevor_dtx/common.yaml | 39 ++++ .../components/vevor_dtx/test.esp32-idf.yaml | 3 + .../vevor_dtx/test.esp8266-ard.yaml | 3 + .../components/vevor_dtx/test.rp2040-ard.yaml | 3 + .../validate-two-hubs.esp32-idf.yaml | 21 ++ tests/components/vevor_dtx/vevor_dtx_test.cpp | 208 ++++++++++++++++++ .../common/remote_receiver/rp2040-ard.yaml | 12 + 15 files changed, 733 insertions(+) create mode 100644 esphome/components/vevor_dtx/__init__.py create mode 100644 esphome/components/vevor_dtx/binary_sensor.py create mode 100644 esphome/components/vevor_dtx/sensor.py create mode 100644 esphome/components/vevor_dtx/text_sensor.py create mode 100644 esphome/components/vevor_dtx/vevor_dtx.cpp create mode 100644 esphome/components/vevor_dtx/vevor_dtx.h create mode 100644 tests/components/vevor_dtx/__init__.py create mode 100644 tests/components/vevor_dtx/common.yaml create mode 100644 tests/components/vevor_dtx/test.esp32-idf.yaml create mode 100644 tests/components/vevor_dtx/test.esp8266-ard.yaml create mode 100644 tests/components/vevor_dtx/test.rp2040-ard.yaml create mode 100644 tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml create mode 100644 tests/components/vevor_dtx/vevor_dtx_test.cpp create mode 100644 tests/test_build_components/common/remote_receiver/rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 19f1194bd95..1f1375ba968 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -691,6 +691,7 @@ esphome/components/vbus/* @ssieb esphome/components/veml3235/* @kbx81 esphome/components/veml7700/* @latonita esphome/components/version/* @esphome/core +esphome/components/vevor_dtx/* @Bofr5555 esphome/components/voice_assistant/* @jesserockz @kahrendt esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher diff --git a/esphome/components/vevor_dtx/__init__.py b/esphome/components/vevor_dtx/__init__.py new file mode 100644 index 00000000000..03522c6f1d1 --- /dev/null +++ b/esphome/components/vevor_dtx/__init__.py @@ -0,0 +1,48 @@ +import esphome.codegen as cg +from esphome.components import remote_base +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import TimePeriod +from esphome.types import ConfigType + +AUTO_LOAD = ["remote_base"] +CODEOWNERS = ["@Bofr5555"] +DEPENDENCIES = ["remote_receiver"] +DOMAIN = "vevor_dtx" +MULTI_CONF = True + +CONF_BIT_TIME = "bit_time" +CONF_STATION_ID = "station_id" +CONF_VEVOR_DTX_ID = "vevor_dtx_id" + +vevor_dtx_ns = cg.esphome_ns.namespace("vevor_dtx") +VevorDtxComponent = vevor_dtx_ns.class_( + "VevorDtxComponent", cg.Component, remote_base.RemoteReceiverListener +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(VevorDtxComponent), + cv.Optional(CONF_BIT_TIME, default="88us"): cv.All( + cv.positive_time_period_microseconds, + cv.Range( + min=TimePeriod(microseconds=50), + max=TimePeriod(microseconds=150), + ), + ), + cv.Optional(CONF_STATION_ID): cv.hex_uint16_t, + } + ) + .extend(remote_base.REMOTE_LISTENER_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await remote_base.register_listener(var, config) + cg.add(var.set_bit_time_us(config[CONF_BIT_TIME])) + if (station_id := config.get(CONF_STATION_ID)) is not None: + cg.add(var.set_station_id(station_id)) diff --git a/esphome/components/vevor_dtx/binary_sensor.py b/esphome/components/vevor_dtx/binary_sensor.py new file mode 100644 index 00000000000..02ed7f291cc --- /dev/null +++ b/esphome/components/vevor_dtx/binary_sensor.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_BATTERY +from esphome.types import ConfigType + +from . import CONF_VEVOR_DTX_ID, VevorDtxComponent + +DEPENDENCIES = ["vevor_dtx"] + +CONF_LOW_BATTERY = "low_battery" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_VEVOR_DTX_ID): cv.use_id(VevorDtxComponent), + cv.Optional(CONF_LOW_BATTERY): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_BATTERY + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_VEVOR_DTX_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_LOW_BATTERY, hub.set_low_battery_binary_sensor) diff --git a/esphome/components/vevor_dtx/sensor.py b/esphome/components/vevor_dtx/sensor.py new file mode 100644 index 00000000000..2a44912222b --- /dev/null +++ b/esphome/components/vevor_dtx/sensor.py @@ -0,0 +1,107 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_HUMIDITY, + CONF_ILLUMINANCE, + CONF_TEMPERATURE, + CONF_WIND_DIRECTION_DEGREES, + CONF_WIND_SPEED, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_WIND_DIRECTION, + DEVICE_CLASS_WIND_SPEED, + ICON_COUNTER, + ICON_GRAIN, + ICON_SIGN_DIRECTION, + ICON_WEATHER_WINDY, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_CELSIUS, + UNIT_DEGREES, + UNIT_LUX, + UNIT_METER_PER_SECOND, + UNIT_MILLIMETER, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from . import CONF_VEVOR_DTX_ID, VevorDtxComponent + +DEPENDENCIES = ["vevor_dtx"] + +CONF_RAIN = "rain" +CONF_TX_COUNTER = "tx_counter" +CONF_UV_INDEX = "uv_index" +CONF_WIND_GUST = "wind_gust" + +_WIND_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_METER_PER_SECOND, + icon=ICON_WEATHER_WINDY, + accuracy_decimals=1, + device_class=DEVICE_CLASS_WIND_SPEED, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_VEVOR_DTX_ID): cv.use_id(VevorDtxComponent), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_WIND_SPEED): _WIND_SCHEMA, + cv.Optional(CONF_WIND_GUST): _WIND_SCHEMA, + cv.Optional(CONF_WIND_DIRECTION_DEGREES): sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREES, + icon=ICON_SIGN_DIRECTION, + accuracy_decimals=0, + device_class=DEVICE_CLASS_WIND_DIRECTION, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RAIN): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIMETER, + icon=ICON_GRAIN, + accuracy_decimals=1, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_UV_INDEX): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TX_COUNTER): sensor.sensor_schema( + accuracy_decimals=0, + icon=ICON_COUNTER, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_VEVOR_DTX_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_HUMIDITY, hub.set_humidity_sensor) + await sensors(CONF_WIND_SPEED, hub.set_wind_speed_sensor) + await sensors(CONF_WIND_GUST, hub.set_wind_gust_sensor) + await sensors(CONF_WIND_DIRECTION_DEGREES, hub.set_wind_direction_sensor) + await sensors(CONF_RAIN, hub.set_rain_sensor) + await sensors(CONF_UV_INDEX, hub.set_uv_index_sensor) + await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor) + await sensors(CONF_TX_COUNTER, hub.set_tx_counter_sensor) diff --git a/esphome/components/vevor_dtx/text_sensor.py b/esphome/components/vevor_dtx/text_sensor.py new file mode 100644 index 00000000000..38810b8d4db --- /dev/null +++ b/esphome/components/vevor_dtx/text_sensor.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_SENSOR_ID +from esphome.types import ConfigType + +from . import CONF_VEVOR_DTX_ID, VevorDtxComponent + +DEPENDENCIES = ["vevor_dtx"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_VEVOR_DTX_ID): cv.use_id(VevorDtxComponent), + cv.Optional(CONF_SENSOR_ID): text_sensor.text_sensor_schema( + icon="mdi:identifier", + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_VEVOR_DTX_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_SENSOR_ID, hub.set_sensor_id_text_sensor) diff --git a/esphome/components/vevor_dtx/vevor_dtx.cpp b/esphome/components/vevor_dtx/vevor_dtx.cpp new file mode 100644 index 00000000000..b42c3803fe5 --- /dev/null +++ b/esphome/components/vevor_dtx/vevor_dtx.cpp @@ -0,0 +1,159 @@ +#include "vevor_dtx.h" + +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::vevor_dtx { + +ESPHOME_LOG_TAG(TAG, "vevor_dtx"); + +// The five sync bytes and the two constant header bytes that start every frame, matched bit by bit +static constexpr uint64_t SYNC_AND_HEADER = 0xAAAACACA54AA00ULL; +static constexpr uint64_t SYNC_AND_HEADER_MASK = (1ULL << 56) - 1; +static constexpr uint8_t HEADER_SIZE = 2; +// Wind arrives in 1/8.333 km/h steps and gust in 1/1.25 km/h (0.8 km/h) steps; both are published in m/s +static constexpr float WIND_SPEED_MS_PER_RAW = 1.0f / (8.333f * 3.6f); +static constexpr float WIND_GUST_MS_PER_RAW = 1.0f / (1.25f * 3.6f); +static constexpr float RAIN_MM_PER_RAW = 0.233f; + +// Wind, direction, rain and light arrive with an offset of one on each byte; 0x00 wraps to 0xFF +static uint16_t offset_word(uint8_t high, uint8_t low) { + return encode_uint16(static_cast(high - 1), static_cast(low - 1)); +} + +void VevorDtxComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Vevor DTX:\n" + " Bit time: %u us", + this->bit_time_us_); + if (this->station_id_ != 0) { + ESP_LOGCONFIG(TAG, " Station ID: 0x%04X", this->station_id_); + } +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "Wind speed", this->wind_speed_sensor_); + LOG_SENSOR(" ", "Wind gust", this->wind_gust_sensor_); + LOG_SENSOR(" ", "Wind direction", this->wind_direction_sensor_); + LOG_SENSOR(" ", "Rain", this->rain_sensor_); + LOG_SENSOR(" ", "UV index", this->uv_index_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "TX counter", this->tx_counter_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Sensor ID", this->sensor_id_text_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Low battery", this->low_battery_binary_sensor_); +#endif +} + +bool VevorDtxComponent::on_receive(remote_base::RemoteReceiveData data) { + Payload payload{}; + if (!this->decode_timings_(data.get_raw_data(), payload)) { + ESP_LOGVV(TAG, "No Vevor DTX frame in %" PRId32 " timings", data.size()); + return false; + } + return this->publish_(payload); +} + +// The frame is NRZ at a fixed bit time: a high run of n bit times is n one bits. The bits are shifted +// through a register until the sync word appears, then the payload is collected and its checksum tested. +// A run is never treated as a gap, since a payload may hold long runs of zeros; a gap simply shifts zeros +// through the register, and one longer than a frame is cut to a frame's worth, which has the same effect. +bool VevorDtxComponent::decode_timings_(const remote_base::RawTimings &timings, Payload &payload) const { + uint64_t stream = 0; + uint16_t collected = 0; // payload bits gathered since the sync word, 0 while still searching for it + + for (const int32_t timing : timings) { + const uint32_t duration = std::abs(timing); + const uint32_t cells = + std::clamp((duration + this->bit_time_us_ / 2) / this->bit_time_us_, 1, FRAME_BITS); + const uint64_t bit = timing > 0 ? 1 : 0; + for (uint32_t i = 0; i < cells; i++) { + stream = (stream << 1) | bit; + if (collected == 0) { + if ((stream & SYNC_AND_HEADER_MASK) != SYNC_AND_HEADER) { + continue; + } + payload[0] = uint8_t(SYNC_AND_HEADER >> 8); + payload[1] = uint8_t(SYNC_AND_HEADER); + collected = HEADER_SIZE * 8; + continue; + } + payload[collected / 8] = uint8_t((payload[collected / 8] << 1) | bit); + if (++collected < FRAME_BITS) { + continue; + } + // The byte after the checksum repeats the counter plus one; it rejects frames whose sum matches by chance + if (sum_bytes(payload.data(), CHECKSUM_INDEX) == payload[CHECKSUM_INDEX] && + payload[TX_COUNTER_INDEX + 2] == uint8_t(payload[TX_COUNTER_INDEX] + 1)) { + return true; + } + ESP_LOGV(TAG, "Rejected frame: sum 0x%02X received 0x%02X, counter %u followed by %u", + sum_bytes(payload.data(), CHECKSUM_INDEX), payload[CHECKSUM_INDEX], payload[TX_COUNTER_INDEX], + payload[TX_COUNTER_INDEX + 2]); + collected = 0; + } + } + return false; +} + +bool VevorDtxComponent::publish_(const Payload &payload) { + const uint16_t sensor_id = encode_uint16(payload[2], payload[3]); + if (this->station_id_ != 0 && sensor_id != this->station_id_) { + ESP_LOGV(TAG, "Ignoring station 0x%04X", sensor_id); + return false; + } + const float temperature = (float(encode_uint16(payload[5], payload[6])) - 500.0f) / 10.0f; + const uint8_t humidity = payload[7]; + const float wind_speed = float(offset_word(payload[8], payload[9])) * WIND_SPEED_MS_PER_RAW; + const float wind_gust = float(payload[10]) * WIND_GUST_MS_PER_RAW; + const float wind_direction = float(offset_word(payload[11], payload[12]) & 0x0FFF); + const float rain = float(offset_word(payload[13], payload[14])) * RAIN_MM_PER_RAW; + const int8_t uv_index = int8_t(payload[15] & 0x1F) - 1; + // The top bit of the light word selects a tenfold range + const uint16_t lux_raw = offset_word(payload[16], payload[17]); + const float illuminance = (lux_raw & 0x8000) != 0 ? float(lux_raw & 0x7FFF) * 10.0f : float(lux_raw); + const uint8_t tx_counter = payload[18]; + + ESP_LOGD(TAG, + "Vevor DTX id=0x%04X status=0x%02X temp=%.1fC hum=%u%% wind=%.1fm/s gust=%.1fm/s dir=%.0f " + "rain=%.1f uv=%d raw=0x%02X lux=%.0f raw=0x%04X tx=%u", + sensor_id, payload[4], temperature, humidity, wind_speed, wind_gust, wind_direction, rain, uv_index, + payload[15], illuminance, lux_raw, tx_counter); + +#ifdef USE_SENSOR + const auto publish = [](sensor::Sensor *sensor, float value) { + if (sensor != nullptr) + sensor->publish_state(value); + }; + publish(this->temperature_sensor_, temperature); + publish(this->humidity_sensor_, humidity); + publish(this->wind_speed_sensor_, wind_speed); + publish(this->wind_gust_sensor_, wind_gust); + publish(this->wind_direction_sensor_, wind_direction); + publish(this->rain_sensor_, rain); + publish(this->uv_index_sensor_, uv_index); + publish(this->illuminance_sensor_, illuminance); + publish(this->tx_counter_sensor_, tx_counter); +#endif +#ifdef USE_TEXT_SENSOR + if (this->sensor_id_text_sensor_ != nullptr) { + char text[7]; + snprintf(text, sizeof(text), "0x%04X", sensor_id); + this->sensor_id_text_sensor_->publish_state(text); + } +#endif +#ifdef USE_BINARY_SENSOR + if (this->low_battery_binary_sensor_ != nullptr) + this->low_battery_binary_sensor_->publish_state((payload[4] & 0x80) != 0); +#endif + return true; +} + +} // namespace esphome::vevor_dtx diff --git a/esphome/components/vevor_dtx/vevor_dtx.h b/esphome/components/vevor_dtx/vevor_dtx.h new file mode 100644 index 00000000000..56eea99415d --- /dev/null +++ b/esphome/components/vevor_dtx/vevor_dtx.h @@ -0,0 +1,63 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/remote_base/remote_base.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif + +#include + +namespace esphome::vevor_dtx { + +class VevorDtxComponent : public Component, public remote_base::RemoteReceiverListener { +#ifdef USE_SENSOR + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(wind_speed) + SUB_SENSOR(wind_gust) + SUB_SENSOR(wind_direction) + SUB_SENSOR(rain) + SUB_SENSOR(uv_index) + SUB_SENSOR(illuminance) + SUB_SENSOR(tx_counter) +#endif +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(sensor_id) +#endif +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(low_battery) +#endif + + public: + void dump_config() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + void set_bit_time_us(uint8_t bit_time_us) { this->bit_time_us_ = bit_time_us; } + void set_station_id(uint16_t station_id) { this->station_id_ = station_id; } + + protected: + static constexpr uint8_t PAYLOAD_SIZE = 21; + static constexpr uint8_t CHECKSUM_INDEX = 19; + static constexpr uint8_t TX_COUNTER_INDEX = 18; + // The frame ends with the counter echo; trailing zero bits that merge into the receiver's idle entry come + // back when that entry is cut to a frame's worth of zeros + static constexpr uint16_t FRAME_BITS = PAYLOAD_SIZE * 8; + + using Payload = std::array; + + bool decode_timings_(const remote_base::RawTimings &timings, Payload &payload) const; + bool publish_(const Payload &payload); + + uint16_t station_id_{0}; // 0 accepts every station in range + uint8_t bit_time_us_{88}; +}; + +} // namespace esphome::vevor_dtx diff --git a/tests/components/vevor_dtx/__init__.py b/tests/components/vevor_dtx/__init__.py new file mode 100644 index 00000000000..d2fd62c61b2 --- /dev/null +++ b/tests/components/vevor_dtx/__init__.py @@ -0,0 +1,16 @@ +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + # The gtests attach every entity kind the hub can publish to + for platform in ("sensor", "binary_sensor", "text_sensor"): + CORE.testing_ensure_platform_registered(platform) + + manifest.to_code = to_code_testing + # The frames come from the test, not from a receiver on a GPIO + manifest.dependencies = ["remote_base", "sensor", "binary_sensor", "text_sensor"] + # The host test config holds one plain hub entry; a multi-instance component would get an empty list + manifest.multi_conf = False diff --git a/tests/components/vevor_dtx/common.yaml b/tests/components/vevor_dtx/common.yaml new file mode 100644 index 00000000000..2d5de1e69d6 --- /dev/null +++ b/tests/components/vevor_dtx/common.yaml @@ -0,0 +1,39 @@ +vevor_dtx: + id: vevor_dtx_hub + receiver_id: rcvr + bit_time: 88us + station_id: 0x1234 + +sensor: + - platform: vevor_dtx + vevor_dtx_id: vevor_dtx_hub + temperature: + name: Outdoor Temperature + humidity: + name: Outdoor Humidity + wind_speed: + name: Wind Speed + wind_gust: + name: Wind Gust + wind_direction_degrees: + name: Wind Direction + rain: + name: Rain + uv_index: + name: UV Index + illuminance: + name: Illuminance + tx_counter: + name: TX Counter + +text_sensor: + - platform: vevor_dtx + vevor_dtx_id: vevor_dtx_hub + sensor_id: + name: Sensor ID + +binary_sensor: + - platform: vevor_dtx + vevor_dtx_id: vevor_dtx_hub + low_battery: + name: Low Battery diff --git a/tests/components/vevor_dtx/test.esp32-idf.yaml b/tests/components/vevor_dtx/test.esp32-idf.yaml new file mode 100644 index 00000000000..493b275f743 --- /dev/null +++ b/tests/components/vevor_dtx/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + vevor_dtx: !include common.yaml diff --git a/tests/components/vevor_dtx/test.esp8266-ard.yaml b/tests/components/vevor_dtx/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4ac20e92d06 --- /dev/null +++ b/tests/components/vevor_dtx/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + vevor_dtx: !include common.yaml diff --git a/tests/components/vevor_dtx/test.rp2040-ard.yaml b/tests/components/vevor_dtx/test.rp2040-ard.yaml new file mode 100644 index 00000000000..910202057f7 --- /dev/null +++ b/tests/components/vevor_dtx/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/rp2040-ard.yaml + vevor_dtx: !include common.yaml diff --git a/tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml b/tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml new file mode 100644 index 00000000000..43c3df671cd --- /dev/null +++ b/tests/components/vevor_dtx/validate-two-hubs.esp32-idf.yaml @@ -0,0 +1,21 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + +# Two stations on one receiver, each with its own hub +vevor_dtx: + - id: roof_station + receiver_id: rcvr + station_id: 0x1234 + - id: garden_station + receiver_id: rcvr + station_id: 0x5678 + +sensor: + - platform: vevor_dtx + vevor_dtx_id: roof_station + temperature: + name: Roof Temperature + - platform: vevor_dtx + vevor_dtx_id: garden_station + temperature: + name: Garden Temperature diff --git a/tests/components/vevor_dtx/vevor_dtx_test.cpp b/tests/components/vevor_dtx/vevor_dtx_test.cpp new file mode 100644 index 00000000000..33fb2d94778 --- /dev/null +++ b/tests/components/vevor_dtx/vevor_dtx_test.cpp @@ -0,0 +1,208 @@ +#include + +#include + +#include "esphome/components/vevor_dtx/vevor_dtx.h" +#include "esphome/core/helpers.h" + +namespace esphome::vevor_dtx::testing { + +static constexpr uint32_t BIT_TIME_US = 88; +static constexpr int32_t FRAME_GAP_US = -20000; + +class VevorDtxUnderTest : public VevorDtxComponent { + public: + using VevorDtxComponent::decode_timings_; + using VevorDtxComponent::Payload; +}; + +// Payload of one transmission: station 0x1234 with a low battery, 21.5 °C, 55 %, 9.96 km/h wind, +// 20 km/h gust, 180°, 23.3 mm of rain, UV index 5, 1234 lx, transmission counter `tx` +static VevorDtxUnderTest::Payload sample_payload(uint8_t tx = 7) { + VevorDtxUnderTest::Payload p{0xAA, 0x00, 0x12, 0x34, 0x80, 0x02, 0xCB, 55, 0x01, 0x54, 25, + 0x01, 0xB5, 0x01, 0x65, 6, 0x05, 0xD3, tx, 0, uint8_t(tx + 1)}; + p[19] = sum_bytes(p.data(), 19); + return p; +} + +// Run lengths of the NRZ bit stream for the sync bytes plus payload, framed by the inter-frame gap on both +// sides; `lead_bits` extra one bits in front shift the byte alignment the receiver sees +static remote_base::RawTimings timings(const VevorDtxUnderTest::Payload &payload, uint8_t lead_bits = 0) { + std::vector bits(lead_bits, true); + const auto push_byte = [&bits](uint8_t byte) { + for (int i = 7; i >= 0; i--) { + bits.push_back(((byte >> i) & 1) != 0); + } + }; + for (uint8_t byte : {0xAA, 0xAA, 0xCA, 0xCA, 0x54}) { + push_byte(byte); + } + for (uint8_t byte : payload) { + push_byte(byte); + } + remote_base::RawTimings out{FRAME_GAP_US}; + bool level = bits[0]; + uint32_t run = 0; + for (bool bit : bits) { + if (bit == level) { + run++; + continue; + } + out.push_back(level ? int32_t(run * BIT_TIME_US) : -int32_t(run * BIT_TIME_US)); + level = bit; + run = 1; + } + out.push_back(level ? int32_t(run * BIT_TIME_US) : -int32_t(run * BIT_TIME_US)); + out.push_back(FRAME_GAP_US); + return out; +} + +static remote_base::RemoteReceiveData receive_data(const remote_base::RawTimings &raw) { + return remote_base::RemoteReceiveData(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); +} + +TEST(VevorDtxTest, DecodesFrame) { + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload payload{}; + ASSERT_TRUE(hub.decode_timings_(timings(sample_payload()), payload)); + EXPECT_EQ(payload, sample_payload()); +} + +TEST(VevorDtxTest, DecodesFrameAtEveryBitOffset) { + VevorDtxUnderTest hub; + for (uint8_t lead = 1; lead < 8; lead++) { + VevorDtxUnderTest::Payload payload{}; + ASSERT_TRUE(hub.decode_timings_(timings(sample_payload(), lead), payload)) << "lead bits " << int(lead); + EXPECT_EQ(payload, sample_payload()); + } +} + +// A payload may hold a run of zeros longer than any inter-bit gap; it must not be taken for a frame gap +TEST(VevorDtxTest, DecodesAFrameWithALongZeroRun) { + auto payload = sample_payload(0); + payload[7] = 49; + payload[16] = 0x02; + payload[17] = 0x00; + payload[20] = 1; + payload[19] = sum_bytes(payload.data(), 19); + ASSERT_EQ(payload[19], 0); // 32 zero bits in a row across bytes 17 to 20 + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload decoded{}; + ASSERT_TRUE(hub.decode_timings_(timings(payload), decoded)); + EXPECT_EQ(decoded[7], 49); + EXPECT_EQ(decoded[19], 0); +} + +// The receiver ends its capture at idle, so the frame's trailing low bits merge into one long gap +TEST(VevorDtxTest, DecodesAFrameWhoseTailMergesIntoTheIdleGap) { + // Counter 15 is echoed as 0x10, so the frame's last four bits are low + const auto payload = sample_payload(15); + ASSERT_EQ(payload[20] & 0x0F, 0); + auto raw = timings(payload); + // Drop the trailing zero run and the gap, and end the capture in one idle entry instead + raw.pop_back(); + if (raw.back() < 0) + raw.pop_back(); + raw.push_back(FRAME_GAP_US); + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload decoded{}; + ASSERT_TRUE(hub.decode_timings_(raw, decoded)); + EXPECT_EQ(decoded[19], payload[19]); +} + +// The byte after the checksum must echo the counter plus one; the checksum does not cover it +TEST(VevorDtxTest, RejectsAWrongCounterEcho) { + auto payload = sample_payload(); + payload[20] = payload[18] + 2; + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload decoded{}; + EXPECT_FALSE(hub.decode_timings_(timings(payload), decoded)); +} + +TEST(VevorDtxTest, RejectsBadChecksum) { + VevorDtxUnderTest hub; + auto corrupted = sample_payload(); + corrupted[7] ^= 0x01; + VevorDtxUnderTest::Payload payload{}; + EXPECT_FALSE(hub.decode_timings_(timings(corrupted), payload)); +} + +TEST(VevorDtxTest, PublishesDecodedValues) { + VevorDtxUnderTest hub; + sensor::Sensor temperature, humidity, wind_speed, wind_gust, wind_direction, rain, uv_index, illuminance, tx_counter; + text_sensor::TextSensor sensor_id; + binary_sensor::BinarySensor low_battery; + hub.set_temperature_sensor(&temperature); + hub.set_humidity_sensor(&humidity); + hub.set_wind_speed_sensor(&wind_speed); + hub.set_wind_gust_sensor(&wind_gust); + hub.set_wind_direction_sensor(&wind_direction); + hub.set_rain_sensor(&rain); + hub.set_uv_index_sensor(&uv_index); + hub.set_illuminance_sensor(&illuminance); + hub.set_tx_counter_sensor(&tx_counter); + hub.set_sensor_id_text_sensor(&sensor_id); + hub.set_low_battery_binary_sensor(&low_battery); + + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload())))); + + EXPECT_NEAR(temperature.state, 21.5f, 0.01f); + EXPECT_NEAR(humidity.state, 55.0f, 0.01f); + EXPECT_NEAR(wind_speed.state, 9.96f / 3.6f, 0.01f); + EXPECT_NEAR(wind_gust.state, 20.0f / 3.6f, 0.01f); + EXPECT_NEAR(wind_direction.state, 180.0f, 0.01f); + EXPECT_NEAR(rain.state, 23.3f, 0.01f); + EXPECT_NEAR(uv_index.state, 5.0f, 0.01f); + EXPECT_NEAR(illuminance.state, 1234.0f, 0.01f); + EXPECT_NEAR(tx_counter.state, 7.0f, 0.01f); + EXPECT_EQ(sensor_id.state, "0x1234"); + EXPECT_TRUE(low_battery.state); +} + +// A byte that wrapped past 0xFF and a light word with the tenfold bit set +TEST(VevorDtxTest, UndoesTheOffsetPerByte) { + VevorDtxUnderTest hub; + sensor::Sensor rain, illuminance; + hub.set_rain_sensor(&rain); + hub.set_illuminance_sensor(&illuminance); + auto payload = sample_payload(); + payload[13] = 0x01; // rain 255: the low byte is 255 + 1 wrapped to 0 + payload[14] = 0x00; + payload[16] = 0x80; // light 0x7F00 = 32512 lx without the tenfold bit + payload[17] = 0x01; + payload[19] = sum_bytes(payload.data(), 19); + EXPECT_TRUE(hub.on_receive(receive_data(timings(payload)))); + EXPECT_NEAR(rain.state, 255 * 0.233f, 0.01f); + EXPECT_NEAR(illuminance.state, 32512.0f, 0.01f); +} + +TEST(VevorDtxTest, DecodesAFrameAfterNoise) { + VevorDtxUnderTest hub; + VevorDtxUnderTest::Payload payload{}; + // A noise burst, the gap, then the frame: the gap resets the search and the frame still decodes + remote_base::RawTimings raw{FRAME_GAP_US, 300, -500, 1000, -176}; + const auto frame = timings(sample_payload()); + raw.insert(raw.end(), frame.begin(), frame.end()); + ASSERT_TRUE(hub.decode_timings_(raw, payload)); + EXPECT_EQ(payload, sample_payload()); +} + +TEST(VevorDtxTest, IgnoresOtherStations) { + VevorDtxUnderTest hub; + hub.set_station_id(0x9999); + EXPECT_FALSE(hub.on_receive(receive_data(timings(sample_payload())))); + hub.set_station_id(0x1234); + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload())))); +} + +TEST(VevorDtxTest, TxCounterIsTheStationsByte) { + VevorDtxUnderTest hub; + sensor::Sensor tx_counter; + hub.set_tx_counter_sensor(&tx_counter); + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload(250))))); + EXPECT_NEAR(tx_counter.state, 250.0f, 0.01f); + EXPECT_TRUE(hub.on_receive(receive_data(timings(sample_payload(3))))); + EXPECT_NEAR(tx_counter.state, 3.0f, 0.01f); +} + +} // namespace esphome::vevor_dtx::testing diff --git a/tests/test_build_components/common/remote_receiver/rp2040-ard.yaml b/tests/test_build_components/common/remote_receiver/rp2040-ard.yaml new file mode 100644 index 00000000000..fe13e2dbc1b --- /dev/null +++ b/tests/test_build_components/common/remote_receiver/rp2040-ard.yaml @@ -0,0 +1,12 @@ +# Common remote_receiver configuration for RP2040 Arduino tests +# Provides a shared remote receiver that all components can use +# Components will auto-use this receiver if they don't specify receiver_id + +substitutions: + remote_receiver_pin: GPIO5 + +remote_receiver: + id: rcvr + pin: ${remote_receiver_pin} + dump: all + tolerance: 25% From 1307faef06d60941f65d4cbcfec46e5dbd9981ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:31:17 -1000 Subject: [PATCH 087/179] [core] Add Component::status_comm_result (#20863) --- esphome/core/component.cpp | 9 ++++++ esphome/core/component.h | 5 ++++ .../core/test_status_comm_result.cpp | 30 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 tests/components/core/test_status_comm_result.cpp diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index edcd922aebd..f65ec469ef0 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -290,6 +290,15 @@ void Component::status_set_warning(const LogString *message) { ESP_LOGW(TAG, "%s set Warning flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } +bool Component::status_comm_result(bool ok) { return this->status_comm_result(ok, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } +bool Component::status_comm_result(bool ok, const LogString *message) { + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(message); + } + return ok; +} void Component::status_set_error() { this->status_set_error((const LogString *) nullptr); } void Component::status_set_error(const LogString *message) { if (this->set_error_flag_(message) && message != nullptr) { diff --git a/esphome/core/component.h b/esphome/core/component.h index 0e95f8d4e90..df51af65df5 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -285,6 +285,11 @@ class Component { void status_set_warning(const char *message); void status_set_warning(const LogString *message); + /// Clears the warning when `ok`, else sets it with `message` (default "Communication failed"); returns `ok`. + /// Replaces the `if (!read()) { status_set_warning(...); return; } status_clear_warning();` pattern. + bool status_comm_result(bool ok); + bool status_comm_result(bool ok, const LogString *message); + void status_set_error(); // Set error flag without message void status_set_error(const LogString *message); diff --git a/tests/components/core/test_status_comm_result.cpp b/tests/components/core/test_status_comm_result.cpp new file mode 100644 index 00000000000..125b43a1b8a --- /dev/null +++ b/tests/components/core/test_status_comm_result.cpp @@ -0,0 +1,30 @@ +#include + +#include "esphome/core/component.h" +#include "esphome/core/log.h" + +namespace esphome { + +TEST(StatusCommResult, FailureSetsWarningAndSuccessClearsIt) { + Component component; + EXPECT_FALSE(component.status_comm_result(false)); + EXPECT_TRUE(component.status_has_warning()); + EXPECT_TRUE(component.status_comm_result(true)); + EXPECT_FALSE(component.status_has_warning()); +} + +TEST(StatusCommResult, CustomMessageOverload) { + Component component; + EXPECT_FALSE(component.status_comm_result(false, LOG_STR("Bad CRC"))); + EXPECT_TRUE(component.status_has_warning()); + EXPECT_TRUE(component.status_comm_result(true, LOG_STR("Bad CRC"))); + EXPECT_FALSE(component.status_has_warning()); +} + +TEST(StatusCommResult, SuccessWithoutWarningStaysClear) { + Component component; + EXPECT_TRUE(component.status_comm_result(true)); + EXPECT_FALSE(component.status_has_warning()); +} + +} // namespace esphome From 968d57c56b0b2d1e209cc2f8f44b40dde31101b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:32:13 -1000 Subject: [PATCH 088/179] [core][time] Add BCD helpers and a shared RTC BCD calendar codec (#20860) --- esphome/components/time/real_time_clock.cpp | 33 +++++++++++++ esphome/components/time/real_time_clock.h | 6 +++ esphome/core/helpers.h | 5 ++ tests/components/core/test_bcd.cpp | 26 +++++++++++ tests/components/time/bcd_calendar.cpp | 51 +++++++++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 tests/components/core/test_bcd.cpp create mode 100644 tests/components/time/bcd_calendar.cpp diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 5f6b7d150f9..a46f9712291 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -107,4 +107,37 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { this->time_sync_callback_.call(); } +// Bits of each calendar byte that hold the value, byte 0 lowest; byte 3 is the one-hot weekday. +// Packed into one constant so it stays in code rather than RAM on ESP8266. +static constexpr uint64_t BCD_CALENDAR_MASKS = 0xFF1F3F7F3F7F7FULL; + +ESPTime decode_bcd_calendar(const uint8_t *raw) { + uint8_t value[7]; + for (size_t i = 0; i < 7; i++) + value[i] = bcd_to_uint8(raw[i] & static_cast(BCD_CALENDAR_MASKS >> (8 * i))); + const uint8_t weekday = raw[3] & static_cast(BCD_CALENDAR_MASKS >> 24); + ESPTime time{ + .second = value[0], + .minute = value[1], + .hour = value[2], + .day_of_week = static_cast(weekday ? __builtin_ctz(weekday) + 1 : 1), + .day_of_month = value[4], + .day_of_year = 1, + .month = value[5], + .year = static_cast(value[6] + 2000), + .is_dst = false, + .timestamp = 0, + }; + time.recalc_timestamp_utc(false); + return time; +} + +void encode_bcd_calendar(const ESPTime &time, uint8_t *raw) { + const uint8_t value[7] = { + time.second, time.minute, time.hour, 0, time.day_of_month, time.month, static_cast(time.year % 100)}; + for (size_t i = 0; i < 7; i++) + raw[i] = uint8_to_bcd(value[i]); + raw[3] = 1 << (time.day_of_week - 1); +} + } // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index f98adb67b5b..a87778e470c 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -44,4 +44,10 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; +/// Decode the 7-byte BCD calendar (second, minute, hour, one-hot weekday, day, month, year since 2000) +/// shared by Epson RTCs such as the RX8025T, RX8111 and RX8130, and fill in the UTC timestamp. +ESPTime decode_bcd_calendar(const uint8_t *raw); +/// Encode @p time into that 7-byte layout; @p time must be valid (is_valid()), with the year 2000 to 2099. +void encode_bcd_calendar(const ESPTime &time, uint8_t *raw); + } // namespace esphome::time diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c3e752c9690..d0e3c91ab0b 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1062,6 +1062,11 @@ constexpr std::array decode_value(T val) { return ret; } +/// Decode a two-digit packed BCD byte (tens in the high nibble) to its value, e.g. 0x59 to 59. +constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } +/// Encode a value from 0 to 99 as a two-digit packed BCD byte, e.g. 59 to 0x59. +constexpr uint8_t uint8_to_bcd(uint8_t value) { return ((value / 10) << 4) | (value % 10); } + /// Reverse the order of 8 bits. inline uint8_t reverse_bits(uint8_t x) { x = ((x & 0xAA) >> 1) | ((x & 0x55) << 1); diff --git a/tests/components/core/test_bcd.cpp b/tests/components/core/test_bcd.cpp new file mode 100644 index 00000000000..5d90d5aafcd --- /dev/null +++ b/tests/components/core/test_bcd.cpp @@ -0,0 +1,26 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static_assert(bcd_to_uint8(0x59) == 59); +static_assert(uint8_to_bcd(59) == 0x59); + +TEST(Bcd, RoundTripsEveryTwoDigitValue) { + for (uint8_t value = 0; value < 100; value++) { + const uint8_t bcd = uint8_to_bcd(value); + EXPECT_EQ(bcd >> 4, value / 10); + EXPECT_EQ(bcd & 0x0F, value % 10); + EXPECT_EQ(bcd_to_uint8(bcd), value); + } +} + +TEST(Bcd, DecodesBoundaryBytes) { + EXPECT_EQ(bcd_to_uint8(0x00), 0); + EXPECT_EQ(bcd_to_uint8(0x09), 9); + EXPECT_EQ(bcd_to_uint8(0x10), 10); + EXPECT_EQ(bcd_to_uint8(0x99), 99); +} + +} // namespace esphome::core::testing diff --git a/tests/components/time/bcd_calendar.cpp b/tests/components/time/bcd_calendar.cpp new file mode 100644 index 00000000000..c66454fe1b6 --- /dev/null +++ b/tests/components/time/bcd_calendar.cpp @@ -0,0 +1,51 @@ +#include + +#include + +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::time::testing { + +// 2026-04-16 23:59:58, a Thursday (day_of_week 5, one-hot bit 4). +static constexpr uint8_t RAW[7] = {0x58, 0x59, 0x23, 0x10, 0x16, 0x04, 0x26}; + +TEST(BcdCalendar, DecodesEveryField) { + const ESPTime time = decode_bcd_calendar(RAW); + EXPECT_EQ(time.second, 58); + EXPECT_EQ(time.minute, 59); + EXPECT_EQ(time.hour, 23); + EXPECT_EQ(time.day_of_week, 5); + EXPECT_EQ(time.day_of_month, 16); + EXPECT_EQ(time.month, 4); + EXPECT_EQ(time.year, 2026); + EXPECT_EQ(time.timestamp, 1776383998); + EXPECT_TRUE(time.is_valid(true, false)); +} + +TEST(BcdCalendar, MasksStatusBits) { + // Bit 7 of seconds/minutes, the top two bits of hour and day and the top three of month are not time. + const uint8_t raw[7] = {0xD8, 0xD9, 0xE3, 0x90, 0xD6, 0xE4, 0x26}; + const ESPTime time = decode_bcd_calendar(raw); + EXPECT_EQ(time.second, 58); + EXPECT_EQ(time.minute, 59); + EXPECT_EQ(time.hour, 23); + EXPECT_EQ(time.day_of_week, 5); + EXPECT_EQ(time.day_of_month, 16); + EXPECT_EQ(time.month, 4); +} + +TEST(BcdCalendar, EmptyWeekdayDecodesAsSunday) { + uint8_t raw[7]; + std::copy(RAW, RAW + 7, raw); + raw[3] = 0; + EXPECT_EQ(decode_bcd_calendar(raw).day_of_week, 1); +} + +TEST(BcdCalendar, EncodeRoundTrips) { + uint8_t raw[7]; + encode_bcd_calendar(decode_bcd_calendar(RAW), raw); + for (size_t i = 0; i < 7; i++) + EXPECT_EQ(raw[i], RAW[i]) << "byte " << i; +} + +} // namespace esphome::time::testing From a8ab035b59c1594f59c80f04f33fbf1eae4c1be5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:33:05 -1000 Subject: [PATCH 089/179] [lock] Filter on_lock and on_unlock with when (#20858) --- esphome/components/lock/__init__.py | 10 ++++++---- esphome/components/lock/automation.h | 22 ---------------------- 2 files changed, 6 insertions(+), 26 deletions(-) delete mode 100644 esphome/components/lock/automation.h diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index fb9a79f81fe..af12fc61b28 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -32,8 +32,6 @@ LockCall = lock_ns.class_("LockCall") LockPublishAction = lock_ns.class_("LockPublishAction", automation.Action) -LockStateForwarder = lock_ns.class_("LockStateForwarder") - LockState = lock_ns.enum("LockState") LOCK_STATES = LockState.options( @@ -90,12 +88,16 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_LOCK, "add_on_state_callback", - forwarder=LockStateForwarder.template(LockState.LOCK_STATE_LOCKED), + params=[(LockState, "state")], + forward=[], + when="state == lock::LOCK_STATE_LOCKED", ), automation.CallbackAutomation( CONF_ON_UNLOCK, "add_on_state_callback", - forwarder=LockStateForwarder.template(LockState.LOCK_STATE_UNLOCKED), + params=[(LockState, "state")], + forward=[], + when="state == lock::LOCK_STATE_UNLOCKED", ), ) diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h deleted file mode 100644 index e578a056439..00000000000 --- a/esphome/components/lock/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "esphome/components/lock/lock.h" -#include "esphome/core/automation.h" -#include "esphome/core/component.h" - -namespace esphome::lock { - -/// Callback forwarder that triggers an Automation<> only when a specific lock state is entered. -/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. -template struct LockStateForwarder { - Automation<> *automation; - void operator()(LockState state) const { - if (state == State) - this->automation->trigger(); - } -}; - -static_assert(sizeof(LockStateForwarder) <= sizeof(void *)); -static_assert(std::is_trivially_copyable_v>); - -} // namespace esphome::lock From 5ad63e3bb628fabf69e6a9a8f9deb74d31120b10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 16:38:07 -1000 Subject: [PATCH 090/179] [http_request] Only treat a URL as secure when its scheme is https (#20867) --- esphome/components/http_request/http_request_arduino.cpp | 3 ++- esphome/components/http_request/http_request_idf.cpp | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 814d598132e..e24531094da 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -40,7 +40,8 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons const uint32_t start = millis(); - bool secure = ESPHOME_strstr_P(url, ESPHOME_PSTR("https:")) != nullptr; + // Only the scheme counts; "https:" later in the URL (a query string) does not make it secure + bool secure = ESPHOME_strncasecmp_P(url, ESPHOME_PSTR("https:"), 6) == 0; container->set_secure(secure); container->set_watchdog_timeout(this->get_watchdog_timeout(timeout_ms)); diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 879abb662b7..cc04c5472c0 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -9,6 +9,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE #include "esp_crt_bundle.h" @@ -77,7 +78,8 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch return nullptr; } - bool secure = strstr(url, "https:") != nullptr; + // Only the scheme counts; "https:" later in the URL (a query string) does not make it secure + bool secure = ESPHOME_strncasecmp_P(url, ESPHOME_PSTR("https:"), 6) == 0; esp_http_client_config_t config = {}; From 6580f4f314d1e6734547745f7655b3251f90816c Mon Sep 17 00:00:00 2001 From: embe <31192394+mbrudka@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:43:07 +0200 Subject: [PATCH 091/179] [micro_wake_word] Allow configurable capture buffer duration (#20942) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/micro_wake_word/__init__.py | 12 ++++++ .../micro_wake_word/micro_wake_word.cpp | 4 +- .../micro_wake_word/micro_wake_word.h | 5 +++ .../micro_wake_word/test_init.py | 37 ++++++++++++++++++- .../test-internal.esp32-idf.yaml | 1 + .../micro_wake_word/test.esp32-s3-idf.yaml | 1 + 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index ca65fa1a5e6..6756e93c486 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -48,6 +48,7 @@ CONF_TASK_CORE = "task_core" CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" +CONF_RING_BUFFER_DURATION = "ring_buffer_duration" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" CONF_PREFER_INTERNAL_RAM = "prefer_internal_ram" CONF_PROBABILITY_CUTOFF = "probability_cutoff" @@ -433,6 +434,11 @@ def _validate_task_core(config: ConfigType) -> ConfigType: return config +# The capture buffer holds at least one feature window and at most a second of audio +MIN_RING_BUFFER_DURATION = cv.TimePeriod(milliseconds=120) +MAX_RING_BUFFER_DURATION = cv.TimePeriod(seconds=1) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -455,6 +461,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, cv.Optional(CONF_TASK_CORE, default=-1): cv.int_range(min=-1, max=1), cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, + cv.Optional(CONF_RING_BUFFER_DURATION): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=MIN_RING_BUFFER_DURATION, max=MAX_RING_BUFFER_DURATION), + ), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter." @@ -635,6 +645,8 @@ async def to_code(config): cg.add(var.add_wake_word_model(wake_word_model)) cg.add(var.set_features_step_size(feature_step_size)) + if (ring_buffer_duration := config.get(CONF_RING_BUFFER_DURATION)) is not None: + cg.add(var.set_ring_buffer_duration_ms(ring_buffer_duration.total_milliseconds)) cg.add(var.set_stop_after_detection(config[CONF_STOP_AFTER_DETECTION])) if on_wake_word_detection_config := config.get(CONF_ON_WAKE_WORD_DETECTED): diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 872dd7bd79a..0c9d071e6f3 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -27,8 +27,6 @@ static const ssize_t DETECTION_QUEUE_LENGTH = 5; static const size_t DATA_TIMEOUT_MS = 50; -static const uint32_t RING_BUFFER_DURATION_MS = 120; - #ifdef CONFIG_IDF_TARGET_ESP32P4 // ESP32-P4 PIE-optimized esp-nn kernels (e.g. depthwise_conv_s8_ch1_pie) require // significantly more stack than other variants, causing stack protection faults at 3072. @@ -181,7 +179,7 @@ void MicroWakeWord::inference_task(void *params) { if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { // Round ring buffer size down to a frame multiple so the wrap boundary never splits an int16 sample. const size_t ring_buffer_size = - (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + (stream_info.ms_to_bytes(this_mww->ring_buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); if (temp_ring_buffer == nullptr) { xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 862d40733bf..5aa87cf1b73 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -24,6 +24,8 @@ namespace esphome::micro_wake_word { +static constexpr uint16_t DEFAULT_RING_BUFFER_DURATION_MS = 120; + enum State { STARTING, DETECTING_WAKE_WORD, @@ -54,6 +56,8 @@ class MicroWakeWord final : public Component void set_features_step_size(uint8_t step_size) { this->features_step_size_ = step_size; } + void set_ring_buffer_duration_ms(uint16_t duration_ms) { this->ring_buffer_duration_ms_ = duration_ms; } + void set_microphone_source(microphone::MicrophoneSource *microphone_source) { this->microphone_source_ = microphone_source; } @@ -126,6 +130,7 @@ class MicroWakeWord final : public Component bool task_stack_in_psram_{false}; uint8_t features_step_size_; + uint16_t ring_buffer_duration_ms_{DEFAULT_RING_BUFFER_DURATION_MS}; // at most a second, see the schema // Audio frontend handles generating spectrogram features struct FrontendConfig frontend_config_; diff --git a/tests/component_tests/micro_wake_word/test_init.py b/tests/component_tests/micro_wake_word/test_init.py index 5e57653585f..0d2e6b87985 100644 --- a/tests/component_tests/micro_wake_word/test_init.py +++ b/tests/component_tests/micro_wake_word/test_init.py @@ -7,8 +7,15 @@ from typing import Any import pytest -from esphome.components.micro_wake_word import LOCAL_SCHEMA +from esphome.components.micro_wake_word import ( + CONF_RING_BUFFER_DURATION, + CONFIG_SCHEMA, + LOCAL_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import PlatformFramework from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable MANIFEST: dict[str, Any] = { "type": "micro", @@ -27,6 +34,34 @@ MANIFEST: dict[str, Any] = { } +def test_ring_buffer_duration_is_absent_by_default( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + assert CONF_RING_BUFFER_DURATION not in CONFIG_SCHEMA({}) + + +@pytest.mark.parametrize( + ("duration", "expected_ms"), + [("120ms", 120), ("400ms", 400), ("800ms", 800), ("1s", 1000)], +) +def test_ring_buffer_duration_is_configurable( + duration: str, expected_ms: int, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({CONF_RING_BUFFER_DURATION: duration}) + assert config[CONF_RING_BUFFER_DURATION].total_milliseconds == expected_ms + + +@pytest.mark.parametrize("duration", ["119ms", "1001ms", "0ms"]) +def test_ring_buffer_duration_schema_rejects_out_of_range( + duration: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_RING_BUFFER_DURATION: duration}) + + def _registered_files() -> list[Path]: """Files components registered for bundling this run.""" data = CORE.data.get("bundle") diff --git a/tests/components/micro_wake_word/test-internal.esp32-idf.yaml b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml index bddab918c2a..0b072cadcc6 100644 --- a/tests/components/micro_wake_word/test-internal.esp32-idf.yaml +++ b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml @@ -7,3 +7,4 @@ packages: micro_wake_word: prefer_internal_ram: true + ring_buffer_duration: 800ms diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index 3ed671a764d..f4709dd5502 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -8,3 +8,4 @@ packages: micro_wake_word: task_core: 1 prefer_internal_ram: true + ring_buffer_duration: 400ms From 265754c4672d01a4e3380e580afd1057281d5b8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 17:43:16 -1000 Subject: [PATCH 092/179] [components] Drop get_setup_priority overrides that return the default (#21014) --- esphome/components/bmi270/bmi270.h | 1 - esphome/components/lsm6ds/lsm6ds.h | 1 - esphome/components/motion/motion_component.h | 1 - esphome/components/qmi8658/qmi8658.h | 1 - esphome/components/resampler/speaker/resampler_speaker.h | 1 - esphome/components/router/speaker/router_speaker.h | 2 -- esphome/components/sen6x/sen6x.h | 1 - .../sensor/waveshare_io_ch32v003_sensor.cpp | 2 -- .../waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h | 1 - esphome/components/xpt2046/touchscreen/xpt2046.cpp | 2 -- esphome/components/xpt2046/touchscreen/xpt2046.h | 1 - 11 files changed, 14 deletions(-) diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index a1fefdffe80..da12ea7973c 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -136,7 +136,6 @@ class BMI270Component final : public motion::MotionComponent, public i2c::I2CDev // Lifecycle void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } // Configuration setters void set_accel_range(BMI270AccelRange r) { this->accel_range_ = r; } diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index d754b08a252..9b3c442dbf0 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -96,7 +96,6 @@ class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDev public: void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } // Configuration setters (called from Python codegen) void set_accel_range(LSM6DSAccelRange r) { this->accel_range_ = r; } diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index 2ca920dbe0e..e2674dbdef6 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -65,7 +65,6 @@ class MotionComponent : public PollingComponent { void setup() override; void update() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } void set_matrix(const std::array &m) { memcpy(this->base_matrix_, m.data(), sizeof(this->base_matrix_)); diff --git a/esphome/components/qmi8658/qmi8658.h b/esphome/components/qmi8658/qmi8658.h index ce31a2b7a9f..aea668d7e5a 100644 --- a/esphome/components/qmi8658/qmi8658.h +++ b/esphome/components/qmi8658/qmi8658.h @@ -88,7 +88,6 @@ class QMI8658Component : public motion::MotionComponent, public i2c::I2CDevice { // Lifecycle void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::DATA; } // Configuration setters void set_accel_range(QMI8658AccelRange r) { this->accel_range_ = r; } diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index 3255bf1fe8c..22ecb9e5207 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -16,7 +16,6 @@ namespace esphome::resampler { class ResamplerSpeaker final : public Component, public speaker::Speaker { public: - float get_setup_priority() const override { return esphome::setup_priority::DATA; } void dump_config() override; void setup() override; void loop() override; diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 2ce4c1a906d..f743d00fcce 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -14,8 +14,6 @@ namespace esphome::router { class Router final : public Component, public speaker::Speaker { public: - float get_setup_priority() const override { return setup_priority::DATA; } - void setup() override; void loop() override; void dump_config() override; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index e73f93f5a4c..234ad75cc40 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -82,7 +82,6 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S SUB_SENSOR(hcho) public: - float get_setup_priority() const override { return setup_priority::DATA; } void setup() override; void dump_config() override; void update() override; diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp index 71cb9ba79fd..42623319b60 100644 --- a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp @@ -6,8 +6,6 @@ namespace esphome::waveshare_io_ch32v003 { ESPHOME_LOG_TAG(TAG, "waveshare_io_ch32v003.sensor"); -float WaveshareIOCH32V003Sensor::get_setup_priority() const { return setup_priority::DATA; } - void WaveshareIOCH32V003Sensor::dump_config() { ESP_LOGCONFIG(TAG, "WaveshareIOCH32V003Sensor:\n" diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h index 01beab5137b..2e7af7b07dc 100644 --- a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h @@ -19,7 +19,6 @@ class WaveshareIOCH32V003Sensor : public sensor::Sensor, void update() override; void dump_config() override; - float get_setup_priority() const override; float sample() override; protected: diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index afb8f6cd5c8..903f8d6859a 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -82,8 +82,6 @@ void XPT2046Component::dump_config() { LOG_UPDATE_INTERVAL(this); } -// float XPT2046Component::get_setup_priority() const { return setup_priority::DATA; } - int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t value3) { int16_t delta_a, delta_b, delta_c; int16_t reta = 0; diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.h b/esphome/components/xpt2046/touchscreen/xpt2046.h index 8fe9b7cc436..0b74fe2c203 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.h +++ b/esphome/components/xpt2046/touchscreen/xpt2046.h @@ -22,7 +22,6 @@ class XPT2046Component final : public Touchscreen, void setup() override; void dump_config() override; - // float get_setup_priority() const override; protected: static int16_t best_two_avg(int16_t value1, int16_t value2, int16_t value3); From 2fe1333cba6d8efc8219153b992f238076d2bc4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 18:34:38 -1000 Subject: [PATCH 093/179] [display] Add add_writer for display lambdas (#21027) --- esphome/components/display/__init__.py | 15 ++++++++ tests/unit_tests/components/test_display.py | 41 +++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/unit_tests/components/test_display.py diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 22e3d9320b0..be8586b3b25 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -27,6 +27,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.final_validate import full_config +from esphome.types import ConfigType DOMAIN = "display" IS_PLATFORM_COMPONENT = True @@ -291,6 +292,20 @@ async def register_display(var, config): await setup_display_core_(var, config) +async def add_writer( + var: cg.MockObj, config: ConfigType, ref: cg.MockObj = DisplayRef +) -> None: + """Pass the configured lambda, if any, to var.set_writer. + + ref is the type of `it`; drivers with their own drawing class pass theirs. + """ + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(ref, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) + + @automation.register_action( "display.page.show", DisplayPageShowAction, diff --git a/tests/unit_tests/components/test_display.py b/tests/unit_tests/components/test_display.py new file mode 100644 index 00000000000..a98dd80ec0e --- /dev/null +++ b/tests/unit_tests/components/test_display.py @@ -0,0 +1,41 @@ +"""Tests for display.add_writer.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from esphome import codegen as cg +from esphome.components import display +from esphome.const import CONF_LAMBDA + + +@pytest.mark.parametrize( + ("kwargs", "ref"), + [({}, display.DisplayRef), ({"ref": "MyDisplayRef"}, "MyDisplayRef")], +) +@pytest.mark.asyncio +async def test_add_writer_sets_the_lambda(kwargs: dict, ref: object) -> None: + var = MagicMock(name="var") + lambda_config = MagicMock(name="lambda") + with ( + patch.object(cg, "process_lambda", AsyncMock(return_value="fn")) as process, + patch.object(cg, "add") as add, + ): + await display.add_writer(var, {CONF_LAMBDA: lambda_config}, **kwargs) + + process.assert_awaited_once_with(lambda_config, [(ref, "it")], return_type=cg.void) + var.set_writer.assert_called_once_with("fn") + add.assert_called_once_with(var.set_writer.return_value) + + +@pytest.mark.asyncio +async def test_add_writer_without_lambda_does_nothing() -> None: + var = MagicMock(name="var") + with ( + patch.object(cg, "process_lambda", AsyncMock()) as process, + patch.object(cg, "add") as add, + ): + await display.add_writer(var, {}) + + process.assert_not_awaited() + add.assert_not_called() From 0bb78186f2e4a08b608e9e8587f40a6415a9afb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 18:34:38 -1000 Subject: [PATCH 094/179] [components] Set display lambdas with display.add_writer (#21028) --- esphome/components/addressable_light/display.py | 6 +----- esphome/components/hub75/display.py | 7 +------ esphome/components/inkplate/display.py | 6 +----- esphome/components/it8951/display.py | 6 +----- esphome/components/lcd_gpio/display.py | 11 ++--------- esphome/components/lcd_pcf8574/display.py | 12 +++--------- esphome/components/max7219/display.py | 8 ++------ esphome/components/max7219digit/display.py | 7 +------ esphome/components/mipi_dsi/display.py | 7 +------ esphome/components/mipi_rgb/display.py | 7 +------ esphome/components/pcd8544/display.py | 6 +----- esphome/components/pixoo/display.py | 8 ++------ .../pvvx_mithermometer/display/__init__.py | 7 +------ esphome/components/sdl/display.py | 7 +------ esphome/components/sh1122_base/__init__.py | 8 ++------ esphome/components/snapshot/display/__init__.py | 7 +------ esphome/components/ssd1306_base/__init__.py | 7 +------ esphome/components/ssd1322_base/__init__.py | 14 ++------------ esphome/components/ssd1325_base/__init__.py | 14 ++------------ esphome/components/ssd1327_base/__init__.py | 8 ++------ esphome/components/ssd1331_base/__init__.py | 8 ++------ esphome/components/ssd1351_base/__init__.py | 8 ++------ esphome/components/st7567_base/__init__.py | 7 +------ esphome/components/st7701s/display.py | 7 +------ esphome/components/st7920/display.py | 8 ++------ esphome/components/tm1621/display.py | 7 +------ esphome/components/tm1637/display.py | 7 +------ esphome/components/tm1638/display.py | 8 +------- 28 files changed, 40 insertions(+), 183 deletions(-) diff --git a/esphome/components/addressable_light/display.py b/esphome/components/addressable_light/display.py index 1db01b40f90..3315d01f51a 100644 --- a/esphome/components/addressable_light/display.py +++ b/esphome/components/addressable_light/display.py @@ -56,8 +56,4 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_pixel_mapper(pixel_mapper_template_)) - if lambda_config := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lambda_config, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 408126e1ad1..b6aa35b8a7b 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_CLK_PIN, CONF_GAMMA_CORRECT, CONF_ID, - CONF_LAMBDA, CONF_OE_PIN, CONF_ROTATION, CONF_UPDATE_INTERVAL, @@ -603,11 +602,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], hub75_config) await display.register_display(var, config) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) automation.register_apply_action( diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 93eecbbe919..c7600d39bbc 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -164,11 +164,7 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await i2c.register_i2c_device(var, config) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) cg.add(var.set_greyscale(config[CONF_GREYSCALE])) if transform := config.get(CONF_TRANSFORM): diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index d9c80ebf43d..28636e5e26e 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -366,11 +366,7 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=False) - if lambda_config := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lambda_config, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) if reset_pin := config.get(CONF_RESET_PIN): cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) if busy_pin := config.get(CONF_BUSY_PIN): diff --git a/esphome/components/lcd_gpio/display.py b/esphome/components/lcd_gpio/display.py index 10e21c87d19..0e99b471b3c 100644 --- a/esphome/components/lcd_gpio/display.py +++ b/esphome/components/lcd_gpio/display.py @@ -1,12 +1,11 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import lcd_base +from esphome.components import display, lcd_base import esphome.config_validation as cv from esphome.const import ( CONF_DATA_PINS, CONF_ENABLE_PIN, CONF_ID, - CONF_LAMBDA, CONF_RS_PIN, CONF_RW_PIN, ) @@ -54,10 +53,4 @@ async def to_code(config: ConfigType) -> None: rw = await cg.gpio_pin_expression(config[CONF_RW_PIN]) cg.add(var.set_rw_pin(rw)) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], - [(GPIOLCDDisplay.operator("ref"), "it")], - return_type=cg.void, - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, GPIOLCDDisplay.operator("ref")) diff --git a/esphome/components/lcd_pcf8574/display.py b/esphome/components/lcd_pcf8574/display.py index 85a79e99cec..d97ef5b5699 100644 --- a/esphome/components/lcd_pcf8574/display.py +++ b/esphome/components/lcd_pcf8574/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg -from esphome.components import i2c, lcd_base +from esphome.components import display, i2c, lcd_base import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA +from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -24,10 +24,4 @@ async def to_code(config: ConfigType) -> None: await lcd_base.setup_lcd_display(var, config) await i2c.register_i2c_device(var, config) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], - [(PCF8574LCDDisplay.operator("ref"), "it")], - return_type=cg.void, - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, PCF8574LCDDisplay.operator("ref")) diff --git a/esphome/components/max7219/display.py b/esphome/components/max7219/display.py index b21f66b553d..76943c65280 100644 --- a/esphome/components/max7219/display.py +++ b/esphome/components/max7219/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import display, spi import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTENSITY, CONF_LAMBDA, CONF_NUM_CHIPS +from esphome.const import CONF_ID, CONF_INTENSITY, CONF_NUM_CHIPS from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -36,8 +36,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_intensity(config[CONF_INTENSITY])) cg.add(var.set_reverse(config[CONF_REVERSE_ENABLE])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(MAX7219ComponentRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, MAX7219ComponentRef) diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index 2271bc09f48..1a982946f68 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_FLIP_X, CONF_ID, CONF_INTENSITY, - CONF_LAMBDA, CONF_NUM_CHIPS, CONF_STATE, ) @@ -97,11 +96,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_reverse(config[CONF_REVERSE_ENABLE])) cg.add(var.set_flip_x(config[CONF_FLIP_X])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(MAX7219ComponentRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, MAX7219ComponentRef) MAX7219_OFF_ACTION_SCHEMA = automation.maybe_simple_id( diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 97af7a33243..6dd96d21bcb 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -45,7 +45,6 @@ from esphome.const import ( CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, @@ -224,8 +223,4 @@ async def to_code(config: ConfigType) -> None: if model.rotation_as_transform(config): config[CONF_ROTATION] = 0 await display.register_display(var, config) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index e973d78ae4d..4c87bf19987 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -65,7 +65,6 @@ from esphome.const import ( CONF_IGNORE_STRAPPING_WARNING, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MODEL, CONF_NUMBER, CONF_RED, @@ -340,8 +339,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_vsync_pin(pin)) await display.register_display(var, config) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/pcd8544/display.py b/esphome/components/pcd8544/display.py index 42cef674884..c63d18b3cc4 100644 --- a/esphome/components/pcd8544/display.py +++ b/esphome/components/pcd8544/display.py @@ -54,8 +54,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_contrast(config[CONF_CONTRAST])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/pixoo/display.py b/esphome/components/pixoo/display.py index 764f06d603a..3aaf378f727 100644 --- a/esphome/components/pixoo/display.py +++ b/esphome/components/pixoo/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import display, spi import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA, CONF_MODEL +from esphome.const import CONF_ID, CONF_MODEL from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -36,8 +36,4 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) - if (lambda_config := config.get(CONF_LAMBDA)) is not None: - lambda_ = await cg.process_lambda( - lambda_config, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/pvvx_mithermometer/display/__init__.py b/esphome/components/pvvx_mithermometer/display/__init__.py index 436f83630c1..1b8c0b5be30 100644 --- a/esphome/components/pvvx_mithermometer/display/__init__.py +++ b/esphome/components/pvvx_mithermometer/display/__init__.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, CONF_DISCONNECT_DELAY, CONF_ID, - CONF_LAMBDA, CONF_TIME_ID, CONF_VALIDITY_PERIOD, ) @@ -49,8 +48,4 @@ async def to_code(config: ConfigType) -> None: time_ = await cg.get_variable(config[CONF_TIME_ID]) cg.add(var.set_time(time_)) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(PVVXDisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, PVVXDisplayRef) diff --git a/esphome/components/sdl/display.py b/esphome/components/sdl/display.py index 77b0001c556..58dff4430eb 100644 --- a/esphome/components/sdl/display.py +++ b/esphome/components/sdl/display.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_DIMENSIONS, CONF_HEIGHT, CONF_ID, - CONF_LAMBDA, CONF_POSITION, CONF_WIDTH, CONF_X, @@ -189,8 +188,4 @@ async def to_code(config: ConfigType) -> None: else: cg.add(var.set_position(position[CONF_X], position[CONF_Y])) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/sh1122_base/__init__.py b/esphome/components/sh1122_base/__init__.py index 4f2b026a5ed..e8828685368 100644 --- a/esphome/components/sh1122_base/__init__.py +++ b/esphome/components/sh1122_base/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN +from esphome.const import CONF_BRIGHTNESS, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -36,8 +36,4 @@ async def setup_sh1122(var: MockObj, config: ConfigType) -> None: reset = await cg.gpio_pin_expression(reset_pin) cg.add(var.set_reset_pin(reset)) cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if (lambda_config := config.get(CONF_LAMBDA)) is not None: - lambda_ = await cg.process_lambda( - lambda_config, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/snapshot/display/__init__.py b/esphome/components/snapshot/display/__init__.py index 68429f164bd..eaecc869197 100644 --- a/esphome/components/snapshot/display/__init__.py +++ b/esphome/components/snapshot/display/__init__.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_DIMENSIONS, CONF_HEIGHT, CONF_ID, - CONF_LAMBDA, CONF_WIDTH, PLATFORM_HOST, ) @@ -54,8 +53,4 @@ async def to_code(config: ConfigType) -> None: (width, height) = dimensions cg.add(var.set_dimensions(width, height)) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index 9577f9d4f7d..b51fb6f8f22 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_FLIP_X, CONF_FLIP_Y, CONF_INVERT, - CONF_LAMBDA, CONF_MODEL, CONF_OFFSET_X, CONF_OFFSET_Y, @@ -100,8 +99,4 @@ async def setup_ssd1306(var: MockObj, config: ConfigType) -> None: cg.add(var.init_offset_y(config[CONF_OFFSET_Y])) if CONF_INVERT in config: cg.add(var.init_invert(config[CONF_INVERT])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1322_base/__init__.py b/esphome/components/ssd1322_base/__init__.py index d66ae8d5c97..72f8bd27235 100644 --- a/esphome/components/ssd1322_base/__init__.py +++ b/esphome/components/ssd1322_base/__init__.py @@ -2,13 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import ( - CONF_BRIGHTNESS, - CONF_EXTERNAL_VCC, - CONF_LAMBDA, - CONF_MODEL, - CONF_RESET_PIN, -) +from esphome.const import CONF_BRIGHTNESS, CONF_EXTERNAL_VCC, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -46,8 +40,4 @@ async def setup_ssd1322(var: MockObj, config: ConfigType) -> None: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: cg.add(var.set_external_vcc(config[CONF_EXTERNAL_VCC])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1325_base/__init__.py b/esphome/components/ssd1325_base/__init__.py index fbbf329d53b..e148808d15f 100644 --- a/esphome/components/ssd1325_base/__init__.py +++ b/esphome/components/ssd1325_base/__init__.py @@ -2,13 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import ( - CONF_BRIGHTNESS, - CONF_EXTERNAL_VCC, - CONF_LAMBDA, - CONF_MODEL, - CONF_RESET_PIN, -) +from esphome.const import CONF_BRIGHTNESS, CONF_EXTERNAL_VCC, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -50,8 +44,4 @@ async def setup_ssd1325(var: MockObj, config: ConfigType) -> None: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: cg.add(var.set_external_vcc(config[CONF_EXTERNAL_VCC])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1327_base/__init__.py b/esphome/components/ssd1327_base/__init__.py index 2c9c0faf3b2..93e4c75d3b1 100644 --- a/esphome/components/ssd1327_base/__init__.py +++ b/esphome/components/ssd1327_base/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN +from esphome.const import CONF_BRIGHTNESS, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -37,8 +37,4 @@ async def setup_ssd1327(var: MockObj, config: ConfigType) -> None: cg.add(var.set_reset_pin(reset)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1331_base/__init__.py b/esphome/components/ssd1331_base/__init__.py index 2345acdf650..5f71570b6ae 100644 --- a/esphome/components/ssd1331_base/__init__.py +++ b/esphome/components/ssd1331_base/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_RESET_PIN +from esphome.const import CONF_BRIGHTNESS, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -28,8 +28,4 @@ async def setup_ssd1331(var: MockObj, config: ConfigType) -> None: cg.add(var.set_reset_pin(reset)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/ssd1351_base/__init__.py b/esphome/components/ssd1351_base/__init__.py index f693004d633..2c44cd18403 100644 --- a/esphome/components/ssd1351_base/__init__.py +++ b/esphome/components/ssd1351_base/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv -from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN +from esphome.const import CONF_BRIGHTNESS, CONF_MODEL, CONF_RESET_PIN from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -38,8 +38,4 @@ async def setup_ssd1351(var: MockObj, config: ConfigType) -> None: cg.add(var.set_reset_pin(reset)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/st7567_base/__init__.py b/esphome/components/st7567_base/__init__.py index 8fdc0a7f7af..a045cf34ccd 100644 --- a/esphome/components/st7567_base/__init__.py +++ b/esphome/components/st7567_base/__init__.py @@ -4,7 +4,6 @@ from esphome.components import display import esphome.config_validation as cv from esphome.const import ( CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, CONF_RESET_PIN, @@ -51,8 +50,4 @@ async def setup_st7567(var: MockObj, config: ConfigType) -> None: cg.add(var.init_mirror_x(transform[CONF_MIRROR_X])) cg.add(var.init_mirror_y(transform[CONF_MIRROR_Y])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 2805837ee01..79430f14176 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -30,7 +30,6 @@ from esphome.const import ( CONF_IGNORE_STRAPPING_WARNING, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, CONF_NUMBER, @@ -235,11 +234,7 @@ async def to_code(config: ConfigType) -> None: (width, height) = dimensions cg.add(var.set_dimensions(width, height)) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) pin = await cg.gpio_pin_expression(config[CONF_DE_PIN]) cg.add(var.set_de_pin(pin)) diff --git a/esphome/components/st7920/display.py b/esphome/components/st7920/display.py index 853e38a2795..ce4fe5d364c 100644 --- a/esphome/components/st7920/display.py +++ b/esphome/components/st7920/display.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import display, spi import esphome.config_validation as cv -from esphome.const import CONF_HEIGHT, CONF_ID, CONF_LAMBDA, CONF_WIDTH +from esphome.const import CONF_HEIGHT, CONF_ID, CONF_WIDTH from esphome.types import ConfigType AUTO_LOAD = ["display"] @@ -31,11 +31,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await spi.register_spi_device(var, config, write_only=True) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(ST7920Ref, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, ST7920Ref) cg.add(var.set_width(config[CONF_WIDTH])) cg.add(var.set_height(config[CONF_HEIGHT])) diff --git a/esphome/components/tm1621/display.py b/esphome/components/tm1621/display.py index e8a40507d84..4a83d363fe4 100644 --- a/esphome/components/tm1621/display.py +++ b/esphome/components/tm1621/display.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_CS_PIN, CONF_DATA_PIN, CONF_ID, - CONF_LAMBDA, CONF_READ_PIN, CONF_WRITE_PIN, ) @@ -40,8 +39,4 @@ async def to_code(config: ConfigType) -> None: write = await cg.gpio_pin_expression(config[CONF_WRITE_PIN]) cg.add(var.set_write_pin(write)) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(TM1621DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, TM1621DisplayRef) diff --git a/esphome/components/tm1637/display.py b/esphome/components/tm1637/display.py index ec4d32561b8..ebdd9449d65 100644 --- a/esphome/components/tm1637/display.py +++ b/esphome/components/tm1637/display.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_ID, CONF_INTENSITY, CONF_INVERTED, - CONF_LAMBDA, CONF_LENGTH, ) from esphome.types import ConfigType @@ -46,8 +45,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_inverted(config[CONF_INVERTED])) cg.add(var.set_length(config[CONF_LENGTH])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(TM1637DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, TM1637DisplayRef) diff --git a/esphome/components/tm1638/display.py b/esphome/components/tm1638/display.py index d6491129c66..74ee8b3293d 100644 --- a/esphome/components/tm1638/display.py +++ b/esphome/components/tm1638/display.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_DIO_PIN, CONF_ID, CONF_INTENSITY, - CONF_LAMBDA, CONF_STB_PIN, ) from esphome.types import ConfigType @@ -47,9 +46,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_intensity(config[CONF_INTENSITY])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(TM1638ComponentRef, "it")], return_type=cg.void - ) - - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, TM1638ComponentRef) From 032a2b7200a8895f9821cc6bbbfaa91f18b9ba78 Mon Sep 17 00:00:00 2001 From: Michele Porelli Date: Sat, 17 Oct 2026 23:58:40 -0700 Subject: [PATCH 095/179] [core] Let entities choose flash or RTC storage for their preferences (#21017) Co-authored-by: J. Nick Koston --- esphome/core/entity_base.cpp | 13 +++- esphome/core/entity_base.h | 12 +++- .../host/entity_preference_test.cpp | 71 +++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 tests/components/host/entity_preference_test.cpp diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index cbb14a37302..51cdb19eb42 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -136,7 +136,7 @@ StringRef EntityBase::get_object_id_to(std::span buf) c return StringRef(buf.data(), len); } -ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { +uint32_t EntityBase::entity_preference_key_(uint32_t version) { // The key hashes the sanitized object_id, so multiple entity names can collide on one // key and overwrite each other's stored preferences ("Living Room" and "living_room", // or two UTF-8 names that both sanitize to underscores). Keys hashed from the raw name @@ -144,9 +144,16 @@ ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t ve // esphome integration cannot handle yet. See: https://github.com/esphome/backlog/issues/85 #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - uint32_t key = this->get_preference_hash() ^ version; + return this->get_preference_hash() ^ version; #pragma GCC diagnostic pop - return global_preferences->make_preference(size, key); +} + +ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version) { + return global_preferences->make_preference(size, this->entity_preference_key_(version)); +} + +ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t version, bool in_flash) { + return global_preferences->make_preference(size, this->entity_preference_key_(version), in_flash); } #ifdef USE_ENTITY_ICON diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 8796e9f067a..3250ad74730 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -190,6 +190,12 @@ class EntityBase { return this->make_entity_preference_(sizeof(T), version); } + /// As make_entity_preference(), stored in flash or RTC memory where the backend supports the choice. + template ESPPreferenceObject make_entity_preference(uint32_t version, bool in_flash) { + static_assert(std::is_trivially_copyable::value, "T must be trivially copyable"); + return this->make_entity_preference_(sizeof(T), version, in_flash); + } + protected: friend void ::setup(); friend void ::original_setup(); @@ -206,9 +212,11 @@ class EntityBase { void set_device_(Device *device) { this->device_ = device; } #endif - /// Non-template helper for make_entity_preference() to avoid code bloat. - /// When the preference hash algorithm changes, migration logic goes here. + /// Non-template helpers for make_entity_preference() to avoid code bloat. ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version); + ESPPreferenceObject make_entity_preference_(size_t size, uint32_t version, bool in_flash); + /// Key for both helpers; preference hash migration goes here. + uint32_t entity_preference_key_(uint32_t version); void calc_object_id_(); diff --git a/tests/components/host/entity_preference_test.cpp b/tests/components/host/entity_preference_test.cpp new file mode 100644 index 00000000000..3ee6f21132d --- /dev/null +++ b/tests/components/host/entity_preference_test.cpp @@ -0,0 +1,71 @@ +#ifdef USE_HOST +#include +#include +#include +#include +#include "esphome/components/host/preferences.h" +#include "esphome/core/application.h" +#include "esphome/core/entity_base.h" + +namespace esphome::host::testing { +namespace fs = std::filesystem; + +/// Same object_id_hash_ means same preference key. +class TestEntity : public EntityBase { + public: + void set_test_id(uint32_t hash) { this->object_id_hash_ = hash; } +}; + +class EntityPreferenceTest : public ::testing::Test { + protected: + void SetUp() override { + this->temp_dir_ = fs::temp_directory_path() / "esphome_entity_preference_test"; + fs::remove_all(this->temp_dir_); + fs::create_directories(this->temp_dir_); + setenv("ESPHOME_PREFDIR", this->temp_dir_.c_str(), 1); + App.pre_setup("test_entity_pref", strlen("test_entity_pref"), "", 0); + setup_preferences(); + // Clear the in-memory store so values do not leak between tests. + host_preferences->reset(); + } + + void TearDown() override { + unsetenv("ESPHOME_PREFDIR"); + fs::remove_all(this->temp_dir_); + } + + fs::path temp_dir_; +}; + +// in_flash must not change the key; host ignores the storage type, so only the key is checked. +TEST_F(EntityPreferenceTest, InFlashOverloadKeepsTheEntityKey) { + TestEntity entity; + entity.set_test_id(0x6001); + for (const bool in_flash : {false, true}) { + const uint32_t saved = in_flash ? 0xF1A5 : 0x0C7C; + auto with_storage = entity.make_entity_preference(7, in_flash); + ASSERT_TRUE(with_storage.save(&saved)); + global_preferences->sync(); + + uint32_t loaded = 0; + auto without_storage = entity.make_entity_preference(7); + ASSERT_TRUE(without_storage.load(&loaded)) << "in_flash=" << in_flash; + EXPECT_EQ(loaded, saved) << "in_flash=" << in_flash; + } +} + +TEST_F(EntityPreferenceTest, InFlashOverloadStillAppliesTheVersion) { + TestEntity entity; + entity.set_test_id(0x6002); + const uint32_t saved = 42; + auto v1 = entity.make_entity_preference(1, true); + ASSERT_TRUE(v1.save(&saved)); + global_preferences->sync(); + + uint32_t loaded = 0; + auto v2 = entity.make_entity_preference(2, true); + EXPECT_FALSE(v2.load(&loaded)) << "a different version must give a different key"; +} + +} // namespace esphome::host::testing +#endif // USE_HOST From 54a28b3e3b55bde5337a302695942845d1f6f10c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:37:48 -1000 Subject: [PATCH 096/179] [bl0906] Read stale receive bytes instead of spinning on flush() (#21035) --- esphome/components/bl0906/bl0906.cpp | 4 +- .../fixtures/uart_mock_bl0906_stray_rx.yaml | 64 +++++++++++++++++++ .../test_uart_mock_bl0906_stray_rx.py | 57 +++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml create mode 100644 tests/integration/test_uart_mock_bl0906_stray_rx.py diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 38edb6ed919..35f663ffa0c 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -20,7 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data) void BL0906::loop() { while (this->available()) - this->flush(); + this->read(); if (this->current_stage_ == STAGE_IDLE) { // Woken up between cycles to drain the action queue. Go back to sleep. @@ -92,7 +92,7 @@ void BL0906::advance_stage_() { void BL0906::setup() { while (this->available()) - this->flush(); + this->read(); this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE)); // Calibration (1: register address; 2: value before calibration; 3: value after calibration) this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1 diff --git a/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml new file mode 100644 index 00000000000..d5f8a7837ce --- /dev/null +++ b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml @@ -0,0 +1,64 @@ +esphome: + name: uart-mock-bl0906-stray-rx + +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 DEPENDENCIES = ["uart"] +uart: + baud_rate: 9600 + port: /dev/null + +# Answers register reads; the first temperature answer has one stray trailing byte +uart_mock: + id: mock_uart + baud_rate: 19200 + on_tx: + - then: + - lambda: |- + // Command and register address arrive as separate one-byte writes + static bool read_pending = false; + static bool stray_sent = false; + if (data.size() != 1) + return; + if (!read_pending) { + read_pending = data[0] == 0x35; + return; + } + read_pending = false; + const uint8_t address = data[0]; + uint32_t raw; + switch (address) { + case 0x5E: // temperature: (300 - 64) * 12.5 / 59 - 40 = 10 C + raw = 300; + break; + case 0x16: // voltage + raw = 0x2A1B3C; + break; + default: + return; + } + const uint8_t l = raw & 0xFF, m = (raw >> 8) & 0xFF, h = (raw >> 16) & 0xFF; + std::vector reply{l, m, h, static_cast((address + l + m + h) ^ 0xFF)}; + if (address == 0x5E && !stray_sent) { + stray_sent = true; + reply.push_back(0xAA); + } + id(mock_uart).inject_to_rx_buffer(reply); + +sensor: + - platform: bl0906 + uart_id: mock_uart + update_interval: 1s + temperature: + name: Temperature + voltage: + name: Voltage diff --git a/tests/integration/test_uart_mock_bl0906_stray_rx.py b/tests/integration/test_uart_mock_bl0906_stray_rx.py new file mode 100644 index 00000000000..5f8a635c7f5 --- /dev/null +++ b/tests/integration/test_uart_mock_bl0906_stray_rx.py @@ -0,0 +1,57 @@ +"""A BL0906 that leaves a stray byte in the receive buffer keeps reading.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import EntityState, SensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_bl0906_stray_rx( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A stray received byte does not stop the read cycle.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + lines: list[str] = [] + states: dict[int, EntityState] = {} + + async with ( + run_compiled(yaml_config, line_callback=lines.append), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + temperature = require_entity(entities, "temperature", SensorInfo) + voltage = require_entity(entities, "voltage", SensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity: SensorInfo) -> float | None: + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + try: + async with asyncio.timeout(10.0): + while state_of(voltage) is None: + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"No voltage reading; log: {lines[-20:]}") + + assert any("RX inject 5 bytes" in x for x in lines), "stray byte not sent" + assert state_of(temperature) == pytest.approx(10.0, abs=0.01) + uref = 1.097 * 100000 / (13162 * 100 * 1000) + assert state_of(voltage) == pytest.approx(0x2A1B3C * uref, rel=1e-5) + assert not any("Junk on wire" in x for x in lines) From 0922df9bb3ee60c0c698dd8f81980d67087ed7a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:39:02 -1000 Subject: [PATCH 097/179] [uart] Add discard_available to drop stale receive bytes (#21029) --- esphome/components/uart/uart.cpp | 7 ++++ esphome/components/uart/uart.h | 3 ++ tests/components/uart/uart_read_available.cpp | 38 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index 56b8e6401e8..1c545af8956 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -33,6 +33,13 @@ void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UART } } +void UARTDevice::discard_available() { + UARTComponent *parent = this->parent_; + uint8_t byte; + while (parent->available() && parent->read_byte(&byte)) { + } +} + const LogString *parity_to_str(UARTParityOptions parity) { return UARTParityStrings::get_log_str(static_cast(parity), UARTParityStrings::LAST_INDEX); } diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index a0840aa26a7..e8f98304bc9 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -90,6 +90,9 @@ class UARTDevice { count); } + /// Read and drop every byte waiting in the receive buffer. + void discard_available(); + UARTFlushResult flush() { return this->parent_->flush(); } // Compat APIs diff --git a/tests/components/uart/uart_read_available.cpp b/tests/components/uart/uart_read_available.cpp index 10c3e7fa0ef..6adc0b475df 100644 --- a/tests/components/uart/uart_read_available.cpp +++ b/tests/components/uart/uart_read_available.cpp @@ -141,4 +141,42 @@ TEST(UARTReadAvailableTest, ChunksWithCountStopEarly) { EXPECT_EQ(uart.remaining(), 14u); } +TEST(UARTReadAvailableTest, DiscardDropsEveryWaitingByte) { + ::testing::NiceMock uart(make_bytes(5)); + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_EQ(uart.remaining(), 0u); +} + +TEST(UARTReadAvailableTest, DiscardWithNothingAvailableDoesNotRead) { + ::testing::NiceMock uart(std::vector{}); + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_TRUE(uart.read_sizes.empty()); +} + +TEST(UARTReadAvailableTest, DiscardDropsBytesThatArriveWhileDraining) { + ::testing::NiceMock uart(std::vector{1, 2}); + bool appended = false; + ON_CALL(uart, available()).WillByDefault(Invoke([&]() -> size_t { + if (!appended && uart.read_sizes.size() == 1) { + uart.append({9, 9}); + appended = true; + } + return uart.remaining(); + })); + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_TRUE(appended); + EXPECT_EQ(uart.remaining(), 0u); +} + +TEST(UARTReadAvailableTest, DiscardStopsOnReadFailure) { + ::testing::NiceMock uart(make_bytes(5)); + uart.fail_after_reads = 2; + UARTDevice dev(&uart); + dev.discard_available(); + EXPECT_EQ(uart.read_sizes.size(), 3u); +} + } // namespace esphome::uart::testing From 3665bcc4e37d3fe43c34ebafa278d743f597449b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:40:35 -1000 Subject: [PATCH 098/179] [ble_device_base][ble_client][modbus] Add new_ble_device_component, new_ble_node_component and new_modbus_client_component (#21025) --- esphome/components/ble_client/__init__.py | 10 +++++++++ .../components/ble_device_base/__init__.py | 10 ++++++++- esphome/components/modbus/__init__.py | 9 +++++++- .../components/test_new_bus_component.py | 22 +++++++++++++++++-- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index b783ef1c153..64dd02c4dc0 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Coroutine +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -171,6 +174,13 @@ async def register_ble_node(var, config): cg.add(parent.register_ble_node(var)) +def new_ble_node_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Construct, register and attach to the BLE client; args go to the constructor.""" + return cg.new_bus_component(register_ble_node, config, *args) + + BLE_WRITE_ACTION_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(BLEClient), diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index e8283457c6c..9f089034143 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -24,8 +24,9 @@ AES-CCM decryption for encrypted advertisements is provided portably in ble_aes_ccm.h. """ -from collections.abc import Callable +from collections.abc import Callable, Coroutine import re +from typing import Any import esphome.codegen as cg from esphome.components.const import CONF_WINDOW @@ -182,6 +183,13 @@ async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj return var +def new_ble_device_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, cg.MockObj]: + """Construct, register and attach to the BLE hub; args go to the constructor.""" + return cg.new_bus_component(register_ble_device, config, *args) + + # ---- shared validation / codegen helpers (platform-neutral) ---- diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 4e4d7fe1608..2c3aba950fe 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import logging from typing import Any, Literal, NamedTuple @@ -390,6 +390,13 @@ async def register_modbus_client_device(var: MockObj, config: ConfigType) -> Non cg.add(var.set_address(config[CONF_ADDRESS])) +def new_modbus_client_component( + config: ConfigType, *args: Any +) -> Coroutine[Any, Any, MockObj]: + """Construct, register and attach to the modbus bus; args go to the constructor.""" + return cg.new_bus_component(register_modbus_client_device, config, *args) + + async def register_modbus_server_device(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MODBUS_ID]) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/tests/unit_tests/components/test_new_bus_component.py b/tests/unit_tests/components/test_new_bus_component.py index 54f947d6257..2fa5c67b24e 100644 --- a/tests/unit_tests/components/test_new_bus_component.py +++ b/tests/unit_tests/components/test_new_bus_component.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from esphome import cpp_helpers -from esphome.components import i2c, spi, uart +from esphome.components import ble_client, ble_device_base, i2c, modbus, spi, uart from esphome.const import CONF_ID @@ -18,6 +18,17 @@ from esphome.const import CONF_ID (i2c.new_i2c_component, i2c, "register_i2c_device"), (uart.new_uart_component, uart, "register_uart_device"), (spi.new_spi_component, spi, "register_spi_device"), + ( + ble_device_base.new_ble_device_component, + ble_device_base, + "register_ble_device", + ), + (ble_client.new_ble_node_component, ble_client, "register_ble_node"), + ( + modbus.new_modbus_client_component, + modbus, + "register_modbus_client_device", + ), ], ) @pytest.mark.asyncio @@ -47,7 +58,14 @@ async def test_creates_registers_and_attaches_in_order( @pytest.mark.parametrize( "helper", - [i2c.new_i2c_component, uart.new_uart_component, spi.new_spi_component], + [ + i2c.new_i2c_component, + uart.new_uart_component, + spi.new_spi_component, + ble_device_base.new_ble_device_component, + ble_client.new_ble_node_component, + modbus.new_modbus_client_component, + ], ) def test_wrappers_are_plain_functions(helper: Callable[..., Any]) -> None: # An async wrapper would stay on the stack and become the component source. From 270d9894811b31957c0a7f689ba09393dca83290 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:41:33 -1000 Subject: [PATCH 099/179] [core] Add cg.add_pin and cg.add_variable for optional pins and ids (#21018) --- AGENTS.md | 4 +++ esphome/codegen.py | 2 ++ esphome/cpp_helpers.py | 22 ++++++++++++++- tests/unit_tests/test_cpp_helpers.py | 40 +++++++++++++++++++++++++++- 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5581a255dba..b2d3b442bf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -398,6 +398,10 @@ file does, and it is the authority when they disagree. The most useful starting await buttons(CONF_RESTART, var.set_restart_button) ``` + - **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])` + and `await cg.add_variable(var.set_bus, config[CONF_BUS_ID])`; both skip a `None` value, so pass + `config.get(KEY)` for an optional key, and return what they created. Use `cg.register_parented` for `set_parent`. + * **Automations (Triggers, Actions, Conditions):** Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true). diff --git a/esphome/codegen.py b/esphome/codegen.py index 64f2ca9f04d..cf569b7bff7 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -53,6 +53,8 @@ from esphome.cpp_generator import ( # noqa: F401 with_local_variable, ) from esphome.cpp_helpers import ( # noqa: F401 + add_pin, + add_variable, build_registry_entry, build_registry_list, extract_registry_entry_config, diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index c744b09728e..ba26d14bc98 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -24,7 +24,7 @@ from esphome.cpp_generator import ( ) from esphome.cpp_types import App from esphome.helpers import cpp_string_escape -from esphome.types import ConfigFragmentType, ConfigType +from esphome.types import ConfigFragmentType, ConfigType, Expression from esphome.util import Registry, RegistryEntry _LOGGER = logging.getLogger(__name__) @@ -231,6 +231,26 @@ async def gpio_pin_expression(conf): return await coroutine(pins.PIN_SCHEMA_REGISTRY[CORE.target_platform][0])(conf) +async def add_pin( + setter: Callable[[MockObj], Expression], pin_config: ConfigType | None +) -> MockObj | None: + """Pass the pin built from ``pin_config`` to ``setter`` and return it; skip None.""" + if (pin := await gpio_pin_expression(pin_config)) is not None: + add(setter(pin)) + return pin + + +async def add_variable( + setter: Callable[[MockObj], Expression], id_: ID | None +) -> MockObj | None: + """Pass the variable declared by ``id_`` to ``setter`` and return it; skip None.""" + if id_ is None: + return None + var = await get_variable(id_) + add(setter(var)) + return var + + def set_setup_priority(var, priority: float) -> None: """Emit a setup-priority override for the given component. diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 7f56faddfbc..4c3a425ae3e 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -1,9 +1,11 @@ +from collections.abc import Awaitable, Callable import importlib.util import logging from pathlib import Path import sys from types import ModuleType -from unittest.mock import Mock +from typing import Any +from unittest.mock import AsyncMock, Mock import pytest @@ -301,3 +303,39 @@ async def test_new_bus_component_source_is_the_caller( result = await dev.to_code(bus.new_component, {const.CONF_ID: "foo.bar"}) assert result is var source.assert_called_once_with(expected) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("helper", [ch.add_pin, ch.add_variable]) +async def test_add_helpers_skip_none( + monkeypatch: pytest.MonkeyPatch, helper: Callable[..., Awaitable[Any]] +) -> None: + add_mock = Mock() + monkeypatch.setattr(ch, "add", add_mock) + setter = Mock() + + assert await helper(setter, None) is None + setter.assert_not_called() + add_mock.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("helper", "lookup"), + [("add_pin", "gpio_pin_expression"), ("add_variable", "get_variable")], +) +async def test_add_helpers_pass_value_to_setter( + monkeypatch: pytest.MonkeyPatch, helper: str, lookup: str +) -> None: + value = Mock() + lookup_mock = AsyncMock(return_value=value) + monkeypatch.setattr(ch, lookup, lookup_mock) + add_mock = Mock() + monkeypatch.setattr(ch, "add", add_mock) + setter = Mock() + arg = Mock() + + assert await getattr(ch, helper)(setter, arg) is value + lookup_mock.assert_awaited_once_with(arg) + setter.assert_called_once_with(value) + add_mock.assert_called_once_with(setter.return_value) From 2a416fbce6b674f855fe99bc98d9ffe6b0a9dec0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:45:21 -1000 Subject: [PATCH 100/179] [core] Document that hub entities go in platforms in AGENTS.md (#21003) --- AGENTS.md | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b2d3b442bf6..c165ddc350f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,7 +287,8 @@ file does, and it is the authority when they disagree. The most useful starting `from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other metadata constants. CI (`lint_component_domain`) fails without it. - `DEPENDENCIES`: List of required components - - `AUTO_LOAD`: Components to automatically load + - `AUTO_LOAD`: Components to automatically load. Not a way to put entities under the component's + own key; see "Entities of a hub" below. - `CONFLICTS_WITH`: Incompatible components - `CODEOWNERS`: GitHub usernames responsible for maintenance - `MULTI_CONF`: Whether multiple instances are allowed @@ -379,6 +380,26 @@ file does, and it is the authority when they disagree. The most useful starting var = await switch.new_switch(config) ``` + - **Entities of a hub:** a new component's entities go in platform files (`sensor.py`, + `binary_sensor.py`, ...) that take the hub's id, never under the hub's own key with their platforms + added to `AUTO_LOAD` to make that work. Auto loading a platform because the C++ uses its type is + fine. See https://developers.esphome.io/architecture/components/#exposing-multiple-entities. + ```yaml + # Bad - entities under the hub's key + my_hub: + connected: + name: Connected + + # Good - the hub, then one platform entry that holds its entities + my_hub: + id: hub1 + binary_sensor: + - platform: my_hub + my_hub_id: hub1 # optional with a single hub + connected: + name: Connected + ``` + - **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or `sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`, `sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented`, @@ -389,13 +410,19 @@ file does, and it is the authority when they disagree. The most useful starting itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are. ```python + # my_hub/sensor.py async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_MY_HUB_ID]) sensors = sensor.sub_sensors(config) - await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) - await sensors(CONF_HUMIDITY, var.set_humidity_sensor) - buttons = button.sub_buttons(config, parent=var) - await buttons(CONF_RESTART, var.set_restart_button) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_HUMIDITY, hub.set_humidity_sensor) + + + # my_hub/button.py + async def to_code(config): + hub = await cg.get_variable(config[CONF_MY_HUB_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_RESTART, hub.set_restart_button) ``` - **Pins and referenced components:** set them with `await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN])` From 0cd1ae01f120572f4a26a90e9b89ac8c82a89fee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:52:14 -1000 Subject: [PATCH 101/179] [components] Use cv.uint8_t and cv.uint16_t for full byte ranges (#21015) --- esphome/components/am43/cover/__init__.py | 2 +- esphome/components/as7341/sensor.py | 2 +- esphome/components/as734x/__init__.py | 2 +- esphome/components/grove_tb6612fng/__init__.py | 2 +- esphome/components/hub75/display.py | 4 ++-- esphome/components/lc709203f/sensor.py | 2 +- esphome/components/ltr501/sensor.py | 8 ++------ esphome/components/ltr_als_ps/sensor.py | 8 ++------ esphome/components/lvgl/lv_validation.py | 4 ++-- esphome/components/lvgl/widgets/animimg.py | 2 +- esphome/components/micronova/sensor/__init__.py | 4 +--- esphome/components/mipi/__init__.py | 2 +- esphome/components/mixer/speaker/__init__.py | 4 +--- esphome/components/nextion/base_component.py | 4 ++-- esphome/components/opentherm/schema.py | 8 ++++---- esphome/components/sen6x/sensor.py | 4 +--- esphome/components/sx127x/__init__.py | 2 +- esphome/components/template/text/__init__.py | 4 ++-- esphome/components/tmc22xx/__init__.py | 2 +- .../components/waveshare_io_ch32v003/output/__init__.py | 8 ++------ esphome/components/wled/__init__.py | 2 +- 21 files changed, 31 insertions(+), 49 deletions(-) diff --git a/esphome/components/am43/cover/__init__.py b/esphome/components/am43/cover/__init__.py index d1783b77df9..5257866a885 100644 --- a/esphome/components/am43/cover/__init__.py +++ b/esphome/components/am43/cover/__init__.py @@ -19,7 +19,7 @@ CONFIG_SCHEMA = ( cover.cover_schema(Am43Component) .extend( { - cv.Optional(CONF_PIN, default=8888): cv.int_range(min=0, max=0xFFFF), + cv.Optional(CONF_PIN, default=8888): cv.uint16_t, cv.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, } ) diff --git a/esphome/components/as7341/sensor.py b/esphome/components/as7341/sensor.py index 65307def3d0..9f5b4be89e3 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -73,7 +73,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_CLEAR): SENSOR_SCHEMA, cv.Optional(CONF_NIR): SENSOR_SCHEMA, cv.Optional(CONF_GAIN, default="X8"): cv.enum(GAIN_OPTIONS), - cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255), + cv.Optional(CONF_ATIME, default=29): cv.uint8_t, cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534), } ) diff --git a/esphome/components/as734x/__init__.py b/esphome/components/as734x/__init__.py index dc45ddd1308..e82be0ea66f 100644 --- a/esphome/components/as734x/__init__.py +++ b/esphome/components/as734x/__init__.py @@ -127,7 +127,7 @@ def _schema( { cv.GenerateID(): cv.declare_id(AS734XComponent), cv.Optional(CONF_GAIN, default="X8"): cv.enum(gains), - cv.Optional(CONF_ATIME, default=29): cv.int_range(min=0, max=255), + cv.Optional(CONF_ATIME, default=29): cv.uint8_t, cv.Optional(CONF_ASTEP, default=599): cv.int_range(min=0, max=65534), cv.Optional(CONF_CALIBRATION, default={}): _calibration_schema(bands), cv.Optional(CONF_NORMALIZE_BASIC_COUNTS, default="NONE"): cv.enum( diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index 501071f42b9..3bb4095ba17 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -47,7 +47,7 @@ automation.register_apply_action( { cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), - cv.Required(CONF_SPEED): cv.templatable(cv.int_range(min=0, max=255)), + cv.Required(CONF_SPEED): cv.templatable(cv.uint8_t), cv.Required(CONF_DIRECTION): cv.enum(DIRECTION_TYPE, upper=True), } ), diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index b6aa35b8a7b..e63b94d3833 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -397,7 +397,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SHIFT_DRIVER): cv.enum(SHIFT_DRIVERS, upper=True), # Display configuration cv.Optional(CONF_DOUBLE_BUFFER): cv.boolean, - cv.Optional(CONF_BRIGHTNESS): cv.int_range(min=0, max=255), + cv.Optional(CONF_BRIGHTNESS): cv.uint8_t, cv.Optional(CONF_BIT_DEPTH): cv.int_range(min=4, max=12), cv.Optional(CONF_GAMMA_CORRECT): cv.enum( {"LINEAR": 0, "CIE1931": 1, "GAMMA_2_2": 2}, upper=True @@ -610,7 +610,7 @@ automation.register_apply_action( cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HUB75Display), - cv.Required(CONF_BRIGHTNESS): cv.templatable(cv.int_range(min=0, max=255)), + cv.Required(CONF_BRIGHTNESS): cv.templatable(cv.uint8_t), }, key=CONF_BRIGHTNESS, ), diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 8ebb14d2ca7..a5b02f22242 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -60,7 +60,7 @@ CONFIG_SCHEMA = ( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend( { - cv.Required(CONF_B_CONSTANT): cv.int_range(0, 0xFFFF), + cv.Required(CONF_B_CONSTANT): cv.uint16_t, } ), } diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index ca9416f924f..a46b312bd07 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -138,12 +138,8 @@ CONFIG_SCHEMA = cv.All( CONF_PS_COOLDOWN, default="5s" ): cv.positive_time_period_seconds, cv.Optional(CONF_PS_GAIN, default="1X"): cv.enum(PS_GAINS, upper=True), - cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.int_range( - min=0, max=65535 - ), - cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.int_range( - min=0, max=65535 - ), + cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.uint16_t, + cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.uint16_t, cv.Optional(CONF_ON_PS_HIGH_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_ON_PS_LOW_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_AMBIENT_LIGHT): cv.maybe_simple_value( diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 5ebaed1f249..0deb01c2830 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -133,12 +133,8 @@ CONFIG_SCHEMA = cv.All( CONF_PS_COOLDOWN, default="5s" ): cv.positive_time_period_seconds, cv.Optional(CONF_PS_GAIN, default="16X"): cv.enum(PS_GAINS, upper=True), - cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.int_range( - min=0, max=65535 - ), - cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.int_range( - min=0, max=65535 - ), + cv.Optional(CONF_PS_HIGH_THRESHOLD, default=65535): cv.uint16_t, + cv.Optional(CONF_PS_LOW_THRESHOLD, default=0): cv.uint16_t, cv.Optional(CONF_ON_PS_HIGH_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_ON_PS_LOW_THRESHOLD): automation.validate_automation({}), cv.Optional(CONF_AMBIENT_LIGHT): cv.maybe_simple_value( diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index d8a96de639a..646e472e7fe 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -236,7 +236,7 @@ def id_name(value): def stop_value(value): - return cv.int_range(0, 255)(value) + return cv.uint8_t(value) def _image_validator(value): @@ -398,7 +398,7 @@ lv_brightness = LValidator( def _percentage_validator(value): - value = cv.Any(cv.percentage, cv.float_range(0.0, 1.0), cv.int_range(0, 255))(value) + value = cv.Any(cv.percentage, cv.float_range(0.0, 1.0), cv.uint8_t)(value) if isinstance(value, int): return value / 255.0 return value diff --git a/esphome/components/lvgl/widgets/animimg.py b/esphome/components/lvgl/widgets/animimg.py index b6d59df7f25..8540b3f5529 100644 --- a/esphome/components/lvgl/widgets/animimg.py +++ b/esphome/components/lvgl/widgets/animimg.py @@ -17,7 +17,7 @@ CONF_ANIMIMG = "animimg" def lv_repeat_count(value): if isinstance(value, str) and value.lower() in ("forever", "infinite"): value = 0xFFFF - return cv.int_range(min=0, max=0xFFFF)(value) + return cv.uint16_t(value) ANIMIMG_BASE_SCHEMA = cv.Schema( diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index 6091718d658..7062e436ce4 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -86,9 +86,7 @@ CONFIG_SCHEMA = cv.Schema( is_polling_component=True, ) ) - .extend( - {cv.Optional(CONF_FAN_RPM_OFFSET, default=0): cv.int_range(min=0, max=255)} - ), + .extend({cv.Optional(CONF_FAN_RPM_OFFSET, default=0): cv.uint8_t}), cv.Optional(CONF_WATER_TEMPERATURE): sensor.sensor_schema( MicroNovaSensor, unit_of_measurement=UNIT_CELSIUS, diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index b2351e23a44..4db1ebdc5ed 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -235,7 +235,7 @@ def map_sequence(value): cv.Range(TimePeriod(milliseconds=1), TimePeriod(milliseconds=255)), )(value) return DELAY_FLAG, delay_value.total_milliseconds - value = cv.All(cv.ensure_list(cv.int_range(0, 255)), cv.Length(1, 254))(value) + value = cv.All(cv.ensure_list(cv.uint8_t), cv.Length(1, 254))(value) return tuple(value) diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 44473b12c32..9d0a55ebb97 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -147,9 +147,7 @@ automation.register_apply_action( cv.Schema( { cv.GenerateID(): cv.use_id(SourceSpeaker), - cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable( - cv.int_range(min=0, max=255) - ), + cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable(cv.uint8_t), cv.Optional(CONF_DURATION, default="0.0s"): cv.templatable( cv.positive_time_period_milliseconds ), diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 74a50a95d4c..17c1b263ed8 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -72,7 +72,7 @@ CONFIG_TEXT_COMPONENT_SCHEMA = CONFIG_BASE_COMPONENT_SCHEMA.extend( cv.Schema( { cv.Required(CONF_COMPONENT_NAME): NextionName, - cv.Optional(CONF_FONT_ID): cv.int_range(min=0, max=255), + cv.Optional(CONF_FONT_ID): cv.uint8_t, } ) ) @@ -89,7 +89,7 @@ CONFIG_BINARY_SENSOR_SCHEMA = CONFIG_BASE_COMPONENT_SCHEMA.extend( CONFIG_SENSOR_COMPONENT_SCHEMA = CONFIG_BINARY_SENSOR_SCHEMA.extend( cv.Schema( { - cv.Optional(CONF_FONT_ID): cv.int_range(min=0, max=255), + cv.Optional(CONF_FONT_ID): cv.uint8_t, } ) ) diff --git a/esphome/components/opentherm/schema.py b/esphome/components/opentherm/schema.py index 7f3ea2df36d..5c735c4aa81 100644 --- a/esphome/components/opentherm/schema.py +++ b/esphome/components/opentherm/schema.py @@ -844,7 +844,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_hb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=1, ), @@ -854,7 +854,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_lb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=1, ), @@ -874,7 +874,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_hb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=5, ), @@ -884,7 +884,7 @@ SETTINGS: dict[str, SettingSchema] = { keep_updated=False, message_data="u8_lb", backing_type="uint8_t", - validation_schema=cv.int_range(min=0, max=255), + validation_schema=cv.uint8_t, default_value=0, order=5, ), diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 953b6529ef6..b928f97774e 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -233,9 +233,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_NORMALIZED_OFFSET_SLOPE, default=0 ): cv.float_range(min=-3.2768, max=3.2767), - cv.Optional(CONF_TIME_CONSTANT, default=0): cv.int_range( - min=0, max=65535 - ), + cv.Optional(CONF_TIME_CONSTANT, default=0): cv.uint16_t, } ), cv.Optional(CONF_TEMPERATURE_ACCELERATION): cv.Schema( diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 08660415a62..3bd3b35a2ed 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -205,7 +205,7 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_PREAMBLE_POLARITY, default=0xAA): cv.All( cv.hex_int, cv.one_of(0xAA, 0x55) ), - cv.Optional(CONF_PREAMBLE_SIZE, default=0): cv.int_range(min=0, max=65535), + cv.Optional(CONF_PREAMBLE_SIZE, default=0): cv.uint16_t, cv.Required(CONF_RST_PIN): pins.internal_gpio_output_pin_schema, cv.Optional(CONF_RX_FLOOR, default=-94): cv.float_range(min=-128, max=-1), cv.Optional(CONF_RX_START, default=True): cv.boolean, diff --git a/esphome/components/template/text/__init__.py b/esphome/components/template/text/__init__.py index 1266370cb21..68e6eadd7b8 100644 --- a/esphome/components/template/text/__init__.py +++ b/esphome/components/template/text/__init__.py @@ -51,8 +51,8 @@ CONFIG_SCHEMA = cv.All( text.text_schema(TemplateText) .extend( { - cv.Optional(CONF_MIN_LENGTH, default=0): cv.int_range(min=0, max=255), - cv.Optional(CONF_MAX_LENGTH, default=255): cv.int_range(min=0, max=255), + cv.Optional(CONF_MIN_LENGTH, default=0): cv.uint8_t, + cv.Optional(CONF_MAX_LENGTH, default=255): cv.uint8_t, cv.Optional(CONF_PATTERN): cv.string, cv.Optional(CONF_LAMBDA): cv.returning_lambda, cv.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index a3ac518d8eb..1018443e002 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -242,7 +242,7 @@ def register_actions(domain: str, cls: MockObjClass) -> None: ), cv.Exclusive(CONF_IHOLD, "hold"): cv.templatable(cv.int_range(0, 31)), cv.Optional(CONF_IHOLDDELAY): cv.templatable(cv.int_range(0, 15)), - cv.Optional(CONF_TPOWERDOWN): cv.templatable(cv.int_range(0, 255)), + cv.Optional(CONF_TPOWERDOWN): cv.templatable(cv.uint8_t), cv.Optional(CONF_STANDSTILL_MODE): cv.templatable( cv.enum(STANDSTILL_MODES, lower=True) ), diff --git a/esphome/components/waveshare_io_ch32v003/output/__init__.py b/esphome/components/waveshare_io_ch32v003/output/__init__.py index 7438769928d..5463ddcc652 100644 --- a/esphome/components/waveshare_io_ch32v003/output/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/output/__init__.py @@ -39,12 +39,8 @@ def validate_pwm_limits(config: ConfigType) -> ConfigType: CONF_SAFE_PWM_LEVELS_SCHEMA = cv.Schema( { - cv.Optional(CONF_MIN_VALUE, default=DUTY_DEFAULT_MIN): cv.int_range( - min=0, max=255 - ), - cv.Optional(CONF_MAX_VALUE, default=DUTY_DEFAULT_MAX): cv.int_range( - min=0, max=255 - ), + cv.Optional(CONF_MIN_VALUE, default=DUTY_DEFAULT_MIN): cv.uint8_t, + cv.Optional(CONF_MAX_VALUE, default=DUTY_DEFAULT_MAX): cv.uint8_t, } ) diff --git a/esphome/components/wled/__init__.py b/esphome/components/wled/__init__.py index 6b3263e6a3b..59f09c2bf6d 100644 --- a/esphome/components/wled/__init__.py +++ b/esphome/components/wled/__init__.py @@ -22,7 +22,7 @@ CONF_BLANK_ON_START = "blank_on_start" "WLED", { cv.Optional(CONF_PORT, default=21324): cv.port, - cv.Optional(CONF_SYNC_GROUP_MASK, default=0): cv.int_range(min=0, max=255), + cv.Optional(CONF_SYNC_GROUP_MASK, default=0): cv.uint8_t, cv.Optional(CONF_BLANK_ON_START, default=True): cv.boolean, }, ) From ed9616d512c3fe136ea58d2e2398162559e39c2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:10:22 -1000 Subject: [PATCH 102/179] [components] Set pins with cg.add_pin (#21019) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/a4988/stepper.py | 11 +-- esphome/components/ac_dimmer/output.py | 6 +- esphome/components/adc/sensor.py | 3 +- esphome/components/ade7880/sensor.py | 13 +--- esphome/components/ade7953_base/__init__.py | 4 +- esphome/components/apc1/sensor.py | 9 +-- esphome/components/as3935/__init__.py | 3 +- esphome/components/as5600/__init__.py | 4 +- .../axs15231/touchscreen/__init__.py | 6 +- esphome/components/bp1658cj/__init__.py | 6 +- esphome/components/bp5758d/__init__.py | 6 +- esphome/components/cap1188/__init__.py | 4 +- esphome/components/cc1101/__init__.py | 4 +- esphome/components/cd74hc4067/__init__.py | 12 +-- .../cdc_acm_uart/bridge/__init__.py | 8 +- esphome/components/chsc6x/touchscreen.py | 3 +- .../components/cst226/touchscreen/__init__.py | 6 +- .../components/cst328/touchscreen/__init__.py | 6 +- .../components/cst816/touchscreen/__init__.py | 6 +- .../cst9220/touchscreen/__init__.py | 6 +- esphome/components/deep_sleep/__init__.py | 3 +- esphome/components/dht/sensor.py | 4 +- esphome/components/ds248x/__init__.py | 4 +- esphome/components/duty_cycle/sensor.py | 3 +- .../ektf2232/touchscreen/__init__.py | 6 +- esphome/components/epaper_spi/display.py | 11 +-- esphome/components/esp32_dac/output.py | 3 +- esphome/components/esp8266_pwm/output.py | 3 +- .../components/fingerprint_grow/__init__.py | 9 +-- .../components/ft5x06/touchscreen/__init__.py | 4 +- esphome/components/ft63x6/touchscreen.py | 8 +- .../components/gpio/binary_sensor/__init__.py | 3 +- esphome/components/gpio/one_wire/__init__.py | 3 +- esphome/components/gpio/output/__init__.py | 3 +- esphome/components/gpio/switch/__init__.py | 3 +- esphome/components/gsl3670/touchscreen.py | 9 +-- .../components/gt911/touchscreen/__init__.py | 6 +- esphome/components/hbridge/switch/__init__.py | 6 +- esphome/components/hlw8012/sensor.py | 9 +-- esphome/components/hx711/sensor.py | 6 +- esphome/components/icnt86/touchscreen.py | 9 +-- esphome/components/inkplate/display.py | 75 +++++-------------- esphome/components/it8951/display.py | 6 +- esphome/components/lcd_gpio/display.py | 12 +-- .../lilygo_t5_47/touchscreen/__init__.py | 3 +- esphome/components/mics_5524/sensor.py | 3 +- esphome/components/mipi_dsi/display.py | 4 +- esphome/components/mipi_rgb/display.py | 24 ++---- esphome/components/mipi_spi/display.py | 9 +-- esphome/components/mlx90393/sensor.py | 4 +- esphome/components/modbus/__init__.py | 5 +- esphome/components/my9231/__init__.py | 6 +- esphome/components/nrf52/__init__.py | 4 +- esphome/components/opentherm/__init__.py | 7 +- esphome/components/pcd8544/display.py | 6 +- esphome/components/pn7160/__init__.py | 9 +-- esphome/components/pn71xx/__init__.py | 7 +- esphome/components/power_supply/__init__.py | 3 +- esphome/components/pulse_counter/sensor.py | 3 +- esphome/components/pulse_meter/sensor.py | 3 +- esphome/components/pulse_width/sensor.py | 3 +- esphome/components/qmc5883l/sensor.py | 4 +- esphome/components/rc522/__init__.py | 4 +- esphome/components/rotary_encoder/sensor.py | 10 +-- esphome/components/rp2040_pwm/output.py | 3 +- esphome/components/serial_proxy/__init__.py | 9 +-- esphome/components/sh1122_base/__init__.py | 4 +- esphome/components/shelly_dimmer/light.py | 6 +- .../components/sigma_delta_output/output.py | 4 +- esphome/components/slow_pwm/output.py | 4 +- esphome/components/sm10bit_base/__init__.py | 6 +- esphome/components/sm16716/__init__.py | 6 +- esphome/components/sm2135/__init__.py | 6 +- esphome/components/sn74hc165/__init__.py | 13 +--- esphome/components/sn74hc595/__init__.py | 13 +--- esphome/components/spd2010/touchscreen.py | 6 +- esphome/components/spi/__init__.py | 13 +--- esphome/components/ssd1306_base/__init__.py | 4 +- esphome/components/ssd1306_spi/display.py | 3 +- esphome/components/ssd1322_base/__init__.py | 4 +- esphome/components/ssd1322_spi/display.py | 3 +- esphome/components/ssd1325_base/__init__.py | 4 +- esphome/components/ssd1325_spi/display.py | 3 +- esphome/components/ssd1327_base/__init__.py | 4 +- esphome/components/ssd1327_spi/display.py | 3 +- esphome/components/ssd1331_base/__init__.py | 4 +- esphome/components/ssd1331_spi/display.py | 3 +- esphome/components/ssd1351_base/__init__.py | 4 +- esphome/components/ssd1351_spi/display.py | 3 +- .../components/st7123/touchscreen/__init__.py | 6 +- esphome/components/st7567_base/__init__.py | 4 +- esphome/components/st7567_spi/display.py | 3 +- esphome/components/st7701s/display.py | 21 ++---- .../components/status_led/light/__init__.py | 8 +- esphome/components/sx126x/__init__.py | 10 +-- esphome/components/sx127x/__init__.py | 7 +- esphome/components/tas58xx/audio_dac.py | 4 +- esphome/components/tca8418/__init__.py | 3 +- esphome/components/tlc5947/__init__.py | 13 +--- esphome/components/tlc5971/__init__.py | 6 +- esphome/components/tm1621/display.py | 12 +-- esphome/components/tm1637/display.py | 6 +- esphome/components/tm1638/display.py | 11 +-- esphome/components/tm1651/__init__.py | 6 +- esphome/components/tmc22xx/__init__.py | 3 +- .../tt21100/touchscreen/__init__.py | 9 +-- esphome/components/ttp229_bsf/__init__.py | 6 +- esphome/components/tuya/__init__.py | 4 +- esphome/components/tuya/climate/__init__.py | 8 +- esphome/components/tx20/sensor.py | 3 +- esphome/components/uln2003/stepper.py | 12 +-- esphome/components/ultrasonic/sensor.py | 6 +- esphome/components/vl53l0x/sensor.py | 4 +- esphome/components/wiegand/__init__.py | 6 +- esphome/components/x9c/output.py | 9 +-- .../xpt2046/touchscreen/__init__.py | 4 +- esphome/components/zyaura/sensor.py | 7 +- 117 files changed, 227 insertions(+), 564 deletions(-) diff --git a/esphome/components/a4988/stepper.py b/esphome/components/a4988/stepper.py index 7a19bd550da..f97f30903a4 100644 --- a/esphome/components/a4988/stepper.py +++ b/esphome/components/a4988/stepper.py @@ -23,11 +23,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await stepper.register_stepper(var, config) - step_pin = await cg.gpio_pin_expression(config[CONF_STEP_PIN]) - cg.add(var.set_step_pin(step_pin)) - dir_pin = await cg.gpio_pin_expression(config[CONF_DIR_PIN]) - cg.add(var.set_dir_pin(dir_pin)) - - if sleep_pin_config := config.get(CONF_SLEEP_PIN): - sleep_pin = await cg.gpio_pin_expression(sleep_pin_config) - cg.add(var.set_sleep_pin(sleep_pin)) + await cg.add_pin(var.set_step_pin, config[CONF_STEP_PIN]) + await cg.add_pin(var.set_dir_pin, config[CONF_DIR_PIN]) + await cg.add_pin(var.set_sleep_pin, config.get(CONF_SLEEP_PIN)) diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index 7d1b66a6905..90d88acc2a3 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -68,10 +68,8 @@ async def to_code(config: ConfigType) -> None: config[CONF_MIN_POWER] = 0.1 await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_GATE_PIN]) - cg.add(var.set_gate_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN]) - cg.add(var.set_zero_cross_pin(pin)) + await cg.add_pin(var.set_gate_pin, config[CONF_GATE_PIN]) + await cg.add_pin(var.set_zero_cross_pin, config[CONF_ZERO_CROSS_PIN]) cg.add(var.set_zero_cross_interrupt_type(config[CONF_ZERO_CROSS_INTERRUPT_TYPE])) cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE])) cg.add(var.set_method(config[CONF_METHOD])) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index b44d88d56e3..1c10aed4a15 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -164,8 +164,7 @@ async def to_code(config: ConfigType) -> None: # Remove before 2027.2.0 cg.add(var.set_is_temperature()) elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) # Skip the setters when the config matches the C++ initializers. if config[CONF_RAW]: diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index 541d37603e1..7e01b35b73b 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -284,16 +284,9 @@ async def power_channel(config: ConfigType) -> MockObj: async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if irq0_pin := config.get(CONF_IRQ0_PIN): - pin = await cg.gpio_pin_expression(irq0_pin) - cg.add(var.set_irq0_pin(pin)) - - pin = await cg.gpio_pin_expression(config[CONF_IRQ1_PIN]) - cg.add(var.set_irq1_pin(pin)) - - if reset_pin := config.get(CONF_RESET_PIN): - pin = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_irq0_pin, config.get(CONF_IRQ0_PIN)) + await cg.add_pin(var.set_irq1_pin, config[CONF_IRQ1_PIN]) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if frequency := config.get(CONF_FREQUENCY): cg.add(var.set_frequency(frequency)) diff --git a/esphome/components/ade7953_base/__init__.py b/esphome/components/ade7953_base/__init__.py index 808de99984c..bd86c4d6d9d 100644 --- a/esphome/components/ade7953_base/__init__.py +++ b/esphome/components/ade7953_base/__init__.py @@ -169,9 +169,7 @@ ADE7953_CONFIG_SCHEMA = cv.Schema( async def register_ade7953(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - if irq_pin_config := config.get(CONF_IRQ_PIN): - irq_pin = await cg.gpio_pin_expression(irq_pin_config) - cg.add(var.set_irq_pin(irq_pin)) + await cg.add_pin(var.set_irq_pin, config.get(CONF_IRQ_PIN)) cg.add(var.set_pga_v(PGA_GAINS[config.get(CONF_VOLTAGE_PGA_GAIN)])) cg.add(var.set_pga_ia(PGA_GAINS[config.get(CONF_CURRENT_PGA_GAIN_A)])) diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py index f3f7e0203f9..61cfdc591b0 100644 --- a/esphome/components/apc1/sensor.py +++ b/esphome/components/apc1/sensor.py @@ -191,13 +191,8 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_RS3, var.set_rs3_sensor) await sensors(CONF_ERROR_CODE, var.set_error_code_sensor) - if (set_pin_config := config.get(CONF_SET_PIN)) is not None: - set_pin = await cg.gpio_pin_expression(set_pin_config) - cg.add(var.set_set_pin(set_pin)) - - if (reset_pin_config := config.get(CONF_RESET_PIN)) is not None: - reset_pin = await cg.gpio_pin_expression(reset_pin_config) - cg.add(var.set_reset_pin(reset_pin)) + await cg.add_pin(var.set_set_pin, config.get(CONF_SET_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) APC1_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/as3935/__init__.py b/esphome/components/as3935/__init__.py index b7b5b09c626..fe36d73b590 100644 --- a/esphome/components/as3935/__init__.py +++ b/esphome/components/as3935/__init__.py @@ -48,8 +48,7 @@ AS3935_SCHEMA = cv.Schema( async def setup_as3935(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - irq_pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) - cg.add(var.set_irq_pin(irq_pin)) + await cg.add_pin(var.set_irq_pin, config[CONF_IRQ_PIN]) cg.add(var.set_indoor(config[CONF_INDOOR])) cg.add(var.set_noise_level(config[CONF_NOISE_LEVEL])) cg.add(var.set_watchdog_threshold(config[CONF_WATCHDOG_THRESHOLD])) diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index d695e34235f..a118334f02c 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -218,9 +218,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_fast_filter(config[CONF_FAST_FILTER])) cg.add(var.set_start_position(config[CONF_START_POSITION])) - if dir_pin_config := config.get(CONF_DIR_PIN): - pin = await cg.gpio_pin_expression(dir_pin_config) - cg.add(var.set_dir_pin(pin)) + await cg.add_pin(var.set_dir_pin, config.get(CONF_DIR_PIN)) if (end_position_config := config.get(CONF_END_POSITION, None)) is not None: cg.add(var.set_end_position(end_position_config)) diff --git a/esphome/components/axs15231/touchscreen/__init__.py b/esphome/components/axs15231/touchscreen/__init__.py index 2616cb281bb..27f2348b6d8 100644 --- a/esphome/components/axs15231/touchscreen/__init__.py +++ b/esphome/components/axs15231/touchscreen/__init__.py @@ -31,7 +31,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/bp1658cj/__init__.py b/esphome/components/bp1658cj/__init__.py index 76b3d427a45..c2c560c7dbb 100644 --- a/esphome/components/bp1658cj/__init__.py +++ b/esphome/components/bp1658cj/__init__.py @@ -34,9 +34,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_max_power_color_channels(config[CONF_MAX_POWER_COLOR_CHANNELS])) cg.add(var.set_max_power_white_channels(config[CONF_MAX_POWER_WHITE_CHANNELS])) diff --git a/esphome/components/bp5758d/__init__.py b/esphome/components/bp5758d/__init__.py index bd6b6ca1106..b3a05268cb3 100644 --- a/esphome/components/bp5758d/__init__.py +++ b/esphome/components/bp5758d/__init__.py @@ -25,7 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) diff --git a/esphome/components/cap1188/__init__.py b/esphome/components/cap1188/__init__.py index 24f6e627d2c..ae17b0477c4 100644 --- a/esphome/components/cap1188/__init__.py +++ b/esphome/components/cap1188/__init__.py @@ -39,9 +39,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_touch_threshold(config[CONF_TOUCH_THRESHOLD])) cg.add(var.set_allow_multiple_touches(config[CONF_ALLOW_MULTIPLE_TOUCHES])) - if reset_pin_config := config.get(CONF_RESET_PIN): - pin = await cg.gpio_pin_expression(reset_pin_config) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index 5375d6bf396..4f2ca2ad266 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -281,9 +281,7 @@ async def to_code(config): if key in config: cg.add(getattr(var, f"set_{key}")(config[key])) - if CONF_GDO0_PIN in config: - gdo0_pin = await cg.gpio_pin_expression(config[CONF_GDO0_PIN]) - cg.add(var.set_gdo0_pin(gdo0_pin)) + await cg.add_pin(var.set_gdo0_pin, config.get(CONF_GDO0_PIN)) if CONF_ON_PACKET in config: await automation.build_automation( var.get_packet_trigger(), diff --git a/esphome/components/cd74hc4067/__init__.py b/esphome/components/cd74hc4067/__init__.py index 655b8db819d..6eb4fe8248e 100644 --- a/esphome/components/cd74hc4067/__init__.py +++ b/esphome/components/cd74hc4067/__init__.py @@ -39,13 +39,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin_s0 = await cg.gpio_pin_expression(config[CONF_PIN_S0]) - cg.add(var.set_pin_s0(pin_s0)) - pin_s1 = await cg.gpio_pin_expression(config[CONF_PIN_S1]) - cg.add(var.set_pin_s1(pin_s1)) - pin_s2 = await cg.gpio_pin_expression(config[CONF_PIN_S2]) - cg.add(var.set_pin_s2(pin_s2)) - pin_s3 = await cg.gpio_pin_expression(config[CONF_PIN_S3]) - cg.add(var.set_pin_s3(pin_s3)) + await cg.add_pin(var.set_pin_s0, config[CONF_PIN_S0]) + await cg.add_pin(var.set_pin_s1, config[CONF_PIN_S1]) + await cg.add_pin(var.set_pin_s2, config[CONF_PIN_S2]) + await cg.add_pin(var.set_pin_s3, config[CONF_PIN_S3]) cg.add(var.set_switch_delay(config[CONF_DELAY])) diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py index 133ea92a483..40d44747430 100644 --- a/esphome/components/cdc_acm_uart/bridge/__init__.py +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -93,9 +93,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], uart_component, usb_cdc) await cg.register_component(var, config) - if dtr_pin_config := config.get(CONF_DTR_PIN): - dtr_pin = await cg.gpio_pin_expression(dtr_pin_config) - cg.add(var.set_dtr_pin(dtr_pin)) - if rts_pin_config := config.get(CONF_RTS_PIN): - rts_pin = await cg.gpio_pin_expression(rts_pin_config) - cg.add(var.set_rts_pin(rts_pin)) + await cg.add_pin(var.set_dtr_pin, config.get(CONF_DTR_PIN)) + await cg.add_pin(var.set_rts_pin, config.get(CONF_RTS_PIN)) diff --git a/esphome/components/chsc6x/touchscreen.py b/esphome/components/chsc6x/touchscreen.py index de974d2a79d..f97feba3c51 100644 --- a/esphome/components/chsc6x/touchscreen.py +++ b/esphome/components/chsc6x/touchscreen.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) diff --git a/esphome/components/cst226/touchscreen/__init__.py b/esphome/components/cst226/touchscreen/__init__.py index 459cba61cd6..ddffd8720c2 100644 --- a/esphome/components/cst226/touchscreen/__init__.py +++ b/esphome/components/cst226/touchscreen/__init__.py @@ -32,7 +32,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/cst328/touchscreen/__init__.py b/esphome/components/cst328/touchscreen/__init__.py index 9bc7744b7c3..9569a7875bd 100644 --- a/esphome/components/cst328/touchscreen/__init__.py +++ b/esphome/components/cst328/touchscreen/__init__.py @@ -33,7 +33,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/cst816/touchscreen/__init__.py b/esphome/components/cst816/touchscreen/__init__.py index 029a544a911..88d5de6ad1a 100644 --- a/esphome/components/cst816/touchscreen/__init__.py +++ b/esphome/components/cst816/touchscreen/__init__.py @@ -32,7 +32,5 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) cg.add(var.set_skip_probe(config[CONF_SKIP_PROBE])) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/cst9220/touchscreen/__init__.py b/esphome/components/cst9220/touchscreen/__init__.py index 393685e67b3..15bfa64c70f 100644 --- a/esphome/components/cst9220/touchscreen/__init__.py +++ b/esphome/components/cst9220/touchscreen/__init__.py @@ -31,7 +31,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index c9a0ec2196f..7230b1e8e9c 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -379,8 +379,7 @@ async def to_code(config: ConfigType) -> None: ) ) else: - pin = await cg.gpio_pin_expression(wakeup_pins[0][CONF_PIN]) - cg.add(var.set_wakeup_pin(pin)) + await cg.add_pin(var.set_wakeup_pin, wakeup_pins[0][CONF_PIN]) if CONF_WAKEUP_PIN_MODE in config: cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE])) if CONF_RUN_DURATION in config: diff --git a/esphome/components/dht/sensor.py b/esphome/components/dht/sensor.py index da8a5775b15..d000add3013 100644 --- a/esphome/components/dht/sensor.py +++ b/esphome/components/dht/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) -from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType dht_ns = cg.esphome_ns.namespace("dht") @@ -59,8 +58,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index 02faa806f7a..623a7547d40 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -107,6 +107,4 @@ async def to_code(config: ConfigType) -> None: if CONF_ACTIVE_PULLUP_RESISTANCE in config: cg.add(var.set_val_rwpu(config[CONF_ACTIVE_PULLUP_RESISTANCE])) - if CONF_SLEEP_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_SLEEP_PIN]) - cg.add(var.set_sleep_pin(pin)) + await cg.add_pin(var.set_sleep_pin, config.get(CONF_SLEEP_PIN)) diff --git a/esphome/components/duty_cycle/sensor.py b/esphome/components/duty_cycle/sensor.py index b7aa1777c83..deb3d02d7a7 100644 --- a/esphome/components/duty_cycle/sensor.py +++ b/esphome/components/duty_cycle/sensor.py @@ -27,5 +27,4 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/ektf2232/touchscreen/__init__.py b/esphome/components/ektf2232/touchscreen/__init__.py index 7636b6993a1..8707f3b4e36 100644 --- a/esphome/components/ektf2232/touchscreen/__init__.py +++ b/esphome/components/ektf2232/touchscreen/__init__.py @@ -35,7 +35,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(interrupt_pin)) - reset_pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset_pin)) + await cg.add_pin(var.set_interrupt_pin, config[CONF_INTERRUPT_PIN]) + await cg.add_pin(var.set_reset_pin, config[CONF_RESET_PIN]) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 6a391811112..75a3426f162 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -243,20 +243,15 @@ async def to_code(config): config = await model.to_code(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) if CONF_LAMBDA in config: lambda_ = await cg.process_lambda( config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void ) cg.add(var.set_writer(lambda_)) - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) - if busy_pin := config.get(CONF_BUSY_PIN): - busy = await cg.gpio_pin_expression(busy_pin) - cg.add(var.set_busy_pin(busy)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await cg.add_pin(var.set_busy_pin, config.get(CONF_BUSY_PIN)) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] cg.add(var.set_enable_pins(enable)) diff --git a/esphome/components/esp32_dac/output.py b/esphome/components/esp32_dac/output.py index c87a9e2a1d0..7a27121a96b 100644 --- a/esphome/components/esp32_dac/output.py +++ b/esphome/components/esp32_dac/output.py @@ -49,5 +49,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index 62ec1fe003b..00b4dc563e4 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -46,8 +46,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) # Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY). if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY: diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 2ef654afc55..2007ca60471 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -146,13 +146,8 @@ async def to_code(config): new_password = config[CONF_NEW_PASSWORD] cg.add(var.set_new_password(new_password)) - if CONF_SENSING_PIN in config: - sensing_pin = await cg.gpio_pin_expression(config[CONF_SENSING_PIN]) - cg.add(var.set_sensing_pin(sensing_pin)) - - if CONF_SENSOR_POWER_PIN in config: - sensor_power_pin = await cg.gpio_pin_expression(config[CONF_SENSOR_POWER_PIN]) - cg.add(var.set_sensor_power_pin(sensor_power_pin)) + await cg.add_pin(var.set_sensing_pin, config.get(CONF_SENSING_PIN)) + await cg.add_pin(var.set_sensor_power_pin, config.get(CONF_SENSOR_POWER_PIN)) if CONF_IDLE_PERIOD_TO_SLEEP in config: idle_period_to_sleep_ms = config[CONF_IDLE_PERIOD_TO_SLEEP] diff --git a/esphome/components/ft5x06/touchscreen/__init__.py b/esphome/components/ft5x06/touchscreen/__init__.py index 3ebff693c00..b56043ec4d7 100644 --- a/esphome/components/ft5x06/touchscreen/__init__.py +++ b/esphome/components/ft5x06/touchscreen/__init__.py @@ -28,6 +28,4 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) await touchscreen.register_touchscreen(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - pin = await cg.gpio_pin_expression(interrupt_pin) - cg.add(var.set_interrupt_pin(pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) diff --git a/esphome/components/ft63x6/touchscreen.py b/esphome/components/ft63x6/touchscreen.py index 0d8537bde90..7ac7a3f3ce4 100644 --- a/esphome/components/ft63x6/touchscreen.py +++ b/esphome/components/ft63x6/touchscreen.py @@ -37,9 +37,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): - interrupt_pin = await cg.gpio_pin_expression(interrupt_pin_config) - cg.add(var.set_interrupt_pin(interrupt_pin)) - if reset_pin_config := config.get(CONF_RESET_PIN): - reset_pin = await cg.gpio_pin_expression(reset_pin_config) - cg.add(var.set_reset_pin(reset_pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 8a40e4e7325..22b7cdb520b 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -129,8 +129,7 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) if config[CONF_USE_INTERRUPT]: cg.add_define("USE_GPIO_BINARY_SENSOR_INTERRUPT") diff --git a/esphome/components/gpio/one_wire/__init__.py b/esphome/components/gpio/one_wire/__init__.py index feb8b53dff6..f2b0ca0be68 100644 --- a/esphome/components/gpio/one_wire/__init__.py +++ b/esphome/components/gpio/one_wire/__init__.py @@ -23,5 +23,4 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/gpio/output/__init__.py b/esphome/components/gpio/output/__init__.py index 645dd61f802..fc4041fdc6d 100644 --- a/esphome/components/gpio/output/__init__.py +++ b/esphome/components/gpio/output/__init__.py @@ -45,5 +45,4 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 481b8d01dad..ec826697eb1 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -33,8 +33,7 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) if (interlock := config.get(CONF_INTERLOCK)) is not None: cg.add_define("USE_GPIO_SWITCH_INTERLOCK") diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py index 703887864bd..5f0edd3d41b 100644 --- a/esphome/components/gsl3670/touchscreen.py +++ b/esphome/components/gsl3670/touchscreen.py @@ -226,13 +226,8 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if CONF_INTERRUPT_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(pin)) - - if CONF_RESET_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) # Firmware table data = _read_firmware(config) diff --git a/esphome/components/gt911/touchscreen/__init__.py b/esphome/components/gt911/touchscreen/__init__.py index fa929d4ba0d..c2fb09fb59e 100644 --- a/esphome/components/gt911/touchscreen/__init__.py +++ b/esphome/components/gt911/touchscreen/__init__.py @@ -28,7 +28,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/hbridge/switch/__init__.py b/esphome/components/hbridge/switch/__init__.py index 294be6ed5fe..5dcd8dd3d24 100644 --- a/esphome/components/hbridge/switch/__init__.py +++ b/esphome/components/hbridge/switch/__init__.py @@ -35,10 +35,8 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - on_pin = await cg.gpio_pin_expression(config[CONF_ON_PIN]) - cg.add(var.set_on_pin(on_pin)) - off_pin = await cg.gpio_pin_expression(config[CONF_OFF_PIN]) - cg.add(var.set_off_pin(off_pin)) + await cg.add_pin(var.set_on_pin, config[CONF_ON_PIN]) + await cg.add_pin(var.set_off_pin, config[CONF_OFF_PIN]) cg.add(var.set_pulse_length(config[CONF_PULSE_LENGTH])) cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) if wait_time := config.get(CONF_WAIT_TIME): diff --git a/esphome/components/hlw8012/sensor.py b/esphome/components/hlw8012/sensor.py index 007f7c53030..04357d564da 100644 --- a/esphome/components/hlw8012/sensor.py +++ b/esphome/components/hlw8012/sensor.py @@ -98,12 +98,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - sel = await cg.gpio_pin_expression(config[CONF_SEL_PIN]) - cg.add(var.set_sel_pin(sel)) - cf = await cg.gpio_pin_expression(config[CONF_CF_PIN]) - cg.add(var.set_cf_pin(cf)) - cf1 = await cg.gpio_pin_expression(config[CONF_CF1_PIN]) - cg.add(var.set_cf1_pin(cf1)) + await cg.add_pin(var.set_sel_pin, config[CONF_SEL_PIN]) + await cg.add_pin(var.set_cf_pin, config[CONF_CF_PIN]) + await cg.add_pin(var.set_cf1_pin, config[CONF_CF1_PIN]) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/hx711/sensor.py b/esphome/components/hx711/sensor.py index 2739589c663..4f4c106f4ef 100644 --- a/esphome/components/hx711/sensor.py +++ b/esphome/components/hx711/sensor.py @@ -39,8 +39,6 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - dout_pin = await cg.gpio_pin_expression(config[CONF_DOUT_PIN]) - cg.add(var.set_dout_pin(dout_pin)) - sck_pin = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_sck_pin(sck_pin)) + await cg.add_pin(var.set_dout_pin, config[CONF_DOUT_PIN]) + await cg.add_pin(var.set_sck_pin, config[CONF_CLK_PIN]) cg.add(var.set_gain(config[CONF_GAIN])) diff --git a/esphome/components/icnt86/touchscreen.py b/esphome/components/icnt86/touchscreen.py index 5d7a7386120..9a45ac4ac32 100644 --- a/esphome/components/icnt86/touchscreen.py +++ b/esphome/components/icnt86/touchscreen.py @@ -31,10 +31,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): - cg.add( - var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin_config)) - ) - - if reset_pin_config := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin_config))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index c7600d39bbc..5e90b1ff490 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -184,59 +184,22 @@ async def to_code(config: ConfigType) -> None: waveform = [element for tupl in waveform for element in tupl] cg.add(var.set_waveform(waveform, False)) - ckv = await cg.gpio_pin_expression(config[CONF_CKV_PIN]) - cg.add(var.set_ckv_pin(ckv)) - - gmod = await cg.gpio_pin_expression(config[CONF_GMOD_PIN]) - cg.add(var.set_gmod_pin(gmod)) - - gpio0_enable = await cg.gpio_pin_expression(config[CONF_GPIO0_ENABLE_PIN]) - cg.add(var.set_gpio0_enable_pin(gpio0_enable)) - - oe = await cg.gpio_pin_expression(config[CONF_OE_PIN]) - cg.add(var.set_oe_pin(oe)) - - powerup = await cg.gpio_pin_expression(config[CONF_POWERUP_PIN]) - cg.add(var.set_powerup_pin(powerup)) - - sph = await cg.gpio_pin_expression(config[CONF_SPH_PIN]) - cg.add(var.set_sph_pin(sph)) - - spv = await cg.gpio_pin_expression(config[CONF_SPV_PIN]) - cg.add(var.set_spv_pin(spv)) - - vcom = await cg.gpio_pin_expression(config[CONF_VCOM_PIN]) - cg.add(var.set_vcom_pin(vcom)) - - wakeup = await cg.gpio_pin_expression(config[CONF_WAKEUP_PIN]) - cg.add(var.set_wakeup_pin(wakeup)) - - cl = await cg.gpio_pin_expression(config[CONF_CL_PIN]) - cg.add(var.set_cl_pin(cl)) - - le = await cg.gpio_pin_expression(config[CONF_LE_PIN]) - cg.add(var.set_le_pin(le)) - - display_data_0 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_0_PIN]) - cg.add(var.set_display_data_0_pin(display_data_0)) - - display_data_1 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_1_PIN]) - cg.add(var.set_display_data_1_pin(display_data_1)) - - display_data_2 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_2_PIN]) - cg.add(var.set_display_data_2_pin(display_data_2)) - - display_data_3 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_3_PIN]) - cg.add(var.set_display_data_3_pin(display_data_3)) - - display_data_4 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_4_PIN]) - cg.add(var.set_display_data_4_pin(display_data_4)) - - display_data_5 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_5_PIN]) - cg.add(var.set_display_data_5_pin(display_data_5)) - - display_data_6 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_6_PIN]) - cg.add(var.set_display_data_6_pin(display_data_6)) - - display_data_7 = await cg.gpio_pin_expression(config[CONF_DISPLAY_DATA_7_PIN]) - cg.add(var.set_display_data_7_pin(display_data_7)) + await cg.add_pin(var.set_ckv_pin, config[CONF_CKV_PIN]) + await cg.add_pin(var.set_gmod_pin, config[CONF_GMOD_PIN]) + await cg.add_pin(var.set_gpio0_enable_pin, config[CONF_GPIO0_ENABLE_PIN]) + await cg.add_pin(var.set_oe_pin, config[CONF_OE_PIN]) + await cg.add_pin(var.set_powerup_pin, config[CONF_POWERUP_PIN]) + await cg.add_pin(var.set_sph_pin, config[CONF_SPH_PIN]) + await cg.add_pin(var.set_spv_pin, config[CONF_SPV_PIN]) + await cg.add_pin(var.set_vcom_pin, config[CONF_VCOM_PIN]) + await cg.add_pin(var.set_wakeup_pin, config[CONF_WAKEUP_PIN]) + await cg.add_pin(var.set_cl_pin, config[CONF_CL_PIN]) + await cg.add_pin(var.set_le_pin, config[CONF_LE_PIN]) + await cg.add_pin(var.set_display_data_0_pin, config[CONF_DISPLAY_DATA_0_PIN]) + await cg.add_pin(var.set_display_data_1_pin, config[CONF_DISPLAY_DATA_1_PIN]) + await cg.add_pin(var.set_display_data_2_pin, config[CONF_DISPLAY_DATA_2_PIN]) + await cg.add_pin(var.set_display_data_3_pin, config[CONF_DISPLAY_DATA_3_PIN]) + await cg.add_pin(var.set_display_data_4_pin, config[CONF_DISPLAY_DATA_4_PIN]) + await cg.add_pin(var.set_display_data_5_pin, config[CONF_DISPLAY_DATA_5_PIN]) + await cg.add_pin(var.set_display_data_6_pin, config[CONF_DISPLAY_DATA_6_PIN]) + await cg.add_pin(var.set_display_data_7_pin, config[CONF_DISPLAY_DATA_7_PIN]) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 28636e5e26e..bb0c1f10fbb 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -367,10 +367,8 @@ async def to_code(config: ConfigType) -> None: await spi.register_spi_device(var, config, write_only=False) await display.add_writer(var, config) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) - if busy_pin := config.get(CONF_BUSY_PIN): - cg.add(var.set_busy_pin(await cg.gpio_pin_expression(busy_pin))) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await cg.add_pin(var.set_busy_pin, config.get(CONF_BUSY_PIN)) if enable_pins := config.get(CONF_ENABLE_PIN): cg.add( var.set_enable_pins( diff --git a/esphome/components/lcd_gpio/display.py b/esphome/components/lcd_gpio/display.py index 0e99b471b3c..a312187c7f6 100644 --- a/esphome/components/lcd_gpio/display.py +++ b/esphome/components/lcd_gpio/display.py @@ -43,14 +43,8 @@ async def to_code(config: ConfigType) -> None: await lcd_base.setup_lcd_display(var, config) pins_ = [await cg.gpio_pin_expression(conf) for conf in config[CONF_DATA_PINS]] cg.add(var.set_data_pins(*pins_)) - enable = await cg.gpio_pin_expression(config[CONF_ENABLE_PIN]) - cg.add(var.set_enable_pin(enable)) - - rs = await cg.gpio_pin_expression(config[CONF_RS_PIN]) - cg.add(var.set_rs_pin(rs)) - - if CONF_RW_PIN in config: - rw = await cg.gpio_pin_expression(config[CONF_RW_PIN]) - cg.add(var.set_rw_pin(rw)) + await cg.add_pin(var.set_enable_pin, config[CONF_ENABLE_PIN]) + await cg.add_pin(var.set_rs_pin, config[CONF_RS_PIN]) + await cg.add_pin(var.set_rw_pin, config.get(CONF_RW_PIN)) await display.add_writer(var, config, GPIOLCDDisplay.operator("ref")) diff --git a/esphome/components/lilygo_t5_47/touchscreen/__init__.py b/esphome/components/lilygo_t5_47/touchscreen/__init__.py index 1e70f379a1a..5c68ddf6eed 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/__init__.py +++ b/esphome/components/lilygo_t5_47/touchscreen/__init__.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(interrupt_pin)) + await cg.add_pin(var.set_interrupt_pin, config[CONF_INTERRUPT_PIN]) diff --git a/esphome/components/mics_5524/sensor.py b/esphome/components/mics_5524/sensor.py index 7b80572a115..0440a4829d9 100644 --- a/esphome/components/mics_5524/sensor.py +++ b/esphome/components/mics_5524/sensor.py @@ -434,8 +434,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_warmup_time(config[CONF_WARMUP_TIME])) cg.add(var.set_preference_key(_preference_key(config))) - if (enable := config.get(CONF_ENABLE_PIN)) is not None: - cg.add(var.set_enable_pin(await cg.gpio_pin_expression(enable))) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) if (reference := config.get(CONF_AIR_REFERENCE, config.get(CONF_R0))) is not None: cg.add(var.set_reference(reference)) # A request on a hub without `calibration:` uses the schema defaults, never persisted. diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 6dd96d21bcb..67aad41c00c 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -213,9 +213,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pclk_frequency(config[CONF_PCLK_FREQUENCY] / 1.0e6)) cg.add(var.set_lanes(int(config[CONF_LANES]))) cg.add(var.set_lane_bit_rate(config[CONF_LANE_BIT_RATE] / 1.0e6)) - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] cg.add(var.set_enable_pins(enable)) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 4c87bf19987..a7cca81d8d5 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -315,28 +315,16 @@ async def to_code(config: ConfigType) -> None: data_pin = await cg.gpio_pin_expression(pin) cg.add(var.add_data_pin(data_pin, index)) - if dc_pin := config.get(CONF_DC_PIN): - dc = await cg.gpio_pin_expression(dc_pin) - cg.add(var.set_dc_pin(dc)) - - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_dc_pin, config.get(CONF_DC_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if model.rotation_as_transform(config): config[CONF_ROTATION] = 0 - if de_pin := config.get(CONF_DE_PIN): - pin = await cg.gpio_pin_expression(de_pin) - cg.add(var.set_de_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_PCLK_PIN]) - cg.add(var.set_pclk_pin(pin)) - if hsync_pin := config.get(CONF_HSYNC_PIN): - pin = await cg.gpio_pin_expression(hsync_pin) - cg.add(var.set_hsync_pin(pin)) - if vsync_pin := config.get(CONF_VSYNC_PIN): - pin = await cg.gpio_pin_expression(vsync_pin) - cg.add(var.set_vsync_pin(pin)) + await cg.add_pin(var.set_de_pin, config.get(CONF_DE_PIN)) + await cg.add_pin(var.set_pclk_pin, config[CONF_PCLK_PIN]) + await cg.add_pin(var.set_hsync_pin, config.get(CONF_HSYNC_PIN)) + await cg.add_pin(var.set_vsync_pin, config.get(CONF_VSYNC_PIN)) await display.register_display(var, config) await display.add_writer(var, config) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index ff49979a244..380c3a4dfd6 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -408,13 +408,8 @@ async def to_code(config: ConfigType) -> None: enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] cg.add(var.set_enable_pins(enable)) - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) - - if dc_pin := config.get(CONF_DC_PIN): - dc_pin = await cg.gpio_pin_expression(dc_pin) - cg.add(var.set_dc_pin(dc_pin)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await cg.add_pin(var.set_dc_pin, config.get(CONF_DC_PIN)) if config.get(CONF_INVERT_COLORS): cg.add(var.set_invert_colors(True)) diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index d6a777e7d88..5f793efd918 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -145,8 +145,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_resolution(2, RESOLUTION[config[CONF_Z_AXIS][CONF_RESOLUTION]])) if await sensors(CONF_TEMPERATURE, var.set_t_sensor): cg.add(var.set_t_oversampling(config[CONF_TEMPERATURE][CONF_OVERSAMPLING])) - if CONF_DRDY_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_DRDY_PIN]) - cg.add(var.set_drdy_gpio(pin)) + await cg.add_pin(var.set_drdy_gpio, config.get(CONF_DRDY_PIN)) cg.add_library("functionpointer/arduino-MLX90393", "1.0.2") diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 2c3aba950fe..8c079afbac3 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -11,7 +11,6 @@ from esphome.components.const import CONF_ROLE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj -from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv from esphome.types import ConfigType, TemplateArgsType @@ -307,9 +306,7 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) - if CONF_FLOW_CONTROL_PIN in config: - pin = await gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) - cg.add(var.set_flow_control_pin(pin)) + await cg.add_pin(var.set_flow_control_pin, config.get(CONF_FLOW_CONTROL_PIN)) if config[CONF_ROLE] == "client": cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME])) diff --git a/esphome/components/my9231/__init__.py b/esphome/components/my9231/__init__.py index 6fcb09a3478..5c4fe6af458 100644 --- a/esphome/components/my9231/__init__.py +++ b/esphome/components/my9231/__init__.py @@ -33,10 +33,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - di = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_pin_di(di)) - dcki = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_pin_dcki(dcki)) + await cg.add_pin(var.set_pin_di, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_pin_dcki, config[CONF_CLOCK_PIN]) cg.add(var.set_num_channels(config[CONF_NUM_CHANNELS])) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index b0c375eb53d..0c6ae8eb470 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -529,9 +529,7 @@ async def to_code(config: ConfigType) -> None: async def _dfu_to_code(dfu_config): cg.add_define("USE_NRF52_DFU") var = cg.new_Pvariable(dfu_config[CONF_ID]) - if CONF_RESET_PIN in dfu_config: - pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(pin)) + await cg.add_pin(var.set_reset_pin, dfu_config.get(CONF_RESET_PIN)) # DFU uses cdc rate callback to enter bootloader which was disabled explicitly to save power. zephyr_add_prj_conf("USB_DEVICE_STACK", True) diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 026951be93d..3772034ab63 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -87,11 +87,8 @@ async def to_code(config: dict[str, Any]) -> None: await cg.register_component(var, config) # Set pins - in_pin = await cg.gpio_pin_expression(config[CONF_IN_PIN]) - cg.add(var.set_in_pin(in_pin)) - - out_pin = await cg.gpio_pin_expression(config[CONF_OUT_PIN]) - cg.add(var.set_out_pin(out_pin)) + await cg.add_pin(var.set_in_pin, config[CONF_IN_PIN]) + await cg.add_pin(var.set_out_pin, config[CONF_OUT_PIN]) non_sensors = { CONF_ID, diff --git a/esphome/components/pcd8544/display.py b/esphome/components/pcd8544/display.py index c63d18b3cc4..1143def11c3 100644 --- a/esphome/components/pcd8544/display.py +++ b/esphome/components/pcd8544/display.py @@ -47,10 +47,8 @@ async def to_code(config: ConfigType) -> None: await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) + await cg.add_pin(var.set_reset_pin, config[CONF_RESET_PIN]) cg.add(var.set_contrast(config[CONF_CONTRAST])) diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index f1bd6122ff1..058b70ffb65 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -29,10 +29,5 @@ pn71xx.register_is_writing_condition("pn7160.is_writing", PN7160) async def setup_pn7160(var: MockObj, config: ConfigType) -> None: await pn71xx.setup_pn71xx(var, config) - if dwl_req_pin_config := config.get(CONF_DWL_REQ_PIN): - pin = await cg.gpio_pin_expression(dwl_req_pin_config) - cg.add(var.set_dwl_req_pin(pin)) - - if wkup_req_pin_config := config.get(CONF_WKUP_REQ_PIN): - pin = await cg.gpio_pin_expression(wkup_req_pin_config) - cg.add(var.set_wkup_req_pin(pin)) + await cg.add_pin(var.set_dwl_req_pin, config.get(CONF_DWL_REQ_PIN)) + await cg.add_pin(var.set_wkup_req_pin, config.get(CONF_WKUP_REQ_PIN)) diff --git a/esphome/components/pn71xx/__init__.py b/esphome/components/pn71xx/__init__.py index 3b40eb11419..10d806ca0c6 100644 --- a/esphome/components/pn71xx/__init__.py +++ b/esphome/components/pn71xx/__init__.py @@ -127,11 +127,8 @@ _request_ontagremoved_trigger_slot = cg.slot_counter( async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) - cg.add(var.set_irq_pin(pin)) - - pin = await cg.gpio_pin_expression(config[CONF_VEN_PIN]) - cg.add(var.set_ven_pin(pin)) + await cg.add_pin(var.set_irq_pin, config[CONF_IRQ_PIN]) + await cg.add_pin(var.set_ven_pin, config[CONF_VEN_PIN]) if emulation_message_config := config.get(CONF_EMULATION_MESSAGE): cg.add(var.set_tag_emulation_message(emulation_message_config)) diff --git a/esphome/components/power_supply/__init__.py b/esphome/components/power_supply/__init__.py index 4d90642b5f5..5862a945a66 100644 --- a/esphome/components/power_supply/__init__.py +++ b/esphome/components/power_supply/__init__.py @@ -35,8 +35,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) cg.add(var.set_enable_time(config[CONF_ENABLE_TIME])) cg.add(var.set_keep_on_time(config[CONF_KEEP_ON_TIME])) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 46aa87a8e11..33beb96dcf6 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -131,8 +131,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config, use_pcnt) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) count = config[CONF_COUNT_MODE] cg.add(var.set_rising_edge_mode(count[CONF_RISING_EDGE])) cg.add(var.set_falling_edge_mode(count[CONF_FALLING_EDGE])) diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index 05708c65c13..1239d011a2b 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -83,8 +83,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) cg.add(var.set_filter_us(config[CONF_INTERNAL_FILTER])) cg.add(var.set_timeout_us(config[CONF_TIMEOUT])) cg.add(var.set_filter_mode(config[CONF_INTERNAL_FILTER_MODE])) diff --git a/esphome/components/pulse_width/sensor.py b/esphome/components/pulse_width/sensor.py index dd7554c4ebd..01f7988f584 100644 --- a/esphome/components/pulse_width/sensor.py +++ b/esphome/components/pulse_width/sensor.py @@ -32,5 +32,4 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/qmc5883l/sensor.py b/esphome/components/qmc5883l/sensor.py index deba7eab7e0..c1b8d3c7af8 100644 --- a/esphome/components/qmc5883l/sensor.py +++ b/esphome/components/qmc5883l/sensor.py @@ -153,6 +153,4 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor) await sensors(CONF_HEADING, var.set_heading_sensor) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) - if CONF_DRDY_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_DRDY_PIN]) - cg.add(var.set_drdy_pin(pin)) + await cg.add_pin(var.set_drdy_pin, config.get(CONF_DRDY_PIN)) diff --git a/esphome/components/rc522/__init__.py b/esphome/components/rc522/__init__.py index 69cc713813b..8b2aabfa237 100644 --- a/esphome/components/rc522/__init__.py +++ b/esphome/components/rc522/__init__.py @@ -44,9 +44,7 @@ RC522_SCHEMA = cv.Schema( async def setup_rc522(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 31391d9c6ac..15629c446ab 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -93,16 +93,12 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pin_a = await cg.gpio_pin_expression(config[CONF_PIN_A]) - cg.add(var.set_pin_a(pin_a)) - pin_b = await cg.gpio_pin_expression(config[CONF_PIN_B]) - cg.add(var.set_pin_b(pin_b)) + await cg.add_pin(var.set_pin_a, config[CONF_PIN_A]) + await cg.add_pin(var.set_pin_b, config[CONF_PIN_B]) cg.add(var.set_publish_initial_value(config[CONF_PUBLISH_INITIAL_VALUE])) cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) - if CONF_PIN_RESET in config: - pin_i = await cg.gpio_pin_expression(config[CONF_PIN_RESET]) - cg.add(var.set_reset_pin(pin_i)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_PIN_RESET)) cg.add(var.set_resolution(config[CONF_RESOLUTION])) if CONF_MIN_VALUE in config: cg.add(var.set_min_value(config[CONF_MIN_VALUE])) diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index 49b10f7d201..2f1394d121c 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -27,8 +27,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) cg.add(var.set_frequency(config[CONF_FREQUENCY])) diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 35b65f6bae3..27d69c97346 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -117,10 +117,5 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(_add_serial_proxy_count_define) data.count += 1 - if CONF_RTS_PIN in config: - rts_pin = await cg.gpio_pin_expression(config[CONF_RTS_PIN]) - cg.add(var.set_rts_pin(rts_pin)) - - if CONF_DTR_PIN in config: - dtr_pin = await cg.gpio_pin_expression(config[CONF_DTR_PIN]) - cg.add(var.set_dtr_pin(dtr_pin)) + await cg.add_pin(var.set_rts_pin, config.get(CONF_RTS_PIN)) + await cg.add_pin(var.set_dtr_pin, config.get(CONF_DTR_PIN)) diff --git a/esphome/components/sh1122_base/__init__.py b/esphome/components/sh1122_base/__init__.py index e8828685368..4e412f5ebf7 100644 --- a/esphome/components/sh1122_base/__init__.py +++ b/esphome/components/sh1122_base/__init__.py @@ -32,8 +32,6 @@ async def setup_sh1122(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if (reset_pin := config.get(CONF_RESET_PIN)) is not None: - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index c166076e0f8..701d29ffa22 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -250,10 +250,8 @@ async def to_code(config: ConfigType) -> None: await light.register_light(var, config) await uart.register_uart_device(var, config) - nrst_pin = await cg.gpio_pin_expression(config[CONF_NRST_PIN]) - cg.add(var.set_nrst_pin(nrst_pin)) - boot0_pin = await cg.gpio_pin_expression(config[CONF_BOOT0_PIN]) - cg.add(var.set_boot0_pin(boot0_pin)) + await cg.add_pin(var.set_nrst_pin, config[CONF_NRST_PIN]) + await cg.add_pin(var.set_boot0_pin, config[CONF_BOOT0_PIN]) cg.add(var.set_leading_edge(config[CONF_LEADING_EDGE])) cg.add(var.set_warmup_brightness(config[CONF_WARMUP_BRIGHTNESS])) diff --git a/esphome/components/sigma_delta_output/output.py b/esphome/components/sigma_delta_output/output.py index aca5f264572..af6f5948eaf 100644 --- a/esphome/components/sigma_delta_output/output.py +++ b/esphome/components/sigma_delta_output/output.py @@ -43,9 +43,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - if CONF_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config.get(CONF_PIN)) if CONF_STATE_CHANGE_ACTION in config: await automation.build_automation( diff --git a/esphome/components/slow_pwm/output.py b/esphome/components/slow_pwm/output.py index f3f82d7ce5a..488932f1b42 100644 --- a/esphome/components/slow_pwm/output.py +++ b/esphome/components/slow_pwm/output.py @@ -47,9 +47,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) - if CONF_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config.get(CONF_PIN)) if CONF_STATE_CHANGE_ACTION in config: await automation.build_automation( var.get_state_change_trigger(), diff --git a/esphome/components/sm10bit_base/__init__.py b/esphome/components/sm10bit_base/__init__.py index 05ba98562d7..5417f9ea6ce 100644 --- a/esphome/components/sm10bit_base/__init__.py +++ b/esphome/components/sm10bit_base/__init__.py @@ -33,10 +33,8 @@ async def register_sm10bit_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_max_power_color_channels(config[CONF_MAX_POWER_COLOR_CHANNELS])) cg.add(var.set_max_power_white_channels(config[CONF_MAX_POWER_WHITE_CHANNELS])) diff --git a/esphome/components/sm16716/__init__.py b/esphome/components/sm16716/__init__.py index ae3c54ab7c7..8359b53671b 100644 --- a/esphome/components/sm16716/__init__.py +++ b/esphome/components/sm16716/__init__.py @@ -31,10 +31,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_num_channels(config[CONF_NUM_CHANNELS])) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/sm2135/__init__.py b/esphome/components/sm2135/__init__.py index 7c5622c041b..934cf68186f 100644 --- a/esphome/components/sm2135/__init__.py +++ b/esphome/components/sm2135/__init__.py @@ -61,10 +61,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_rgb_current(config[CONF_RGB_CURRENT])) cg.add(var.set_cw_current(config[CONF_CW_CURRENT])) diff --git a/esphome/components/sn74hc165/__init__.py b/esphome/components/sn74hc165/__init__.py index af566cebda9..f7d03a897d9 100644 --- a/esphome/components/sn74hc165/__init__.py +++ b/esphome/components/sn74hc165/__init__.py @@ -44,15 +44,10 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data_pin = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data_pin)) - clock_pin = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock_pin)) - load_pin = await cg.gpio_pin_expression(config[CONF_LOAD_PIN]) - cg.add(var.set_load_pin(load_pin)) - if CONF_CLOCK_INHIBIT_PIN in config: - clock_inhibit_pin = await cg.gpio_pin_expression(config[CONF_CLOCK_INHIBIT_PIN]) - cg.add(var.set_clock_inhibit_pin(clock_inhibit_pin)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) + await cg.add_pin(var.set_load_pin, config[CONF_LOAD_PIN]) + await cg.add_pin(var.set_clock_inhibit_pin, config.get(CONF_CLOCK_INHIBIT_PIN)) cg.add(var.set_sr_count(config[CONF_SR_COUNT])) diff --git a/esphome/components/sn74hc595/__init__.py b/esphome/components/sn74hc595/__init__.py index 78ed703ed9e..8d540011798 100644 --- a/esphome/components/sn74hc595/__init__.py +++ b/esphome/components/sn74hc595/__init__.py @@ -72,18 +72,13 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if config[CONF_TYPE] == TYPE_GPIO: - data_pin = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data_pin)) - clock_pin = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock_pin)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) else: await spi.register_spi_device(var, config) - latch_pin = await cg.gpio_pin_expression(config[CONF_LATCH_PIN]) - cg.add(var.set_latch_pin(latch_pin)) - if oe_pin := config.get(CONF_OE_PIN): - oe_pin = await cg.gpio_pin_expression(oe_pin) - cg.add(var.set_oe_pin(oe_pin)) + await cg.add_pin(var.set_latch_pin, config[CONF_LATCH_PIN]) + await cg.add_pin(var.set_oe_pin, config.get(CONF_OE_PIN)) cg.add(var.set_sr_count(config[CONF_SR_COUNT])) diff --git a/esphome/components/spd2010/touchscreen.py b/esphome/components/spd2010/touchscreen.py index 7ead6a34184..0f3a2e24bf6 100644 --- a/esphome/components/spd2010/touchscreen.py +++ b/esphome/components/spd2010/touchscreen.py @@ -27,7 +27,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 92d5baccf76..151f228651e 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -436,12 +436,9 @@ async def to_code(configs: list[ConfigType]) -> None: for spi in configs: var = cg.new_Pvariable(spi[CONF_ID]) await cg.register_component(var, spi) - clk = await cg.gpio_pin_expression(spi[CONF_CLK_PIN]) - cg.add(var.set_clk(clk)) - if miso := spi.get(CONF_MISO_PIN): - cg.add(var.set_miso(await cg.gpio_pin_expression(miso))) - if mosi := spi.get(CONF_MOSI_PIN): - cg.add(var.set_mosi(await cg.gpio_pin_expression(mosi))) + await cg.add_pin(var.set_clk, spi[CONF_CLK_PIN]) + await cg.add_pin(var.set_miso, spi.get(CONF_MISO_PIN)) + await cg.add_pin(var.set_mosi, spi.get(CONF_MOSI_PIN)) if data_pins := spi.get(CONF_DATA_PINS): cg.add(var.set_data_pins(data_pins)) if (index := spi.get(CONF_INTERFACE_INDEX)) is not None: @@ -491,9 +488,7 @@ async def register_spi_device( cg.add(var.set_spi_parent(parent)) if write_only: cg.add(var.set_write_only(True)) - if cs_pin := config.get(CONF_CS_PIN): - pin = await cg.gpio_pin_expression(cs_pin) - cg.add(var.set_cs_pin(pin)) + await cg.add_pin(var.set_cs_pin, config.get(CONF_CS_PIN)) if data_rate := config.get(CONF_DATA_RATE): cg.add(var.set_data_rate(data_rate)) if spi_mode := config.get(CONF_SPI_MODE): diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index b51fb6f8f22..4f9dda09dd7 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -80,9 +80,7 @@ async def setup_ssd1306(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_CONTRAST in config: diff --git a/esphome/components/ssd1306_spi/display.py b/esphome/components/ssd1306_spi/display.py index 9a415ccfdbd..e7d067eca46 100644 --- a/esphome/components/ssd1306_spi/display.py +++ b/esphome/components/ssd1306_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1306_base.setup_ssd1306(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1322_base/__init__.py b/esphome/components/ssd1322_base/__init__.py index 72f8bd27235..e3a6ac5656e 100644 --- a/esphome/components/ssd1322_base/__init__.py +++ b/esphome/components/ssd1322_base/__init__.py @@ -33,9 +33,7 @@ async def setup_ssd1322(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: diff --git a/esphome/components/ssd1322_spi/display.py b/esphome/components/ssd1322_spi/display.py index 63e0816ae72..e9b036eb7f8 100644 --- a/esphome/components/ssd1322_spi/display.py +++ b/esphome/components/ssd1322_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1322_base.setup_ssd1322(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1325_base/__init__.py b/esphome/components/ssd1325_base/__init__.py index e148808d15f..ee75f2b0b0b 100644 --- a/esphome/components/ssd1325_base/__init__.py +++ b/esphome/components/ssd1325_base/__init__.py @@ -37,9 +37,7 @@ async def setup_ssd1325(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) if CONF_EXTERNAL_VCC in config: diff --git a/esphome/components/ssd1325_spi/display.py b/esphome/components/ssd1325_spi/display.py index cca9a3cf8e8..601d874ed3d 100644 --- a/esphome/components/ssd1325_spi/display.py +++ b/esphome/components/ssd1325_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1325_base.setup_ssd1325(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1327_base/__init__.py b/esphome/components/ssd1327_base/__init__.py index 93e4c75d3b1..bd317cf17f4 100644 --- a/esphome/components/ssd1327_base/__init__.py +++ b/esphome/components/ssd1327_base/__init__.py @@ -32,9 +32,7 @@ async def setup_ssd1327(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/ssd1327_spi/display.py b/esphome/components/ssd1327_spi/display.py index 8a5ea40efa2..60a96c5764c 100644 --- a/esphome/components/ssd1327_spi/display.py +++ b/esphome/components/ssd1327_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1327_base.setup_ssd1327(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1331_base/__init__.py b/esphome/components/ssd1331_base/__init__.py index 5f71570b6ae..acde9a1ea46 100644 --- a/esphome/components/ssd1331_base/__init__.py +++ b/esphome/components/ssd1331_base/__init__.py @@ -23,9 +23,7 @@ SSD1331_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( async def setup_ssd1331(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/ssd1331_spi/display.py b/esphome/components/ssd1331_spi/display.py index eeebb48ac6b..f52f14a2273 100644 --- a/esphome/components/ssd1331_spi/display.py +++ b/esphome/components/ssd1331_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1331_base.setup_ssd1331(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/ssd1351_base/__init__.py b/esphome/components/ssd1351_base/__init__.py index 2c44cd18403..7078f7d2421 100644 --- a/esphome/components/ssd1351_base/__init__.py +++ b/esphome/components/ssd1351_base/__init__.py @@ -33,9 +33,7 @@ async def setup_ssd1351(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_model(config[CONF_MODEL])) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if CONF_BRIGHTNESS in config: cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) await display.add_writer(var, config) diff --git a/esphome/components/ssd1351_spi/display.py b/esphome/components/ssd1351_spi/display.py index 10991b8338f..1f70dd14b95 100644 --- a/esphome/components/ssd1351_spi/display.py +++ b/esphome/components/ssd1351_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await ssd1351_base.setup_ssd1351(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/st7123/touchscreen/__init__.py b/esphome/components/st7123/touchscreen/__init__.py index 55ea43ca6ab..a1b19c49247 100644 --- a/esphome/components/st7123/touchscreen/__init__.py +++ b/esphome/components/st7123/touchscreen/__init__.py @@ -27,7 +27,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) - if reset_pin := config.get(CONF_RESET_PIN): - cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/st7567_base/__init__.py b/esphome/components/st7567_base/__init__.py index a045cf34ccd..67f05157d97 100644 --- a/esphome/components/st7567_base/__init__.py +++ b/esphome/components/st7567_base/__init__.py @@ -39,9 +39,7 @@ ST7567_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( async def setup_st7567(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) cg.add(var.init_invert_colors(config[CONF_INVERT_COLORS])) diff --git a/esphome/components/st7567_spi/display.py b/esphome/components/st7567_spi/display.py index ad04b9f2125..ba4a08eaf45 100644 --- a/esphome/components/st7567_spi/display.py +++ b/esphome/components/st7567_spi/display.py @@ -35,5 +35,4 @@ async def to_code(config: ConfigType) -> None: await st7567_base.setup_st7567(var, config) await spi.register_spi_device(var, config, write_only=True) - dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) - cg.add(var.set_dc_pin(dc)) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 79430f14176..915d32b5380 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -209,13 +209,8 @@ async def to_code(config: ConfigType) -> None: data_pin = await cg.gpio_pin_expression(pin) cg.add(var.add_data_pin(data_pin, index)) - if dc_pin := config.get(CONF_DC_PIN): - dc = await cg.gpio_pin_expression(dc_pin) - cg.add(var.set_dc_pin(dc)) - - if reset_pin := config.get(CONF_RESET_PIN): - reset = await cg.gpio_pin_expression(reset_pin) - cg.add(var.set_reset_pin(reset)) + await cg.add_pin(var.set_dc_pin, config.get(CONF_DC_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) if transform := config.get(CONF_TRANSFORM): cg.add(var.set_mirror_x(transform[CONF_MIRROR_X])) @@ -236,11 +231,7 @@ async def to_code(config: ConfigType) -> None: await display.add_writer(var, config) - pin = await cg.gpio_pin_expression(config[CONF_DE_PIN]) - cg.add(var.set_de_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_PCLK_PIN]) - cg.add(var.set_pclk_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_HSYNC_PIN]) - cg.add(var.set_hsync_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_VSYNC_PIN]) - cg.add(var.set_vsync_pin(pin)) + await cg.add_pin(var.set_de_pin, config[CONF_DE_PIN]) + await cg.add_pin(var.set_pclk_pin, config[CONF_PCLK_PIN]) + await cg.add_pin(var.set_hsync_pin, config[CONF_HSYNC_PIN]) + await cg.add_pin(var.set_vsync_pin, config[CONF_VSYNC_PIN]) diff --git a/esphome/components/status_led/light/__init__.py b/esphome/components/status_led/light/__init__.py index f63cf14178b..a36de55f198 100644 --- a/esphome/components/status_led/light/__init__.py +++ b/esphome/components/status_led/light/__init__.py @@ -27,11 +27,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) - if CONF_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) - if CONF_OUTPUT in config: - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_pin(var.set_pin, config.get(CONF_PIN)) + await cg.add_variable(var.set_output, config.get(CONF_OUTPUT)) await cg.register_component(var, config) await light.register_light(var, config) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 3af042ea72b..2c14ea0bd56 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -249,13 +249,9 @@ async def to_code(config: ConfigType) -> None: ], config[CONF_ON_PACKET], ) - if CONF_DIO1_PIN in config: - dio1_pin = await cg.gpio_pin_expression(config[CONF_DIO1_PIN]) - cg.add(var.set_dio1_pin(dio1_pin)) - rst_pin = await cg.gpio_pin_expression(config[CONF_RST_PIN]) - cg.add(var.set_rst_pin(rst_pin)) - busy_pin = await cg.gpio_pin_expression(config[CONF_BUSY_PIN]) - cg.add(var.set_busy_pin(busy_pin)) + await cg.add_pin(var.set_dio1_pin, config.get(CONF_DIO1_PIN)) + await cg.add_pin(var.set_rst_pin, config[CONF_RST_PIN]) + await cg.add_pin(var.set_busy_pin, config[CONF_BUSY_PIN]) cg.add(var.set_bandwidth(config[CONF_BANDWIDTH])) cg.add(var.set_frequency(config[CONF_FREQUENCY])) cg.add(var.set_hw_version(config[CONF_HW_VERSION])) diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 3bd3b35a2ed..49e68c61844 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -232,11 +232,8 @@ async def to_code(config: ConfigType) -> None: ], config[CONF_ON_PACKET], ) - if CONF_DIO0_PIN in config: - dio0_pin = await cg.gpio_pin_expression(config[CONF_DIO0_PIN]) - cg.add(var.set_dio0_pin(dio0_pin)) - rst_pin = await cg.gpio_pin_expression(config[CONF_RST_PIN]) - cg.add(var.set_rst_pin(rst_pin)) + await cg.add_pin(var.set_dio0_pin, config.get(CONF_DIO0_PIN)) + await cg.add_pin(var.set_rst_pin, config[CONF_RST_PIN]) cg.add(var.set_auto_cal(config[CONF_AUTO_CAL])) cg.add(var.set_bandwidth(config[CONF_BANDWIDTH])) cg.add(var.set_frequency(config[CONF_FREQUENCY])) diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 332d994d5e9..4a23e085ea0 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -175,6 +175,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_mixer_mode(config[CONF_MIXER_MODE])) cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) - if enable_pin_config := config.get(CONF_ENABLE_PIN): - enable_pin = await cg.gpio_pin_expression(enable_pin_config) - cg.add(var.set_enable_pin(enable_pin)) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py index ee9decd6680..2c33df3db91 100644 --- a/esphome/components/tca8418/__init__.py +++ b/esphome/components/tca8418/__init__.py @@ -145,8 +145,7 @@ async def to_code(config: ConfigType) -> None: array = cg.shared_progmem_array("tca8418_keys", cg.uint8, table) cg.add(var.set_keys(array, len(table))) cg.add(var.set_gpi_events(config[CONF_GPI_EVENTS])) - if (interrupt_pin := config.get(CONF_INTERRUPT_PIN)) is not None: - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) for conf in config.get(CONF_ON_KEY, []): await automation.build_callback_automation( diff --git a/esphome/components/tlc5947/__init__.py b/esphome/components/tlc5947/__init__.py index 71247dd13ef..1f1f4ea9c29 100644 --- a/esphome/components/tlc5947/__init__.py +++ b/esphome/components/tlc5947/__init__.py @@ -38,14 +38,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) - lat = await cg.gpio_pin_expression(config[CONF_LAT_PIN]) - cg.add(var.set_lat_pin(lat)) - if CONF_OE_PIN in config: - outenable = await cg.gpio_pin_expression(config[CONF_OE_PIN]) - cg.add(var.set_outenable_pin(outenable)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) + await cg.add_pin(var.set_lat_pin, config[CONF_LAT_PIN]) + await cg.add_pin(var.set_outenable_pin, config.get(CONF_OE_PIN)) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/tlc5971/__init__.py b/esphome/components/tlc5971/__init__.py index 26beec280e1..8e22b080f42 100644 --- a/esphome/components/tlc5971/__init__.py +++ b/esphome/components/tlc5971/__init__.py @@ -28,9 +28,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - clock = await cg.gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_clock_pin(clock)) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_clock_pin, config[CONF_CLOCK_PIN]) cg.add(var.set_num_chips(config[CONF_NUM_CHIPS])) diff --git a/esphome/components/tm1621/display.py b/esphome/components/tm1621/display.py index 4a83d363fe4..4765d950cbe 100644 --- a/esphome/components/tm1621/display.py +++ b/esphome/components/tm1621/display.py @@ -30,13 +30,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - cs = await cg.gpio_pin_expression(config[CONF_CS_PIN]) - cg.add(var.set_cs_pin(cs)) - data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_data_pin(data)) - read = await cg.gpio_pin_expression(config[CONF_READ_PIN]) - cg.add(var.set_read_pin(read)) - write = await cg.gpio_pin_expression(config[CONF_WRITE_PIN]) - cg.add(var.set_write_pin(write)) + await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN]) + await cg.add_pin(var.set_data_pin, config[CONF_DATA_PIN]) + await cg.add_pin(var.set_read_pin, config[CONF_READ_PIN]) + await cg.add_pin(var.set_write_pin, config[CONF_WRITE_PIN]) await display.add_writer(var, config, TM1621DisplayRef) diff --git a/esphome/components/tm1637/display.py b/esphome/components/tm1637/display.py index ebdd9449d65..27289dea774 100644 --- a/esphome/components/tm1637/display.py +++ b/esphome/components/tm1637/display.py @@ -36,10 +36,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - clk = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_clk_pin(clk)) - dio = await cg.gpio_pin_expression(config[CONF_DIO_PIN]) - cg.add(var.set_dio_pin(dio)) + await cg.add_pin(var.set_clk_pin, config[CONF_CLK_PIN]) + await cg.add_pin(var.set_dio_pin, config[CONF_DIO_PIN]) cg.add(var.set_intensity(config[CONF_INTENSITY])) cg.add(var.set_inverted(config[CONF_INVERTED])) diff --git a/esphome/components/tm1638/display.py b/esphome/components/tm1638/display.py index 74ee8b3293d..d7e375ac885 100644 --- a/esphome/components/tm1638/display.py +++ b/esphome/components/tm1638/display.py @@ -35,14 +35,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) - clk = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_clk_pin(clk)) - - dio = await cg.gpio_pin_expression(config[CONF_DIO_PIN]) - cg.add(var.set_dio_pin(dio)) - - stb = await cg.gpio_pin_expression(config[CONF_STB_PIN]) - cg.add(var.set_stb_pin(stb)) + await cg.add_pin(var.set_clk_pin, config[CONF_CLK_PIN]) + await cg.add_pin(var.set_dio_pin, config[CONF_DIO_PIN]) + await cg.add_pin(var.set_stb_pin, config[CONF_STB_PIN]) cg.add(var.set_intensity(config[CONF_INTENSITY])) diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index 14aa1c8842b..60cf1e542a1 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -43,10 +43,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - clk_pin = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) - cg.add(var.set_clk_pin(clk_pin)) - dio_pin = await cg.gpio_pin_expression(config[CONF_DIO_PIN]) - cg.add(var.set_dio_pin(dio_pin)) + await cg.add_pin(var.set_clk_pin, config[CONF_CLK_PIN]) + await cg.add_pin(var.set_dio_pin, config[CONF_DIO_PIN]) validate_brightness = cv.enum(TM1651_BRIGHTNESS_OPTIONS, int=True) diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index 1018443e002..427b3fe83a0 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -176,8 +176,7 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: (CONF_INDEX_PIN, var.set_index_pin), (CONF_DIAG_PIN, var.set_diag_pin), ): - if (pin := config.get(key)) is not None: - cg.add(setter(await cg.gpio_pin_expression(pin))) + await cg.add_pin(setter, config.get(key)) for key, setter in ( (CONF_RSENSE, var.set_rsense), (CONF_VSENSE, var.set_vsense), diff --git a/esphome/components/tt21100/touchscreen/__init__.py b/esphome/components/tt21100/touchscreen/__init__.py index 22489df379e..7ef15f810d4 100644 --- a/esphome/components/tt21100/touchscreen/__init__.py +++ b/esphome/components/tt21100/touchscreen/__init__.py @@ -32,10 +32,5 @@ async def to_code(config: ConfigType) -> None: await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) - if CONF_INTERRUPT_PIN in config: - interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_interrupt_pin(interrupt_pin)) - - if CONF_RESET_PIN in config: - rts_pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(rts_pin)) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) diff --git a/esphome/components/ttp229_bsf/__init__.py b/esphome/components/ttp229_bsf/__init__.py index 1b10940570b..274ea25fd7c 100644 --- a/esphome/components/ttp229_bsf/__init__.py +++ b/esphome/components/ttp229_bsf/__init__.py @@ -26,7 +26,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - sdo = await cg.gpio_pin_expression(config[CONF_SDO_PIN]) - cg.add(var.set_sdo_pin(sdo)) - scl = await cg.gpio_pin_expression(config[CONF_SCL_PIN]) - cg.add(var.set_scl_pin(scl)) + await cg.add_pin(var.set_sdo_pin, config[CONF_SDO_PIN]) + await cg.add_pin(var.set_scl_pin, config[CONF_SCL_PIN]) diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index 51db405512c..4a334e3e518 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -89,9 +89,7 @@ async def to_code(config): cg.add(var.set_time_id(time_)) if config[CONF_FORCE_TIME_SYNC]: cg.add(var.set_force_time_sync(True)) - if CONF_STATUS_PIN in config: - status_pin_ = await cg.gpio_pin_expression(config[CONF_STATUS_PIN]) - cg.add(var.set_status_pin(status_pin_)) + await cg.add_pin(var.set_status_pin, config.get(CONF_STATUS_PIN)) if CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS in config: for dp in config[CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS]: cg.add(var.add_ignore_mcu_update_on_datapoints(dp)) diff --git a/esphome/components/tuya/climate/__init__.py b/esphome/components/tuya/climate/__init__.py index cefe5d5a4b4..91ef9a6a68a 100644 --- a/esphome/components/tuya/climate/__init__.py +++ b/esphome/components/tuya/climate/__init__.py @@ -204,12 +204,8 @@ async def to_code(config): if switch_datapoint := config.get(CONF_SWITCH_DATAPOINT): cg.add(var.set_switch_id(switch_datapoint)) - if heating_state_pin_config := config.get(CONF_HEATING_STATE_PIN): - heating_state_pin = await cg.gpio_pin_expression(heating_state_pin_config) - cg.add(var.set_heating_state_pin(heating_state_pin)) - if cooling_state_pin_config := config.get(CONF_COOLING_STATE_PIN): - cooling_state_pin = await cg.gpio_pin_expression(cooling_state_pin_config) - cg.add(var.set_cooling_state_pin(cooling_state_pin)) + await cg.add_pin(var.set_heating_state_pin, config.get(CONF_HEATING_STATE_PIN)) + await cg.add_pin(var.set_cooling_state_pin, config.get(CONF_COOLING_STATE_PIN)) if active_state_config := config.get(CONF_ACTIVE_STATE): cg.add(var.set_active_state_id(active_state_config.get(CONF_DATAPOINT))) if (heating_value := active_state_config.get(CONF_HEATING_VALUE)) is not None: diff --git a/esphome/components/tx20/sensor.py b/esphome/components/tx20/sensor.py index 77418e5284f..fe2d1ba99f6 100644 --- a/esphome/components/tx20/sensor.py +++ b/esphome/components/tx20/sensor.py @@ -48,5 +48,4 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_WIND_SPEED, var.set_wind_speed_sensor) await sensors(CONF_WIND_DIRECTION_DEGREES, var.set_wind_direction_degrees_sensor) - pin = await cg.gpio_pin_expression(config[CONF_PIN]) - cg.add(var.set_pin(pin)) + await cg.add_pin(var.set_pin, config[CONF_PIN]) diff --git a/esphome/components/uln2003/stepper.py b/esphome/components/uln2003/stepper.py index f09b2702c71..bc656a5ff36 100644 --- a/esphome/components/uln2003/stepper.py +++ b/esphome/components/uln2003/stepper.py @@ -42,14 +42,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await stepper.register_stepper(var, config) - pin_a = await cg.gpio_pin_expression(config[CONF_PIN_A]) - cg.add(var.set_pin_a(pin_a)) - pin_b = await cg.gpio_pin_expression(config[CONF_PIN_B]) - cg.add(var.set_pin_b(pin_b)) - pin_c = await cg.gpio_pin_expression(config[CONF_PIN_C]) - cg.add(var.set_pin_c(pin_c)) - pin_d = await cg.gpio_pin_expression(config[CONF_PIN_D]) - cg.add(var.set_pin_d(pin_d)) + await cg.add_pin(var.set_pin_a, config[CONF_PIN_A]) + await cg.add_pin(var.set_pin_b, config[CONF_PIN_B]) + await cg.add_pin(var.set_pin_c, config[CONF_PIN_C]) + await cg.add_pin(var.set_pin_d, config[CONF_PIN_D]) cg.add(var.set_sleep_when_done(config[CONF_SLEEP_WHEN_DONE])) cg.add(var.set_step_mode(config[CONF_STEP_MODE])) diff --git a/esphome/components/ultrasonic/sensor.py b/esphome/components/ultrasonic/sensor.py index 25973447f6f..a978f36b90b 100644 --- a/esphome/components/ultrasonic/sensor.py +++ b/esphome/components/ultrasonic/sensor.py @@ -49,9 +49,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - trigger = await cg.gpio_pin_expression(config[CONF_TRIGGER_PIN]) - cg.add(var.set_trigger_pin(trigger)) - echo = await cg.gpio_pin_expression(config[CONF_ECHO_PIN]) - cg.add(var.set_echo_pin(echo)) + await cg.add_pin(var.set_trigger_pin, config[CONF_TRIGGER_PIN]) + await cg.add_pin(var.set_echo_pin, config[CONF_ECHO_PIN]) cg.add(var.set_timeout_us(config[CONF_TIMEOUT] / (0.000343 / 2))) cg.add(var.set_pulse_time_us(config[CONF_PULSE_TIME])) diff --git a/esphome/components/vl53l0x/sensor.py b/esphome/components/vl53l0x/sensor.py index 3029e0f77b1..8f6a97c384a 100644 --- a/esphome/components/vl53l0x/sensor.py +++ b/esphome/components/vl53l0x/sensor.py @@ -81,9 +81,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_long_range(config[CONF_LONG_RANGE])) cg.add(var.set_timeout_us(config[CONF_TIMEOUT])) - if CONF_ENABLE_PIN in config: - enable = await cg.gpio_pin_expression(config[CONF_ENABLE_PIN]) - cg.add(var.set_enable_pin(enable)) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) if timing_budget := config.get(CONF_TIMING_BUDGET): cg.add(var.set_timing_budget(timing_budget)) diff --git a/esphome/components/wiegand/__init__.py b/esphome/components/wiegand/__init__.py index 93d5e4f0f06..c59bf1c0785 100644 --- a/esphome/components/wiegand/__init__.py +++ b/esphome/components/wiegand/__init__.py @@ -56,10 +56,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin = await cg.gpio_pin_expression(config[CONF_D0]) - cg.add(var.set_d0_pin(pin)) - pin = await cg.gpio_pin_expression(config[CONF_D1]) - cg.add(var.set_d1_pin(pin)) + await cg.add_pin(var.set_d0_pin, config[CONF_D0]) + await cg.add_pin(var.set_d1_pin, config[CONF_D1]) for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/x9c/output.py b/esphome/components/x9c/output.py index ff3f26e2abf..b927cfa906f 100644 --- a/esphome/components/x9c/output.py +++ b/esphome/components/x9c/output.py @@ -45,12 +45,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await output.register_output(var, config) - cs_pin = await cg.gpio_pin_expression(config[CONF_CS_PIN]) - cg.add(var.set_cs_pin(cs_pin)) - inc_pin = await cg.gpio_pin_expression(config[CONF_INC_PIN]) - cg.add(var.set_inc_pin(inc_pin)) - ud_pin = await cg.gpio_pin_expression(config[CONF_UD_PIN]) - cg.add(var.set_ud_pin(ud_pin)) + await cg.add_pin(var.set_cs_pin, config[CONF_CS_PIN]) + await cg.add_pin(var.set_inc_pin, config[CONF_INC_PIN]) + await cg.add_pin(var.set_ud_pin, config[CONF_UD_PIN]) cg.add(var.set_initial_value(config[CONF_INITIAL_VALUE])) cg.add(var.set_step_delay(config[CONF_STEP_DELAY])) diff --git a/esphome/components/xpt2046/touchscreen/__init__.py b/esphome/components/xpt2046/touchscreen/__init__.py index e38bc23da37..a8bbe9d88fd 100644 --- a/esphome/components/xpt2046/touchscreen/__init__.py +++ b/esphome/components/xpt2046/touchscreen/__init__.py @@ -39,6 +39,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_threshold(config[CONF_THRESHOLD])) - if CONF_INTERRUPT_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) - cg.add(var.set_irq_pin(pin)) + await cg.add_pin(var.set_irq_pin, config.get(CONF_INTERRUPT_PIN)) diff --git a/esphome/components/zyaura/sensor.py b/esphome/components/zyaura/sensor.py index cfe980f037c..23fd3bc5bfb 100644 --- a/esphome/components/zyaura/sensor.py +++ b/esphome/components/zyaura/sensor.py @@ -18,7 +18,6 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) -from esphome.cpp_helpers import gpio_pin_expression from esphome.types import ConfigType zyaura_ns = cg.esphome_ns.namespace("zyaura") @@ -56,10 +55,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - pin_clock = await gpio_pin_expression(config[CONF_CLOCK_PIN]) - cg.add(var.set_pin_clock(pin_clock)) - pin_data = await gpio_pin_expression(config[CONF_DATA_PIN]) - cg.add(var.set_pin_data(pin_data)) + await cg.add_pin(var.set_pin_clock, config[CONF_CLOCK_PIN]) + await cg.add_pin(var.set_pin_data, config[CONF_DATA_PIN]) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) From eca95348acb87f129f4fc650aa9078cfe3ba9cb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:16:50 -1000 Subject: [PATCH 103/179] [components] Assemble 24-bit readings with encode_uint24 (#21016) --- esphome/components/am2315c/am2315c.cpp | 2 +- esphome/components/bl0906/bl0906.cpp | 3 ++- esphome/components/bmp085/bmp085.cpp | 3 ++- esphome/components/bmp3xx_base/bmp3xx_base.cpp | 7 ++++--- esphome/components/bmp581_base/bmp581_base.cpp | 3 ++- esphome/components/dps310/dps310.cpp | 10 ++++------ esphome/components/ens210/ens210.cpp | 5 +++-- esphome/components/it8951/it8951.cpp | 2 +- esphome/components/ms5611/ms5611.cpp | 5 +++-- esphome/components/pzem004t/pzem004t.cpp | 3 ++- 10 files changed, 24 insertions(+), 19 deletions(-) diff --git a/esphome/components/am2315c/am2315c.cpp b/esphome/components/am2315c/am2315c.cpp index 9b350560ee9..cd754411577 100644 --- a/esphome/components/am2315c/am2315c.cpp +++ b/esphome/components/am2315c/am2315c.cpp @@ -64,7 +64,7 @@ bool AM2315C::convert_(uint8_t *data, float &humidity, float &temperature) { uint32_t raw; raw = (data[1] << 12) | (data[2] << 4) | (data[3] >> 4); humidity = raw * 9.5367431640625e-5; - raw = ((data[3] & 0x0F) << 16) | (data[4] << 8) | data[5]; + raw = encode_uint24(data[3] & 0x0F, data[4], data[5]); temperature = raw * 1.9073486328125e-4 - 50; return crc8(data, 6, 0xFF, 0x31, true) == data[6]; } diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 35f663ffa0c..6f59da77d34 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -1,13 +1,14 @@ #include "bl0906.h" #include "constants.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bl0906 { ESPHOME_LOG_TAG(TAG, "bl0906"); -constexpr uint32_t to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } +constexpr uint32_t to_uint32_t(ube24_t input) { return encode_uint24(input.h, input.m, input.l); } constexpr int32_t to_int32_t(sbe24_t input) { return static_cast(encode_uint32((uint8_t) input.h, input.m, input.l, 0)) >> 8; diff --git a/esphome/components/bmp085/bmp085.cpp b/esphome/components/bmp085/bmp085.cpp index f9ec710a9b6..a7607d4dbd3 100644 --- a/esphome/components/bmp085/bmp085.cpp +++ b/esphome/components/bmp085/bmp085.cpp @@ -1,4 +1,5 @@ #include "bmp085.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bmp085 { @@ -93,7 +94,7 @@ void BMP085Component::read_pressure_() { return; } - uint32_t value = (uint32_t(buffer[0]) << 16) | (uint32_t(buffer[1]) << 8) | uint32_t(buffer[2]); + uint32_t value = encode_uint24(buffer[0], buffer[1], buffer[2]); if ((value >> 5) == 0) { ESP_LOGW(TAG, "Invalid pressure!"); this->status_set_warning(); diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index f59abc61ec3..f985ebed870 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -7,6 +7,7 @@ #include "bmp3xx_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" #include @@ -222,7 +223,7 @@ bool BMP3XXComponent::get_temperature(float &temperature) { return false; } // Copy the temperature data into the adc variables - int32_t adc_temp = (int32_t) data[2] << 16 | (int32_t) data[1] << 8 | (int32_t) data[0]; + int32_t adc_temp = encode_uint24(data[2], data[1], data[0]); // Temperature compensation (function from BMP388 datasheet) temperature = bmp388_compensate_temperature_((float) adc_temp); return true; @@ -249,8 +250,8 @@ bool BMP3XXComponent::get_measurements(float &temperature, float &pressure) { return false; } // Copy the temperature and pressure data into the adc variables - int32_t adc_pres = (int32_t) data[2] << 16 | (int32_t) data[1] << 8 | (int32_t) data[0]; - int32_t adc_temp = (int32_t) data[5] << 16 | (int32_t) data[4] << 8 | (int32_t) data[3]; + int32_t adc_pres = encode_uint24(data[2], data[1], data[0]); + int32_t adc_temp = encode_uint24(data[5], data[4], data[3]); // Temperature compensation (function from BMP388 datasheet) temperature = bmp388_compensate_temperature_((float) adc_temp); diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 7bc743977b2..9f67f734e6e 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -12,6 +12,7 @@ #include "bmp581_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -410,7 +411,7 @@ bool BMP581Component::read_temperature_and_pressure_(float &temperature, float & temperature = (float) (raw_temp / 65536.0); // convert measurement to degrees Celsius (page 22 of datasheet) // pressure MSB is in data[5], LSB is in data[4], XLSB in data[3] - int32_t raw_press = (int32_t) data[5] << 16 | (int32_t) data[4] << 8 | (int32_t) data[3]; + int32_t raw_press = encode_uint24(data[5], data[4], data[3]); pressure = (float) (raw_press / 64.0); // Divide by 2^6=64 for Pa (page 22 of datasheet) return true; diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index 6ab786e7c71..5b3272b1d5e 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -1,4 +1,5 @@ #include "dps310.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -69,8 +70,7 @@ bool DPS310Component::setup_registers_() { (((uint32_t) coef_data_raw[5] >> 4) & 0x0F); this->c00_ = DPS310Component::twos_complement(c00_, 20); - this->c10_ = - (((uint32_t) coef_data_raw[5] & 0x0F) << 16) | ((uint32_t) coef_data_raw[6] << 8) | (uint32_t) coef_data_raw[7]; + this->c10_ = encode_uint24(coef_data_raw[5] & 0x0F, coef_data_raw[6], coef_data_raw[7]); this->c10_ = DPS310Component::twos_complement(c10_, 20); this->c01_ = int16_t(((uint16_t) coef_data_raw[8] << 8) | (uint16_t) coef_data_raw[9]); @@ -133,8 +133,7 @@ void DPS310Component::read_pressure_() { return; } this->got_pres_ = true; - this->raw_pressure_ = DPS310Component::twos_complement( - int32_t((uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2]))), 24); + this->raw_pressure_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); } void DPS310Component::read_temperature_() { @@ -144,8 +143,7 @@ void DPS310Component::read_temperature_() { return; } this->got_temp_ = true; - this->raw_temperature_ = DPS310Component::twos_complement( - int32_t((uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2]))), 24); + this->raw_temperature_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); } // Calculations are taken from the datasheet which can be found here: diff --git a/esphome/components/ens210/ens210.cpp b/esphome/components/ens210/ens210.cpp index 2251e0a574b..0d31a398177 100644 --- a/esphome/components/ens210/ens210.cpp +++ b/esphome/components/ens210/ens210.cpp @@ -7,6 +7,7 @@ // https://github.com/sciosense/ENS210_driver #include "ens210.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -160,7 +161,7 @@ void ENS210Component::update() { return; } // Pack bytes for humidity - h_val_data = (uint32_t) ((uint32_t) data[5] << 16 | (uint32_t) data[4] << 8 | (uint32_t) data[3]); + h_val_data = encode_uint24(data[5], data[4], data[3]); // Extract humidity data and update the status extract_measurement_(h_val_data, &humidity_data, &humidity_status); @@ -175,7 +176,7 @@ void ENS210Component::update() { return; } // Pack bytes for temperature - t_val_data = (uint32_t) ((uint32_t) data[2] << 16 | (uint32_t) data[1] << 8 | (uint32_t) data[0]); + t_val_data = encode_uint24(data[2], data[1], data[0]); // Extract temperature data and update the status extract_measurement_(t_val_data, &temperature_data, &temperature_status); diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 6b0a4b56094..e0d6f683f0d 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -988,7 +988,7 @@ void HOT IT8951Display::draw_pixels_internal(int x_start, int y_start, int w, in } case COLOR_BITNESS_888: { const size_t i = source_idx * 3; - color_value = ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); + color_value = encode_uint24(ptr[i + 2], ptr[i + 1], ptr[i]); break; } default: diff --git a/esphome/components/ms5611/ms5611.cpp b/esphome/components/ms5611/ms5611.cpp index e3e5194c415..29d483dd228 100644 --- a/esphome/components/ms5611/ms5611.cpp +++ b/esphome/components/ms5611/ms5611.cpp @@ -1,4 +1,5 @@ #include "ms5611.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -52,7 +53,7 @@ void MS5611Component::read_temperature_() { this->status_set_warning(); return; } - const uint32_t raw_temperature = (uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2])); + const uint32_t raw_temperature = encode_uint24(bytes[0], bytes[1], bytes[2]); // request pressure reading if (!this->write_bytes(MS5611_CMD_CONV_D1 + 0x08, nullptr, 0)) { @@ -68,7 +69,7 @@ void MS5611Component::read_pressure_(uint32_t raw_temperature) { this->status_set_warning(); return; } - const uint32_t raw_pressure = (uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | (uint32_t(bytes[2])); + const uint32_t raw_pressure = encode_uint24(bytes[0], bytes[1], bytes[2]); this->calculate_values_(raw_temperature, raw_pressure); } diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index 606a03e0c23..2c5e349bd31 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -1,4 +1,5 @@ #include "pzem004t.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/application.h" #include @@ -74,7 +75,7 @@ void PZEM004T::loop() { } case 0xA3: { // Energy Response - uint32_t energy = (uint32_t(resp[1]) << 16) | (uint32_t(resp[2]) << 8) | (uint32_t(resp[3])); + uint32_t energy = encode_uint24(resp[1], resp[2], resp[3]); if (this->energy_sensor_ != nullptr) this->energy_sensor_->publish_state(energy); ESP_LOGD(TAG, "Got Energy %" PRIu32 " Wh", energy); From 22a36de4aed7ef86840ed0a9e707a4d5c01f0308 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:20:58 -1000 Subject: [PATCH 104/179] [components] Decode little-endian I2C values with the new helpers (#20939) --- esphome/components/apds9930/apds9930.cpp | 7 +++--- esphome/components/as7341/as7341.cpp | 10 ++++---- esphome/components/as7341/as7341.h | 1 - esphome/components/as734x/as734x.cpp | 4 ++-- esphome/components/bh1745/bh1745.cpp | 13 +++++----- .../components/bme280_base/bme280_base.cpp | 4 ++-- esphome/components/bme280_base/bme280_base.h | 2 +- esphome/components/bme280_i2c/bme280_i2c.cpp | 4 ++-- esphome/components/bme280_i2c/bme280_i2c.h | 2 +- esphome/components/bme280_spi/bme280_spi.cpp | 7 +++--- esphome/components/bme280_spi/bme280_spi.h | 2 +- esphome/components/bmi160/bmi160.cpp | 17 ++----------- esphome/components/bmi160/bmi160.h | 3 --- esphome/components/bmi270/bmm150_aux.h | 2 +- .../components/bmp280_base/bmp280_base.cpp | 4 ++-- esphome/components/bmp280_base/bmp280_base.h | 2 +- esphome/components/bmp280_i2c/bmp280_i2c.h | 2 +- esphome/components/bmp280_spi/bmp280_spi.cpp | 7 +++--- esphome/components/bmp280_spi/bmp280_spi.h | 2 +- esphome/components/bq27220/bq27220.cpp | 11 +-------- esphome/components/bq27220/bq27220.h | 3 +-- esphome/components/bq76972/bq76972.cpp | 5 ++-- .../touchscreen/cst9220_touchscreen.cpp | 8 +++---- esphome/components/fusb302b/fusb302b.cpp | 8 +++---- .../gt911/touchscreen/gt911_touchscreen.cpp | 4 ++-- esphome/components/hdc2010/hdc2010.cpp | 5 ++-- esphome/components/hdc2080/hdc2080.cpp | 5 ++-- esphome/components/icm20689/icm20689.cpp | 14 +++++------ esphome/components/lis2dw12/lis2dw12.cpp | 6 ++--- esphome/components/lis3dh/lis3dh.cpp | 16 ++++++------- esphome/components/lps22/lps22.cpp | 3 ++- esphome/components/lsm6ds/lsm6ds.cpp | 18 +++++++------- esphome/components/mlx90614/mlx90614.cpp | 2 +- esphome/components/mpr121/mpr121.cpp | 7 +----- esphome/components/qmi8658/qmi8658.cpp | 14 +++++------ esphome/components/qmp6988/qmp6988.cpp | 20 ++++++++-------- .../spd2010/spd2010_touchscreen.cpp | 6 ++--- esphome/components/stts22h/stts22h.cpp | 3 ++- esphome/components/tca9555/tca9555.cpp | 24 +++++++------------ esphome/components/tcs34725/tcs34725.cpp | 12 +++++----- esphome/components/tfluna/tfluna.cpp | 8 +++---- esphome/components/tsl2561/tsl2561.cpp | 8 +------ esphome/components/veml3235/veml3235.cpp | 6 ++--- esphome/components/veml3235/veml3235.h | 10 +++----- esphome/components/xdb401/xdb401.cpp | 2 +- 45 files changed, 136 insertions(+), 187 deletions(-) diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp index a4b836beaa1..eec741d5938 100644 --- a/esphome/components/apds9930/apds9930.cpp +++ b/esphome/components/apds9930/apds9930.cpp @@ -1,5 +1,6 @@ #include "apds9930.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -111,8 +112,8 @@ void APDS9930Component::update() { const uint8_t status = raw[0]; if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) { - const uint16_t ch0 = encode_uint16(raw[2], raw[1]); - const uint16_t ch1 = encode_uint16(raw[4], raw[3]); + const uint16_t ch0 = encode_value_le(&raw[1]); + const uint16_t ch1 = encode_value_le(&raw[3]); if (ch0 >= ALS_SATURATION || ch1 >= ALS_SATURATION) { // A saturated channel would give a lux value that is too low, not too high ESP_LOGW(TAG, "Light channel saturated, lower ambient_light_gain"); @@ -125,7 +126,7 @@ void APDS9930Component::update() { } if (this->proximity_sensor_ != nullptr && (status & APDS9930_PVALID) != 0) { - this->proximity_sensor_->publish_state(encode_uint16(raw[6], raw[5])); + this->proximity_sensor_->publish_state(encode_value_le(&raw[5])); } } diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index cf02884c5e4..a40b8b507ce 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -95,16 +95,16 @@ uint8_t AS7341Component::get_atime() { } uint16_t AS7341Component::get_astep() { - uint16_t data; - this->read_byte_16(AS7341_ASTEP, &data); - return this->swap_bytes(data); + uint16_t data = 0; + this->read_byte_16_le(AS7341_ASTEP, &data); + return data; } bool AS7341Component::setup_gain(AS7341Gain gain) { return this->write_byte(AS7341_CFG1, gain); } bool AS7341Component::setup_atime(uint8_t atime) { return this->write_byte(AS7341_ATIME, atime); } -bool AS7341Component::setup_astep(uint16_t astep) { return this->write_byte_16(AS7341_ASTEP, swap_bytes(astep)); } +bool AS7341Component::setup_astep(uint16_t astep) { return this->write_byte_16_le(AS7341_ASTEP, astep); } bool AS7341Component::read_channels(uint16_t *data) { this->set_smux_low_channels(true); @@ -253,6 +253,4 @@ bool AS7341Component::clear_register_bit(uint8_t address, uint8_t bit_position) return this->write_byte(address, data); } -uint16_t AS7341Component::swap_bytes(uint16_t data) { return (data >> 8) | (data << 8); } - } // namespace esphome::as7341 diff --git a/esphome/components/as7341/as7341.h b/esphome/components/as7341/as7341.h index 2d72987f1cd..0ecc5dca419 100644 --- a/esphome/components/as7341/as7341.h +++ b/esphome/components/as7341/as7341.h @@ -118,7 +118,6 @@ class AS7341Component final : public PollingComponent, public i2c::I2CDevice { bool write_register_bit(uint8_t address, bool value, uint8_t bit_position); bool set_register_bit(uint8_t address, uint8_t bit_position); bool clear_register_bit(uint8_t address, uint8_t bit_position); - uint16_t swap_bytes(uint16_t data); protected: sensor::Sensor *f1_{nullptr}; diff --git a/esphome/components/as734x/as734x.cpp b/esphome/components/as734x/as734x.cpp index 30412be70f5..5be4f4808ac 100644 --- a/esphome/components/as734x/as734x.cpp +++ b/esphome/components/as734x/as734x.cpp @@ -277,7 +277,7 @@ bool AS734XComponent::update_bits_(uint8_t reg, uint8_t mask, bool value) { bool AS734XComponent::write_exposure_() { const ChipInfo *chip = this->chip_; if (!this->select_bank_(REG_ATIME) || !this->write_byte(REG_ATIME, this->atime_) || - !this->write_byte_16(pgm(chip->astep_reg), byteswap(this->astep_)) || + !this->write_byte_16_le(pgm(chip->astep_reg), this->astep_) || !this->write_byte(pgm(chip->cfg1_reg), this->gain_)) { return false; } @@ -459,7 +459,7 @@ void AS734XComponent::read_step_() { if (frame[0] & ASTATUS_ASAT) { ESP_LOGV(TAG, "Step %u saturated", this->step_); } - auto slot_value = [&frame](uint8_t slot) { return encode_uint16(frame[2 + 2 * slot], frame[1 + 2 * slot]); }; + auto slot_value = [&frame](uint8_t slot) { return encode_value_le(&frame[1 + 2 * slot]); }; const uint8_t *readout = pgm(this->chip_->readout) + this->step_ * NUM_BANDS; const uint8_t extra_clear = pgm(this->chip_->extra_clear_slot); for (uint8_t band = 0; band < NUM_BANDS; band++) { diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp index ee7b9935f4c..c34c2493310 100644 --- a/esphome/components/bh1745/bh1745.cpp +++ b/esphome/components/bh1745/bh1745.cpp @@ -133,18 +133,17 @@ void BH1745Component::update() { if ((mode_control2 & MODE_CONTROL2_VALID) == 0) return; - uint8_t buffer[BH1745_CHANNELS * 2]; - if (!this->read_bytes(REG_RED_DATA_LSB, buffer, sizeof(buffer))) { + uint16_t buffer[BH1745_CHANNELS]; + if (!this->read_bytes_16_le(REG_RED_DATA_LSB, buffer, BH1745_CHANNELS)) { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->status_clear_warning(); - // Each channel is sent low byte first. - const uint16_t red = encode_uint16(buffer[1], buffer[0]); - const uint16_t green = encode_uint16(buffer[3], buffer[2]); - const uint16_t blue = encode_uint16(buffer[5], buffer[4]); - const uint16_t clear = encode_uint16(buffer[7], buffer[6]); + const uint16_t red = buffer[0]; + const uint16_t green = buffer[1]; + const uint16_t blue = buffer[2]; + const uint16_t clear = buffer[3]; ESP_LOGV(TAG, "Red: %u, Green: %u, Blue: %u, Clear: %u", red, green, blue, clear); if (this->red_counts_sensor_ != nullptr) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index cf61160f77e..f922b1942b4 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -335,8 +335,8 @@ uint8_t BME280Component::read_u8_(uint8_t a_register) { } uint16_t BME280Component::read_u16_le_(uint8_t a_register) { uint16_t data = 0; - this->read_byte_16(a_register, &data); - return (data >> 8) | (data << 8); + this->read_byte_16_le(a_register, &data); + return data; } int16_t BME280Component::read_s16_le_(uint8_t a_register) { return this->read_u16_le_(a_register); } diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index d1a441ccebb..5ddb3cfd456 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -91,7 +91,7 @@ class BME280Component : public PollingComponent { virtual bool read_byte(uint8_t a_register, uint8_t *data) = 0; virtual bool write_byte(uint8_t a_register, uint8_t data) = 0; virtual bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; - virtual bool read_byte_16(uint8_t a_register, uint16_t *data) = 0; + virtual bool read_byte_16_le(uint8_t a_register, uint16_t *data) = 0; BME280CalibrationData calibration_; BME280Oversampling temperature_oversampling_{BME280_OVERSAMPLING_16X}; diff --git a/esphome/components/bme280_i2c/bme280_i2c.cpp b/esphome/components/bme280_i2c/bme280_i2c.cpp index f4380b0d343..40f2384852b 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.cpp +++ b/esphome/components/bme280_i2c/bme280_i2c.cpp @@ -16,8 +16,8 @@ bool BME280I2CComponent::write_byte(uint8_t a_register, uint8_t data) { bool BME280I2CComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t len) { return I2CDevice::read_bytes(a_register, data, len); }; -bool BME280I2CComponent::read_byte_16(uint8_t a_register, uint16_t *data) { - return I2CDevice::read_byte_16(a_register, data); +bool BME280I2CComponent::read_byte_16_le(uint8_t a_register, uint16_t *data) { + return I2CDevice::read_byte_16_le(a_register, data); }; void BME280I2CComponent::dump_config() { diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index 45c3e5b260f..a4817faece0 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -12,7 +12,7 @@ class BME280I2CComponent final : public esphome::bme280_base::BME280Component, p bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; - bool read_byte_16(uint8_t a_register, uint16_t *data) override; + bool read_byte_16_le(uint8_t a_register, uint16_t *data) override; void dump_config() override; }; diff --git a/esphome/components/bme280_spi/bme280_spi.cpp b/esphome/components/bme280_spi/bme280_spi.cpp index a4e7b7d95c3..b9473fb6985 100644 --- a/esphome/components/bme280_spi/bme280_spi.cpp +++ b/esphome/components/bme280_spi/bme280_spi.cpp @@ -3,6 +3,7 @@ #include "bme280_spi.h" #include +#include "esphome/core/helpers.h" namespace esphome::bme280_spi { @@ -51,11 +52,11 @@ bool BME280SPIComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t le return true; } -bool BME280SPIComponent::read_byte_16(uint8_t a_register, uint16_t *data) { +bool BME280SPIComponent::read_byte_16_le(uint8_t a_register, uint16_t *data) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); - ((uint8_t *) data)[1] = this->transfer_byte(0); - ((uint8_t *) data)[0] = this->transfer_byte(0); + const uint8_t lsb = this->transfer_byte(0); + *data = encode_uint16(this->transfer_byte(0), lsb); this->disable(); return true; } diff --git a/esphome/components/bme280_spi/bme280_spi.h b/esphome/components/bme280_spi/bme280_spi.h index 3879151ea13..98f39f44b38 100644 --- a/esphome/components/bme280_spi/bme280_spi.h +++ b/esphome/components/bme280_spi/bme280_spi.h @@ -12,7 +12,7 @@ class BME280SPIComponent final : public esphome::bme280_base::BME280Component, bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; - bool read_byte_16(uint8_t a_register, uint16_t *data) override; + bool read_byte_16_le(uint8_t a_register, uint16_t *data) override; }; } // namespace esphome::bme280_spi diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index b97baae06ae..2a522c7a8ce 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -196,19 +196,6 @@ void BMI160Component::dump_config() { LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); } -i2c::ErrorCode BMI160Component::read_le_int16_(uint8_t reg, int16_t *value, uint8_t len) { - uint8_t raw_data[len * 2]; - // read using read_register because we have little-endian data, and read_bytes_16 will swap it - i2c::ErrorCode err = this->read_register(reg, raw_data, len * 2); - if (err != i2c::ERROR_OK) { - return err; - } - for (int i = 0; i < len; i++) { - value[i] = (int16_t) ((uint16_t) raw_data[i * 2] | ((uint16_t) raw_data[i * 2 + 1] << 8)); - } - return err; -} - void BMI160Component::update() { if (!this->setup_complete_) { return; @@ -216,7 +203,7 @@ void BMI160Component::update() { ESP_LOGV(TAG, " Updating BMI160"); int16_t data[6]; - if (this->read_le_int16_(BMI160_REGISTER_DATA_GYRO_X_LSB, data, 6) != i2c::ERROR_OK) { + if (!this->read_bytes_16_le(BMI160_REGISTER_DATA_GYRO_X_LSB, data, 6)) { this->status_set_warning(); return; } @@ -229,7 +216,7 @@ void BMI160Component::update() { float accel_z = (float) data[5] / (float) INT16_MAX * 16 * GRAVITY_EARTH; int16_t raw_temperature; - if (this->read_le_int16_(BMI160_REGISTER_DATA_TEMP_LSB, &raw_temperature, 1) != i2c::ERROR_OK) { + if (!this->read_bytes_16_le(BMI160_REGISTER_DATA_TEMP_LSB, &raw_temperature, 1)) { this->status_set_warning(); return; } diff --git a/esphome/components/bmi160/bmi160.h b/esphome/components/bmi160/bmi160.h index 53704965cd9..eb0b553cc24 100644 --- a/esphome/components/bmi160/bmi160.h +++ b/esphome/components/bmi160/bmi160.h @@ -34,9 +34,6 @@ class BMI160Component final : public PollingComponent, public i2c::I2CDevice { /// Run one setup stage; false on an I2C failure. bool setup_stage_(int stage); bool setup_complete_{false}; - - /** reads `len` 16-bit little-endian integers from the given i2c register */ - i2c::ErrorCode read_le_int16_(uint8_t reg, int16_t *value, uint8_t len); }; } // namespace esphome::bmi160 diff --git a/esphome/components/bmi270/bmm150_aux.h b/esphome/components/bmi270/bmm150_aux.h index 747a0898fa8..3e1c288b77a 100644 --- a/esphome/components/bmi270/bmm150_aux.h +++ b/esphome/components/bmi270/bmm150_aux.h @@ -61,7 +61,7 @@ struct BMM150Trim { // A little-endian 16-bit trim word, addressed by its register inline uint16_t bmm150_trim_word(const uint8_t *regs, uint8_t reg) { const uint8_t offset = reg - BMM150_REG_TRIM_START; - return encode_uint16(regs[offset + 1], regs[offset]); + return encode_value_le(®s[offset]); } // Decodes the trim registers; `regs` holds the bytes of registers 0x5D onwards (at least 21). diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index d82f80ccd43..10be0585210 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -247,8 +247,8 @@ uint8_t BMP280Component::read_u8_(uint8_t a_register) { } uint16_t BMP280Component::read_u16_le_(uint8_t a_register) { uint16_t data = 0; - this->bmp_read_byte_16(a_register, &data); - return (data >> 8) | (data << 8); + this->bmp_read_byte_16_le(a_register, &data); + return data; } int16_t BMP280Component::read_s16_le_(uint8_t a_register) { return this->read_u16_le_(a_register); } diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 1c3fc9cf6cf..cee8f43c7c2 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -69,7 +69,7 @@ class BMP280Component : public PollingComponent { virtual bool bmp_read_byte(uint8_t a_register, uint8_t *data) = 0; virtual bool bmp_write_byte(uint8_t a_register, uint8_t data) = 0; virtual bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; - virtual bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) = 0; + virtual bool bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) = 0; /// Read the temperature value and store the calculated ambient temperature in t_fine. float read_temperature_(int32_t *t_fine); diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index f2ec93176ef..441d1a4565f 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -16,7 +16,7 @@ class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, p bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override { return read_bytes(a_register, data, len); } - bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) override { return read_byte_16(a_register, data); } + bool bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) override { return read_byte_16_le(a_register, data); } void dump_config() override; }; diff --git a/esphome/components/bmp280_spi/bmp280_spi.cpp b/esphome/components/bmp280_spi/bmp280_spi.cpp index 04f92f9b899..5008d191392 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.cpp +++ b/esphome/components/bmp280_spi/bmp280_spi.cpp @@ -3,6 +3,7 @@ #include "bmp280_spi.h" #include +#include "esphome/core/helpers.h" namespace esphome::bmp280_spi { @@ -51,11 +52,11 @@ bool BMP280SPIComponent::bmp_read_bytes(uint8_t a_register, uint8_t *data, size_ return true; } -bool BMP280SPIComponent::bmp_read_byte_16(uint8_t a_register, uint16_t *data) { +bool BMP280SPIComponent::bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) { this->enable(); this->transfer_byte(set_bit(a_register, 7)); - ((uint8_t *) data)[1] = this->transfer_byte(0); - ((uint8_t *) data)[0] = this->transfer_byte(0); + const uint8_t lsb = this->transfer_byte(0); + *data = encode_uint16(this->transfer_byte(0), lsb); this->disable(); return true; } diff --git a/esphome/components/bmp280_spi/bmp280_spi.h b/esphome/components/bmp280_spi/bmp280_spi.h index 449167811d3..fafc722c6cc 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.h +++ b/esphome/components/bmp280_spi/bmp280_spi.h @@ -12,7 +12,7 @@ class BMP280SPIComponent final : public esphome::bmp280_base::BMP280Component, bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; bool bmp_write_byte(uint8_t a_register, uint8_t data) override; bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; - bool bmp_read_byte_16(uint8_t a_register, uint16_t *data) override; + bool bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) override; }; } // namespace esphome::bmp280_spi diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp index b7912b930e6..3c78893437d 100644 --- a/esphome/components/bq27220/bq27220.cpp +++ b/esphome/components/bq27220/bq27220.cpp @@ -25,15 +25,6 @@ static constexpr uint8_t REG_MAC_DATA = 0x40; static constexpr uint16_t DEVICE_NUMBER = 0x0220; static constexpr uint16_t TIME_TO_EMPTY_NOT_APPLICABLE = 0xFFFF; -bool BQ27220Component::read_word_(uint8_t reg, uint16_t &value) { - uint8_t data[2]; - if (this->read_register(reg, data, 2) != i2c::ERROR_OK) { - return false; - } - value = encode_uint16(data[1], data[0]); - return true; -} - void BQ27220Component::setup() { const uint8_t device_number_cmd[2] = {0x01, 0x00}; // DEVICE_NUMBER subcommand, little-endian if (this->write_register(REG_CONTROL, device_number_cmd, 2) != i2c::ERROR_OK) { @@ -42,7 +33,7 @@ void BQ27220Component::setup() { } delay(2); // SLUUBD4A §5.3: the subcommand result needs a moment before it can be read uint16_t device_number = 0; - if (!this->read_word_(REG_MAC_DATA, device_number)) { + if (!this->read_byte_16_le(REG_MAC_DATA, &device_number)) { this->mark_failed(LOG_STR("Failed to read device number from BQ27220")); return; } diff --git a/esphome/components/bq27220/bq27220.h b/esphome/components/bq27220/bq27220.h index d88b76a20f0..62cc4b659d0 100644 --- a/esphome/components/bq27220/bq27220.h +++ b/esphome/components/bq27220/bq27220.h @@ -25,14 +25,13 @@ class BQ27220Component final : public PollingComponent, public i2c::I2CDevice { void dump_config() override; protected: - bool read_word_(uint8_t reg, uint16_t &value); /// Read a standard command register and publish it through the sensor, if one is configured. /// A failed read publishes NAN and clears `ok`. template void publish_(sensor::Sensor *sensor, uint8_t reg, bool &ok, F convert) { if (sensor == nullptr) return; uint16_t raw; - if (!this->read_word_(reg, raw)) { + if (!this->read_byte_16_le(reg, &raw)) { ok = false; sensor->publish_state(NAN); return; diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp index 9426fbb2526..59d18878370 100644 --- a/esphome/components/bq76972/bq76972.cpp +++ b/esphome/components/bq76972/bq76972.cpp @@ -80,11 +80,10 @@ bool BQ76972Component::write_block_(uint8_t start_register, const uint8_t *data, } bool BQ76972Component::read_words_(uint8_t start_register, uint16_t *data, size_t num_words) { - std::array bytes; - if (num_words * 2 > bytes.size() || !this->read_block_(start_register, bytes.data(), num_words * 2)) + if (!this->read_block_(start_register, reinterpret_cast(data), num_words * 2)) return false; for (size_t i = 0; i < num_words; i++) - data[i] = encode_uint16(bytes[2 * i + 1], bytes[2 * i]); + data[i] = convert_little_endian(data[i]); return true; } diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp index 366b1846d73..72803804335 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -51,9 +51,9 @@ void CST9220Touchscreen::continue_setup_() { // Read the panel resolution unless the user supplied calibration values. if (this->read_register16(REG_RESOLUTION, buffer, 4) == i2c::ERROR_OK) { if (this->x_raw_max_ == this->x_raw_min_) - this->x_raw_max_ = encode_uint16(buffer[1], buffer[0]); + this->x_raw_max_ = encode_value_le(buffer); if (this->y_raw_max_ == this->y_raw_min_) - this->y_raw_max_ = encode_uint16(buffer[3], buffer[2]); + this->y_raw_max_ = encode_value_le(&buffer[2]); } // Read the chip type and project id and validate the controller. @@ -62,8 +62,8 @@ void CST9220Touchscreen::continue_setup_() { this->mark_failed(); return; } - this->chip_id_ = encode_uint16(buffer[3], buffer[2]); - this->project_id_ = encode_uint16(buffer[1], buffer[0]); + this->chip_id_ = encode_value_le(&buffer[2]); + this->project_id_ = encode_value_le(buffer); if (this->chip_id_ != CST9220_CHIP_ID && this->chip_id_ != CST9217_CHIP_ID) { ESP_LOGE(TAG, "Unknown chip ID: 0x%04X", this->chip_id_); this->status_set_error(LOG_STR("Unknown chip ID")); diff --git a/esphome/components/fusb302b/fusb302b.cpp b/esphome/components/fusb302b/fusb302b.cpp index 479981f8cac..608b356bbac 100644 --- a/esphome/components/fusb302b/fusb302b.cpp +++ b/esphome/components/fusb302b/fusb302b.cpp @@ -343,16 +343,14 @@ bool FUSB302B::read_message_(PdMsg &msg, bool &is_sop) { uint8_t header[2]; if (!this->read_byte(FUSB_FIFOS, &token) || this->read_register(FUSB_FIFOS, header, 2) != i2c::ERROR_OK) return false; - msg.set_header(encode_uint16(header[1], header[0])); + msg.set_header(encode_value_le(header)); // Data objects, followed by the CRC32 that the chip already checked uint8_t buf[PD_MAX_NUM_DATA_OBJECTS * 4 + 4]; if (this->read_register(FUSB_FIFOS, buf, msg.num_of_obj * 4 + 4) != i2c::ERROR_OK) return false; - for (uint8_t i = 0; i < msg.num_of_obj; i++) { - const uint8_t *obj = &buf[i * 4]; - msg.data_objects[i] = encode_uint32(obj[3], obj[2], obj[1], obj[0]); - } + for (uint8_t i = 0; i < msg.num_of_obj; i++) + msg.data_objects[i] = encode_value_le(&buf[i * 4]); is_sop = (token & FUSB_FIFO_RX_TOKEN_BITS) == FUSB_FIFO_RX_SOP; return true; } diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 388b8165fd7..fe251508e27 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -90,8 +90,8 @@ void GT911Touchscreen::setup_internal_() { if (err == i2c::ERROR_OK) { err = this->read(data, sizeof(data)); if (err == i2c::ERROR_OK) { - this->x_raw_max_ = encode_uint16(data[1], data[0]); - this->y_raw_max_ = encode_uint16(data[3], data[2]); + this->x_raw_max_ = encode_value_le(data); + this->y_raw_max_ = encode_value_le(&data[2]); if (this->swap_x_y_) std::swap(this->x_raw_max_, this->y_raw_max_); } diff --git a/esphome/components/hdc2010/hdc2010.cpp b/esphome/components/hdc2010/hdc2010.cpp index 471afe515a1..ecae9909390 100644 --- a/esphome/components/hdc2010/hdc2010.cpp +++ b/esphome/components/hdc2010/hdc2010.cpp @@ -1,4 +1,5 @@ #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "hdc2010.h" // https://github.com/vigsterkr/homebridge-hdc2010/blob/main/src/hdc2010.js // https://github.com/lime-labs/HDC2080-Arduino/blob/master/src/HDC2080.cpp @@ -76,7 +77,7 @@ float HDC2010Component::read_temp() { this->read_register(REG_TEMPERATURE_LOW, &byte[0], 1); this->read_register(REG_TEMPERATURE_HIGH, &byte[1], 1); - uint16_t temp = encode_uint16(byte[1], byte[0]); + uint16_t temp = encode_value_le(byte); return (float) temp * 0.0025177f - 40.0f; } @@ -86,7 +87,7 @@ float HDC2010Component::read_humidity() { this->read_register(REG_HUMIDITY_LOW, &byte[0], 1); this->read_register(REG_HUMIDITY_HIGH, &byte[1], 1); - uint16_t humidity = encode_uint16(byte[1], byte[0]); + uint16_t humidity = encode_value_le(byte); return (float) humidity * 0.001525879f; } diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index beb84f30746..8672dc497f5 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -1,5 +1,6 @@ #include "hdc2080.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::hdc2080 { @@ -55,11 +56,11 @@ void HDC2080Component::update() { } this->status_clear_warning(); if (this->temperature_sensor_ != nullptr) { - float temp = encode_uint16(raw_data[1], raw_data[0]) * (165.0f / 65536.0f) - 40.5f; + float temp = encode_value_le(raw_data) * (165.0f / 65536.0f) - 40.5f; this->temperature_sensor_->publish_state(temp); } if (this->humidity_sensor_ != nullptr) { - float humidity = encode_uint16(raw_data[3], raw_data[2]) * (100.0f / 65536.0f); + float humidity = encode_value_le(&raw_data[2]) * (100.0f / 65536.0f); this->humidity_sensor_->publish_state(humidity); } }); diff --git a/esphome/components/icm20689/icm20689.cpp b/esphome/components/icm20689/icm20689.cpp index 3bca35dc254..69cb69c1f25 100644 --- a/esphome/components/icm20689/icm20689.cpp +++ b/esphome/components/icm20689/icm20689.cpp @@ -101,19 +101,19 @@ bool ICM20689Component::update_data(motion::MotionData &data) { const float accel_factor = accel_scale(accel_range); - data.acceleration[motion::X_AXIS] = static_cast(encode_uint16(raw_data[0], raw_data[1])) * accel_factor; - data.acceleration[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[2], raw_data[3])) * accel_factor; - data.acceleration[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[4], raw_data[5])) * accel_factor; + data.acceleration[motion::X_AXIS] = encode_value(raw_data) * accel_factor; + data.acceleration[motion::Y_AXIS] = encode_value(&raw_data[2]) * accel_factor; + data.acceleration[motion::Z_AXIS] = encode_value(&raw_data[4]) * accel_factor; const float gyro_factor = gyro_scale(gyro_range); - data.angular_rate[motion::X_AXIS] = static_cast(encode_uint16(raw_data[8], raw_data[9])) * gyro_factor; - data.angular_rate[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[10], raw_data[11])) * gyro_factor; - data.angular_rate[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[12], raw_data[13])) * gyro_factor; + data.angular_rate[motion::X_AXIS] = encode_value(&raw_data[8]) * gyro_factor; + data.angular_rate[motion::Y_AXIS] = encode_value(&raw_data[10]) * gyro_factor; + data.angular_rate[motion::Z_AXIS] = encode_value(&raw_data[12]) * gyro_factor; if (!this->temperature_callback_.empty()) { // Datasheet formula: temperature (°C) = raw / 326.8 + 25. - const float temperature = static_cast(encode_uint16(raw_data[6], raw_data[7])) / 326.8f + 25.0f; + const float temperature = encode_value(&raw_data[6]) / 326.8f + 25.0f; this->temperature_callback_.call(temperature); } diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp index 21e1a07676f..7af60be87a4 100644 --- a/esphome/components/lis2dw12/lis2dw12.cpp +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -214,9 +214,9 @@ bool LIS2DW12Component::read_acceleration_data_(float &x, float &y, float &z) { return false; } - int16_t raw_x = static_cast(encode_uint16(accel_data[1], accel_data[0])) >> this->shift_; - int16_t raw_y = static_cast(encode_uint16(accel_data[3], accel_data[2])) >> this->shift_; - int16_t raw_z = static_cast(encode_uint16(accel_data[5], accel_data[4])) >> this->shift_; + int16_t raw_x = encode_value_le(accel_data) >> this->shift_; + int16_t raw_y = encode_value_le(&accel_data[2]) >> this->shift_; + int16_t raw_z = encode_value_le(&accel_data[4]) >> this->shift_; if (this->swap_xy_) { std::swap(raw_x, raw_y); diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp index 64e6da4ef95..f5ade6ab0b3 100644 --- a/esphome/components/lis3dh/lis3dh.cpp +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -144,8 +144,8 @@ bool LIS3DHComponent::update_data(motion::MotionData &data) { if (this->is_failed()) return false; - uint8_t raw[6]; - if (!this->read_bytes(LIS3DH_REG_OUT_X_L | LIS3DH_AUTO_INCREMENT, raw, 6)) { + int16_t raw[3]; + if (!this->read_bytes_16_le(LIS3DH_REG_OUT_X_L | LIS3DH_AUTO_INCREMENT, raw, 3)) { this->status_set_warning(LOG_STR("Failed to read acceleration data")); return false; } @@ -154,9 +154,9 @@ bool LIS3DHComponent::update_data(motion::MotionData &data) { progmem_memcpy(&scale, &SCALE_G[this->range_], sizeof(scale)); // Data is little-endian and left-justified in a signed 16-bit register. - int16_t raw_x = (int16_t) encode_uint16(raw[1], raw[0]); - int16_t raw_y = (int16_t) encode_uint16(raw[3], raw[2]); - int16_t raw_z = (int16_t) encode_uint16(raw[5], raw[4]); + int16_t raw_x = raw[0]; + int16_t raw_y = raw[1]; + int16_t raw_z = raw[2]; ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); data.acceleration[motion::X_AXIS] = raw_x * scale; data.acceleration[motion::Y_AXIS] = raw_y * scale; @@ -168,14 +168,14 @@ bool LIS3DHComponent::update_data(motion::MotionData &data) { } bool LIS3DHComponent::publish_temperature_() { - uint8_t raw[2]; - if (!this->read_bytes(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, raw, 2)) { + int16_t raw; + if (!this->read_bytes_16_le(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, &raw, 1)) { this->status_set_warning(LOG_STR("Failed to read temperature data")); return false; } // Signed, left-justified, relative value with 1 °C per count of the high byte; the // low byte carries the fractional bits in the modes that have them. - float temperature = (int16_t) encode_uint16(raw[1], raw[0]) / 256.0f + LIS3DH_TEMP_REFERENCE_C; + float temperature = raw / 256.0f + LIS3DH_TEMP_REFERENCE_C; this->temperature_callback_.call(temperature); return true; } diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp index 57f801c332b..47b00057c69 100644 --- a/esphome/components/lps22/lps22.cpp +++ b/esphome/components/lps22/lps22.cpp @@ -1,4 +1,5 @@ #include "lps22.h" +#include "esphome/core/helpers.h" namespace esphome::lps22 { @@ -65,7 +66,7 @@ void LPS22Component::try_read_() { if (this->temperature_sensor_ != nullptr) { uint8_t t_buf[2]{0}; this->read_register(TEMP_L, t_buf, 2); - int16_t encoded = static_cast(encode_uint16(t_buf[1], t_buf[0])); + int16_t encoded = encode_value_le(t_buf); float temp = TEMPERATURE_SCALE * static_cast(encoded); this->temperature_sensor_->publish_state(temp); } diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp index e34735a4160..1d284d54ef0 100644 --- a/esphome/components/lsm6ds/lsm6ds.cpp +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -1,6 +1,7 @@ #include "lsm6ds.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/progmem.h" namespace esphome::lsm6ds { @@ -166,9 +167,9 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { }; float gyro_scale = GYRO_SCALE[this->gyro_range_]; - data.angular_rate[motion::X_AXIS] = (int16_t) ((raw[1] << 8) | raw[0]) * gyro_scale; - data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw[3] << 8) | raw[2]) * gyro_scale; - data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw[5] << 8) | raw[4]) * gyro_scale; + data.angular_rate[motion::X_AXIS] = encode_value_le(raw) * gyro_scale; + data.angular_rate[motion::Y_AXIS] = encode_value_le(&raw[2]) * gyro_scale; + data.angular_rate[motion::Z_AXIS] = encode_value_le(&raw[4]) * gyro_scale; // Accelerometer // Sensitivity (mg/LSB) from datasheet Table 3. @@ -182,12 +183,9 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { }; float accel_scale = ACCEL_SCALE[this->accel_range_]; - data.acceleration[motion::X_AXIS] = - (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 1] << 8) | raw[LSM6DS_ACCEL_OFFSET + 0]) * accel_scale; - data.acceleration[motion::Y_AXIS] = - (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 3] << 8) | raw[LSM6DS_ACCEL_OFFSET + 2]) * accel_scale; - data.acceleration[motion::Z_AXIS] = - (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 5] << 8) | raw[LSM6DS_ACCEL_OFFSET + 4]) * accel_scale; + data.acceleration[motion::X_AXIS] = encode_value_le(&raw[LSM6DS_ACCEL_OFFSET]) * accel_scale; + data.acceleration[motion::Y_AXIS] = encode_value_le(&raw[LSM6DS_ACCEL_OFFSET + 2]) * accel_scale; + data.acceleration[motion::Z_AXIS] = encode_value_le(&raw[LSM6DS_ACCEL_OFFSET + 4]) * accel_scale; // Temperature (lazy — only read if a listener is registered) // Kept as a separate 2-byte read to avoid extending the burst to 14 bytes when @@ -196,7 +194,7 @@ bool LSM6DSComponent::update_data(motion::MotionData &data) { if (!this->temperature_callback_.empty()) { uint8_t raw_t[2]; if (this->read_bytes(LSM6DS_REG_OUT_TEMP_L, raw_t, 2)) { - int16_t temp_raw = (int16_t) ((raw_t[1] << 8) | raw_t[0]); + int16_t temp_raw = encode_value_le(raw_t); float temperature = temp_raw / static_cast(this->temperature_sensitivity_) + 25.0f; this->temperature_callback_.call(temperature); } diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index a67d770cc3d..353057dc34d 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -140,7 +140,7 @@ i2c::ErrorCode MLX90614Component::read_register_(uint8_t reg, uint16_t &data) { return i2c::ERROR_CRC; } - data = encode_uint16(buf[4], buf[3]); + data = encode_value_le(&buf[3]); return i2c::ERROR_OK; } diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index 3d9cded59ff..bbcee223e8c 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -72,12 +72,7 @@ void MPR121Component::dump_config() { } void MPR121Component::loop() { uint16_t val = 0; - this->read_byte_16(MPR121_TOUCHSTATUS_L, &val); - - // Flip order - uint8_t lsb = val >> 8; - uint8_t msb = val; - val = (uint16_t(msb) << 8) | lsb; + this->read_byte_16_le(MPR121_TOUCHSTATUS_L, &val); for (auto *channel : this->channels_) channel->process(val); diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index f03aac9a80c..f6690e0af6b 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -99,18 +99,18 @@ bool QMI8658Component::update_data(motion::MotionData &data) { // Data is little-endian (low byte first). float scale = ACCEL_SCALE[this->accel_range_ >> 4]; - int16_t raw_x = encode_uint16(raw_data[ACC_OFFS + 1], raw_data[ACC_OFFS + 0]); - int16_t raw_y = encode_uint16(raw_data[ACC_OFFS + 3], raw_data[ACC_OFFS + 2]); - int16_t raw_z = encode_uint16(raw_data[ACC_OFFS + 5], raw_data[ACC_OFFS + 4]); + int16_t raw_x = encode_value_le(&raw_data[ACC_OFFS]); + int16_t raw_y = encode_value_le(&raw_data[ACC_OFFS + 2]); + int16_t raw_z = encode_value_le(&raw_data[ACC_OFFS + 4]); ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); data.acceleration[motion::X_AXIS] = raw_x * scale; data.acceleration[motion::Y_AXIS] = raw_y * scale; data.acceleration[motion::Z_AXIS] = raw_z * scale; scale = GYRO_SCALE[this->gyro_range_ >> 4]; - raw_x = encode_uint16(raw_data[GYR_OFFS + 1], raw_data[GYR_OFFS + 0]); - raw_y = encode_uint16(raw_data[GYR_OFFS + 3], raw_data[GYR_OFFS + 2]); - raw_z = encode_uint16(raw_data[GYR_OFFS + 5], raw_data[GYR_OFFS + 4]); + raw_x = encode_value_le(&raw_data[GYR_OFFS]); + raw_y = encode_value_le(&raw_data[GYR_OFFS + 2]); + raw_z = encode_value_le(&raw_data[GYR_OFFS + 4]); ESP_LOGV(TAG, "Read raw gyro data: %d, %d, %d", raw_x, raw_y, raw_z); data.angular_rate[motion::X_AXIS] = raw_x * scale; data.angular_rate[motion::Y_AXIS] = raw_y * scale; @@ -119,7 +119,7 @@ bool QMI8658Component::update_data(motion::MotionData &data) { if (this->temperature_callback_.empty()) return true; // Temperature: signed 16-bit, °C = raw / 256 - int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + int16_t raw_t = encode_value_le(&raw_data[TEMP_OFFS]); this->temperature_callback_.call(raw_t / 256.0f); return true; } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index ea3bf19eb4c..9dce402fe02 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -112,21 +112,21 @@ bool QMP6988Component::get_calibration_data_() { (int32_t) encode_uint32(a_data_uint8_tr[18], a_data_uint8_tr[19], (a_data_uint8_tr[24] & 0x0f) << 4, 0); qmp6988_data_.qmp6988_cali.COE_a0 = qmp6988_data_.qmp6988_cali.COE_a0 >> 12; - qmp6988_data_.qmp6988_cali.COE_a1 = (int16_t) encode_uint16(a_data_uint8_tr[20], a_data_uint8_tr[21]); - qmp6988_data_.qmp6988_cali.COE_a2 = (int16_t) encode_uint16(a_data_uint8_tr[22], a_data_uint8_tr[23]); + qmp6988_data_.qmp6988_cali.COE_a1 = encode_value(&a_data_uint8_tr[20]); + qmp6988_data_.qmp6988_cali.COE_a2 = encode_value(&a_data_uint8_tr[22]); qmp6988_data_.qmp6988_cali.COE_b00 = (int32_t) encode_uint32(a_data_uint8_tr[0], a_data_uint8_tr[1], a_data_uint8_tr[24] & 0xf0, 0); qmp6988_data_.qmp6988_cali.COE_b00 = qmp6988_data_.qmp6988_cali.COE_b00 >> 12; - qmp6988_data_.qmp6988_cali.COE_bt1 = (int16_t) encode_uint16(a_data_uint8_tr[2], a_data_uint8_tr[3]); - qmp6988_data_.qmp6988_cali.COE_bt2 = (int16_t) encode_uint16(a_data_uint8_tr[4], a_data_uint8_tr[5]); - qmp6988_data_.qmp6988_cali.COE_bp1 = (int16_t) encode_uint16(a_data_uint8_tr[6], a_data_uint8_tr[7]); - qmp6988_data_.qmp6988_cali.COE_b11 = (int16_t) encode_uint16(a_data_uint8_tr[8], a_data_uint8_tr[9]); - qmp6988_data_.qmp6988_cali.COE_bp2 = (int16_t) encode_uint16(a_data_uint8_tr[10], a_data_uint8_tr[11]); - qmp6988_data_.qmp6988_cali.COE_b12 = (int16_t) encode_uint16(a_data_uint8_tr[12], a_data_uint8_tr[13]); - qmp6988_data_.qmp6988_cali.COE_b21 = (int16_t) encode_uint16(a_data_uint8_tr[14], a_data_uint8_tr[15]); - qmp6988_data_.qmp6988_cali.COE_bp3 = (int16_t) encode_uint16(a_data_uint8_tr[16], a_data_uint8_tr[17]); + qmp6988_data_.qmp6988_cali.COE_bt1 = encode_value(&a_data_uint8_tr[2]); + qmp6988_data_.qmp6988_cali.COE_bt2 = encode_value(&a_data_uint8_tr[4]); + qmp6988_data_.qmp6988_cali.COE_bp1 = encode_value(&a_data_uint8_tr[6]); + qmp6988_data_.qmp6988_cali.COE_b11 = encode_value(&a_data_uint8_tr[8]); + qmp6988_data_.qmp6988_cali.COE_bp2 = encode_value(&a_data_uint8_tr[10]); + qmp6988_data_.qmp6988_cali.COE_b12 = encode_value(&a_data_uint8_tr[12]); + qmp6988_data_.qmp6988_cali.COE_b21 = encode_value(&a_data_uint8_tr[14]); + qmp6988_data_.qmp6988_cali.COE_bp3 = encode_value(&a_data_uint8_tr[16]); ESP_LOGV(TAG, "Calibration data:\n" diff --git a/esphome/components/spd2010/spd2010_touchscreen.cpp b/esphome/components/spd2010/spd2010_touchscreen.cpp index 94fad87d7d4..55fa177d18e 100644 --- a/esphome/components/spd2010/spd2010_touchscreen.cpp +++ b/esphome/components/spd2010/spd2010_touchscreen.cpp @@ -88,7 +88,7 @@ void SPD2010Touchscreen::initialize_() { this->mark_failed(LOG_STR("Failed to read firmware version")); return; } - ESP_LOGD(TAG, "Firmware version: 0x%04X", encode_uint16(version[5], version[4])); + ESP_LOGD(TAG, "Firmware version: 0x%04X", encode_value_le(&version[4])); if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); @@ -104,7 +104,7 @@ bool SPD2010Touchscreen::finish_report_() { return false; if (status[5] == HDP_STATUS_DONE) return this->clear_interrupt_(); - const uint16_t length = encode_uint16(status[3], status[2]); + const uint16_t length = encode_value_le(&status[2]); if (status[5] != HDP_STATUS_MORE || length == 0 || length > MAX_REPORT_LENGTH) break; uint8_t remaining[MAX_REPORT_LENGTH]; @@ -125,7 +125,7 @@ bool SPD2010Touchscreen::read_data_() { } const uint8_t status_low = status[0]; const uint8_t status_high = status[1]; - const uint16_t length = encode_uint16(status[3], status[2]); + const uint16_t length = encode_value_le(&status[2]); if (status_high & STATUS_BIOS) { this->status_set_warning(LOG_STR("BIOS status; start CPU")); diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index fa96c6a0227..aee4fc9c204 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -1,3 +1,4 @@ +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "stts22h.h" @@ -48,7 +49,7 @@ float STTS22HComponent::read_temperature_() { // Combine the two bytes into a single 16-bit signed integer // The STTS22H temperature data is in two's complement format - int16_t temp_raw_value = static_cast(encode_uint16(temp_reg_value[1], temp_reg_value[0])); + int16_t temp_raw_value = encode_value_le(temp_reg_value); return temp_raw_value * SENSOR_SCALE; // Apply sensor resolution } diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 2b234983a20..15272e614fb 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -64,12 +64,12 @@ void TCA9555Component::loop() { bool TCA9555Component::read_gpio_outputs_() { if (this->is_failed()) return false; - uint8_t data[2]; - if (!this->read_bytes(TCA9555_OUTPUT_PORT_REGISTER_0, data, 2)) { + uint16_t value; + if (!this->read_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, &value)) { this->status_set_warning(LOG_STR("Failed to read output register")); return false; } - this->output_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); + this->output_mask_ = value; this->status_clear_warning(); return true; } @@ -77,13 +77,12 @@ bool TCA9555Component::read_gpio_outputs_() { bool TCA9555Component::read_gpio_modes_() { if (this->is_failed()) return false; - uint8_t data[2]; - bool success = this->read_bytes(TCA9555_CONFIGURATION_PORT_0, data, 2); - if (!success) { + uint16_t value; + if (!this->read_byte_16_le(TCA9555_CONFIGURATION_PORT_0, &value)) { this->status_set_warning(LOG_STR("Failed to read mode register")); return false; } - this->mode_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); + this->mode_mask_ = value; this->status_clear_warning(); return true; @@ -120,10 +119,7 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) { this->output_mask_ &= ~(1 << pin); } - uint8_t data[2]; - data[0] = this->output_mask_; - data[1] = this->output_mask_ >> 8; - if (!this->write_bytes(TCA9555_OUTPUT_PORT_REGISTER_0, data, 2)) { + if (!this->write_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, this->output_mask_)) { this->status_set_warning(LOG_STR("Failed to write output register")); return; } @@ -134,11 +130,7 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) { bool TCA9555Component::write_gpio_modes_() { if (this->is_failed()) return false; - uint8_t data[2]; - - data[0] = this->mode_mask_; - data[1] = this->mode_mask_ >> 8; - if (!this->write_bytes(TCA9555_CONFIGURATION_PORT_0, data, 2)) { + if (!this->write_byte_16_le(TCA9555_CONFIGURATION_PORT_0, this->mode_mask_)) { this->status_set_warning(LOG_STR("Failed to write mode register")); return false; } diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 979137a9729..a8497e7a446 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -177,20 +177,20 @@ void TCS34725Component::calculate_temperature_and_lux_(uint16_t r, uint16_t g, u } void TCS34725Component::update() { - uint8_t data[8]; // Buffer to hold the 8 bytes (2 bytes for each of the 4 channels) + uint16_t data[4]; // Clear, red, green and blue channels // Perform burst - if (this->read_register(TCS34725_REGISTER_CRGBDATAL, data, 8) != i2c::ERROR_OK) { + if (!this->read_bytes_16_le(TCS34725_REGISTER_CRGBDATAL, data, 4)) { this->status_set_warning(); ESP_LOGW(TAG, "Error reading TCS34725 sensor data"); return; } // Extract the data - uint16_t raw_c = encode_uint16(data[1], data[0]); // Clear channel - uint16_t raw_r = encode_uint16(data[3], data[2]); // Red channel - uint16_t raw_g = encode_uint16(data[5], data[4]); // Green channel - uint16_t raw_b = encode_uint16(data[7], data[6]); // Blue channel + uint16_t raw_c = data[0]; // Clear channel + uint16_t raw_r = data[1]; // Red channel + uint16_t raw_g = data[2]; // Green channel + uint16_t raw_b = data[3]; // Blue channel ESP_LOGV(TAG, "Raw values clear=%d red=%d green=%d blue=%d", raw_c, raw_r, raw_g, raw_b); diff --git a/esphome/components/tfluna/tfluna.cpp b/esphome/components/tfluna/tfluna.cpp index 706213024d6..ac168327e09 100644 --- a/esphome/components/tfluna/tfluna.cpp +++ b/esphome/components/tfluna/tfluna.cpp @@ -94,10 +94,10 @@ bool TFLuna::read_data_() { // Layout: // buf[0..1] = distance (LE), buf[2..3] = signal (LE), // buf[4..5] = temperature (LE), buf[6..7] = timestamp (LE) - uint16_t distance = encode_uint16(buf[1], buf[0]); - uint16_t signal_strength = encode_uint16(buf[3], buf[2]); - int16_t temperature_raw = encode_uint16(buf[5], buf[4]); - uint16_t timestamp = encode_uint16(buf[7], buf[6]); + uint16_t distance = encode_value_le(buf); + uint16_t signal_strength = encode_value_le(&buf[2]); + int16_t temperature_raw = encode_value_le(&buf[4]); + uint16_t timestamp = encode_value_le(&buf[6]); if (timestamp == this->previous_timestamp_) { return false; diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index f887b7d32ca..0ae96c9fd1c 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -148,13 +148,7 @@ bool TSL2561Sensor::tsl2561_write_byte(uint8_t a_register, uint8_t value) { return this->write_byte(a_register | TSL2561_COMMAND_BIT, value); } bool TSL2561Sensor::tsl2561_read_uint(uint8_t a_register, uint16_t *value) { - uint8_t data[2]; - if (!this->read_bytes(a_register | TSL2561_COMMAND_BIT, data, 2)) - return false; - const uint16_t hi = data[1]; - const uint16_t lo = data[0]; - *value = (hi << 8) | lo; - return true; + return this->read_byte_16_le(a_register | TSL2561_COMMAND_BIT, value); } bool TSL2561Sensor::tsl2561_read_byte(uint8_t a_register, uint8_t *value) { return this->read_byte(a_register | TSL2561_COMMAND_BIT, value); diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index 09340f3bdee..ffaaf9eb2a3 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -33,14 +33,14 @@ void VEML3235Sensor::setup() { } bool VEML3235Sensor::refresh_config_reg() { - uint16_t data = 0x1; // mandatory 1 per RM; shutdown bits cleared (device powered on) + uint16_t data = 0x100; // mandatory 1 per RM; shutdown bits cleared (device powered on) data |= (uint16_t(this->integration_time_) << CONFIG_REG_IT_BIT); data |= (uint16_t(this->digital_gain_) << CONFIG_REG_DG_BIT); data |= (uint16_t(this->gain_) << CONFIG_REG_G_BIT); ESP_LOGVV(TAG, "Writing 0x%.4x to register 0x%.2x", data, CONFIG_REG); - return this->write_byte_16(CONFIG_REG, data); + return this->write_byte_16_le(CONFIG_REG, data); } void VEML3235Sensor::update() { @@ -63,7 +63,7 @@ void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { this->status_clear_warning(); - uint16_t als_counts = encode_uint16(als_regs[1], als_regs[0]); + uint16_t als_counts = encode_value_le(als_regs); if (this->auto_gain_ && adjustments_left > 0) { // A sample integrated with the previous settings may still be in the data register after the diff --git a/esphome/components/veml3235/veml3235.h b/esphome/components/veml3235/veml3235.h index c19fc17b65f..fb00a30326d 100644 --- a/esphome/components/veml3235/veml3235.h +++ b/esphome/components/veml3235/veml3235.h @@ -15,13 +15,9 @@ static const uint8_t ID_REG = 0x09; // Bit offsets within CONFIG_REG // -// The device expects the low data byte first while write_byte_16() sends the high byte first, so the 16-bit -// configuration word used here is byte-swapped relative to the datasheet: datasheet low-byte bits are word -// bits 15:8 here and datasheet high-byte bits are word bits 7:0. -// -static const uint8_t CONFIG_REG_IT_BIT = 12; -static const uint8_t CONFIG_REG_DG_BIT = 5; -static const uint8_t CONFIG_REG_G_BIT = 3; +static const uint8_t CONFIG_REG_IT_BIT = 4; +static const uint8_t CONFIG_REG_DG_BIT = 13; +static const uint8_t CONFIG_REG_G_BIT = 11; // Other important constants // diff --git a/esphome/components/xdb401/xdb401.cpp b/esphome/components/xdb401/xdb401.cpp index 11a1a569f3f..721300bae74 100644 --- a/esphome/components/xdb401/xdb401.cpp +++ b/esphome/components/xdb401/xdb401.cpp @@ -149,7 +149,7 @@ i2c::ErrorCode XDB401Component::read_temperature_(float &temperature) { ESP_LOGV(TAG, "Got temperature data: %s", temperature_buf); // Temperature is a signed 16-bit big-endian value in 1/256 °C (Q8.8 fixed point). - int16_t raw_temperature = static_cast(encode_uint16(t_data[0], t_data[1])); + int16_t raw_temperature = encode_value(t_data); ESP_LOGD(TAG, "Temperature data raw %i", raw_temperature); temperature = static_cast(raw_temperature) / 256.0f; From a801e414a06e683195d9e0fbc2c8c4b899e25508 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 03:32:06 -1000 Subject: [PATCH 105/179] [components] Set referenced components with cg.add_variable and register_parented (m-z) (#21023) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../components/mitsubishi_cn105/__init__.py | 3 +-- esphome/components/modbus/__init__.py | 3 +-- esphome/components/monochromatic/light.py | 3 +-- .../mqtt_subscribe/sensor/__init__.py | 3 +-- .../mqtt_subscribe/text_sensor/__init__.py | 3 +-- esphome/components/my9231/output.py | 3 +-- esphome/components/nextion/base_component.py | 20 +++++++----------- esphome/components/ntc/sensor.py | 3 +-- esphome/components/one_wire/__init__.py | 3 +-- esphome/components/opentherm/__init__.py | 5 +++-- esphome/components/output/__init__.py | 4 +--- esphome/components/output/button/__init__.py | 3 +-- esphome/components/output/lock/__init__.py | 3 +-- esphome/components/output/switch/__init__.py | 3 +-- esphome/components/pid/climate.py | 17 ++++----------- .../pvvx_mithermometer/display/__init__.py | 4 +--- esphome/components/remote_base/__init__.py | 3 +-- .../components/resampler/speaker/__init__.py | 3 +-- esphome/components/resistance/sensor.py | 3 +-- esphome/components/rgb/light.py | 9 +++----- esphome/components/rgbct/light.py | 16 +++++--------- esphome/components/rgbw/light.py | 12 ++++------- esphome/components/rgbww/light.py | 16 +++++--------- esphome/components/router/speaker/__init__.py | 3 +-- .../components/rp2_ble_tracker/__init__.py | 3 +-- esphome/components/rtttl/__init__.py | 7 ++----- .../components/safe_mode/button/__init__.py | 3 +-- .../components/safe_mode/switch/__init__.py | 3 +-- esphome/components/scd4x/sensor.py | 7 ++++--- esphome/components/sen6x/sensor.py | 9 ++++---- esphome/components/sendspin/__init__.py | 3 +-- esphome/components/serial_proxy/__init__.py | 6 ++---- esphome/components/servo/__init__.py | 3 +-- esphome/components/sgp30/sensor.py | 10 +++++---- esphome/components/sgp4x/sensor.py | 10 +++++---- esphome/components/sm16716/output.py | 3 +-- esphome/components/sm2135/output.py | 3 +-- esphome/components/sm2235/output.py | 3 +-- esphome/components/sm2335/output.py | 3 +-- esphome/components/speaker/__init__.py | 4 +--- .../speaker/media_player/__init__.py | 10 +++++---- esphome/components/spi/__init__.py | 3 +-- esphome/components/sprinkler/__init__.py | 6 +----- esphome/components/stcc4/sensor.py | 21 +++++++------------ esphome/components/sun/__init__.py | 3 +-- esphome/components/sun/sensor/__init__.py | 3 +-- .../components/sun/text_sensor/__init__.py | 3 +-- .../switch/binary_sensor/__init__.py | 3 +-- .../sx126x/packet_transport/__init__.py | 3 +-- .../sx127x/packet_transport/__init__.py | 3 +-- esphome/components/thermostat/climate.py | 4 +--- esphome/components/tm1638/output/__init__.py | 3 +-- esphome/components/tm1638/switch/__init__.py | 3 +-- .../components/total_daily_energy/sensor.py | 6 ++---- esphome/components/touchscreen/__init__.py | 3 +-- .../touchscreen/binary_sensor/__init__.py | 3 +-- esphome/components/tuya/__init__.py | 3 +-- .../components/tuya/binary_sensor/__init__.py | 3 +-- esphome/components/tuya/climate/__init__.py | 3 +-- esphome/components/tuya/cover/__init__.py | 3 +-- esphome/components/tuya/light/__init__.py | 3 +-- esphome/components/tuya/number/__init__.py | 3 +-- esphome/components/tuya/select/__init__.py | 3 +-- esphome/components/tuya/sensor/__init__.py | 3 +-- esphome/components/tuya/switch/__init__.py | 3 +-- .../components/tuya/text_sensor/__init__.py | 3 +-- .../components/tuya/water_heater/__init__.py | 3 +-- esphome/components/ufire_ec/sensor.py | 6 +++--- esphome/components/ufire_ise/sensor.py | 6 +++--- esphome/components/uponor_smatrix/__init__.py | 3 +-- esphome/components/uptime/sensor/__init__.py | 3 +-- .../components/voice_assistant/__init__.py | 14 +++---------- esphome/components/wireguard/__init__.py | 2 +- esphome/components/yashima/climate.py | 8 ++----- esphome/components/zigbee/time/__init__.py | 3 +-- 75 files changed, 138 insertions(+), 248 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index ba51bd3a806..4037dd14d1f 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -85,8 +85,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_MITSUBISHI_CN105_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MITSUBISHI_CN105_ID]) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 8c079afbac3..a1f15aab452 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -382,8 +382,7 @@ def final_validate_modbus_device( async def register_modbus_client_device(var: MockObj, config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_MODBUS_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MODBUS_ID]) cg.add(var.set_address(config[CONF_ADDRESS])) diff --git a/esphome/components/monochromatic/light.py b/esphome/components/monochromatic/light.py index 04336bcde1a..40e793a6058 100644 --- a/esphome/components/monochromatic/light.py +++ b/esphome/components/monochromatic/light.py @@ -21,5 +21,4 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/mqtt_subscribe/sensor/__init__.py b/esphome/components/mqtt_subscribe/sensor/__init__.py index e451d2db8ad..38eb47654ad 100644 --- a/esphome/components/mqtt_subscribe/sensor/__init__.py +++ b/esphome/components/mqtt_subscribe/sensor/__init__.py @@ -33,7 +33,6 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_MQTT_PARENT_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MQTT_PARENT_ID]) cg.add(var.set_topic(config[CONF_TOPIC])) cg.add(var.set_qos(config[CONF_QOS])) diff --git a/esphome/components/mqtt_subscribe/text_sensor/__init__.py b/esphome/components/mqtt_subscribe/text_sensor/__init__.py index d432ef8a931..5b195778c55 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/__init__.py +++ b/esphome/components/mqtt_subscribe/text_sensor/__init__.py @@ -31,7 +31,6 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_MQTT_PARENT_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MQTT_PARENT_ID]) cg.add(var.set_topic(config[CONF_TOPIC])) cg.add(var.set_qos(config[CONF_QOS])) diff --git a/esphome/components/my9231/output.py b/esphome/components/my9231/output.py index c2ec15d411b..1516640e0e7 100644 --- a/esphome/components/my9231/output.py +++ b/esphome/components/my9231/output.py @@ -24,6 +24,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_MY9231_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_MY9231_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 17c1b263ed8..442eb979158 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -116,21 +116,17 @@ async def setup_component_core_(var, config, arg): ) ) - if CONF_BACKGROUND_COLOR in config: - color_component = await cg.get_variable(config[CONF_BACKGROUND_COLOR]) - cg.add(var.set_background_color(color_component)) + await cg.add_variable(var.set_background_color, config.get(CONF_BACKGROUND_COLOR)) - if CONF_BACKGROUND_PRESSED_COLOR in config: - color_component = await cg.get_variable(config[CONF_BACKGROUND_PRESSED_COLOR]) - cg.add(var.set_background_pressed_color(color_component)) + await cg.add_variable( + var.set_background_pressed_color, config.get(CONF_BACKGROUND_PRESSED_COLOR) + ) - if CONF_FOREGROUND_COLOR in config: - color_component = await cg.get_variable(config[CONF_FOREGROUND_COLOR]) - cg.add(var.set_foreground_color(color_component)) + await cg.add_variable(var.set_foreground_color, config.get(CONF_FOREGROUND_COLOR)) - if CONF_FOREGROUND_PRESSED_COLOR in config: - color_component = await cg.get_variable(config[CONF_FOREGROUND_PRESSED_COLOR]) - cg.add(var.set_foreground_pressed_color(color_component)) + await cg.add_variable( + var.set_foreground_pressed_color, config.get(CONF_FOREGROUND_PRESSED_COLOR) + ) if CONF_FONT_ID in config: cg.add(var.set_font_id(config[CONF_FONT_ID])) diff --git a/esphome/components/ntc/sensor.py b/esphome/components/ntc/sensor.py index 6c2cb699901..9ac69b595f6 100644 --- a/esphome/components/ntc/sensor.py +++ b/esphome/components/ntc/sensor.py @@ -138,8 +138,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) calib = config[CONF_CALIBRATION] cg.add(var.set_a(calib[CONF_A])) cg.add(var.set_b(calib[CONF_B])) diff --git a/esphome/components/one_wire/__init__.py b/esphome/components/one_wire/__init__.py index 69368a990ad..4a8edc656e2 100644 --- a/esphome/components/one_wire/__init__.py +++ b/esphome/components/one_wire/__init__.py @@ -37,8 +37,7 @@ async def register_one_wire_device(var: MockObj, config: ConfigType) -> None: This is a coroutine, you need to await it with an 'await' expression! """ - parent = await cg.get_variable(config[CONF_ONE_WIRE_ID]) - cg.add(var.set_one_wire_bus(parent)) + await cg.add_variable(var.set_one_wire_bus, config[CONF_ONE_WIRE_ID]) if (address := config.get(CONF_ADDRESS)) is not None: cg.add(var.set_address(address)) if (index := config.get(CONF_INDEX)) is not None: diff --git a/esphome/components/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 3772034ab63..309d35aac19 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -103,8 +103,9 @@ async def to_code(config: dict[str, Any]) -> None: if key in non_sensors: continue if key in schema.INPUTS: - input_sensor = await cg.get_variable(value) - cg.add(getattr(var, f"set_{key}_{const.INPUT_SENSOR}")(input_sensor)) + await cg.add_variable( + getattr(var, f"set_{key}_{const.INPUT_SENSOR}"), value + ) input_sensors.append(key) elif key in schema.SETTINGS: if value == schema.SETTINGS[key].default_value: diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 1b794037848..ff7f55586da 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -59,9 +59,7 @@ FloatOutputPtr = FloatOutput.operator("ptr") async def setup_output_platform_(obj, config): if CONF_INVERTED in config: cg.add(obj.set_inverted(config[CONF_INVERTED])) - if CONF_POWER_SUPPLY in config: - power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) - cg.add(obj.set_power_supply(power_supply_)) + await cg.add_variable(obj.set_power_supply, config.get(CONF_POWER_SUPPLY)) # The C++ initializers are max_power 1.0 and min_power 0.0; skip the setter when # the config matches them. The define stays whenever the key is present because # platforms such as ac_dimmer read the scaling fields directly. diff --git a/esphome/components/output/button/__init__.py b/esphome/components/output/button/__init__.py index 1d14fa5755f..95ad18a56b9 100644 --- a/esphome/components/output/button/__init__.py +++ b/esphome/components/output/button/__init__.py @@ -23,8 +23,7 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_duration(config[CONF_DURATION])) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) await cg.register_component(var, config) await button.register_button(var, config) diff --git a/esphome/components/output/lock/__init__.py b/esphome/components/output/lock/__init__.py index 553114b689c..ffca1a5c47c 100644 --- a/esphome/components/output/lock/__init__.py +++ b/esphome/components/output/lock/__init__.py @@ -22,5 +22,4 @@ async def to_code(config): var = await lock.new_lock(config) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/output/switch/__init__.py b/esphome/components/output/switch/__init__.py index e896b183cad..6d46f8b2d5e 100644 --- a/esphome/components/output/switch/__init__.py +++ b/esphome/components/output/switch/__init__.py @@ -25,5 +25,4 @@ async def to_code(config): var = await switch.new_switch(config) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 1527fa6f354..a6b5641eb70 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -100,19 +100,10 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) - - if CONF_HUMIDITY_SENSOR in config: - sens = await cg.get_variable(config[CONF_HUMIDITY_SENSOR]) - cg.add(var.set_humidity_sensor(sens)) - - if CONF_COOL_OUTPUT in config: - out = await cg.get_variable(config[CONF_COOL_OUTPUT]) - cg.add(var.set_cool_output(out)) - if CONF_HEAT_OUTPUT in config: - out = await cg.get_variable(config[CONF_HEAT_OUTPUT]) - cg.add(var.set_heat_output(out)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) + await cg.add_variable(var.set_cool_output, config.get(CONF_COOL_OUTPUT)) + await cg.add_variable(var.set_heat_output, config.get(CONF_HEAT_OUTPUT)) params = config[CONF_CONTROL_PARAMETERS] cg.add(var.set_kp(params[CONF_KP])) cg.add(var.set_ki(params[CONF_KI])) diff --git a/esphome/components/pvvx_mithermometer/display/__init__.py b/esphome/components/pvvx_mithermometer/display/__init__.py index 1b8c0b5be30..dcbb749d400 100644 --- a/esphome/components/pvvx_mithermometer/display/__init__.py +++ b/esphome/components/pvvx_mithermometer/display/__init__.py @@ -44,8 +44,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auto_clear(config[CONF_AUTO_CLEAR_ENABLED])) cg.add(var.set_validity_period(config[CONF_VALIDITY_PERIOD].total_seconds)) - if CONF_TIME_ID in config: - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.add_variable(var.set_time, config.get(CONF_TIME_ID)) await display.add_writer(var, config, PVVXDisplayRef) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 990d2b87e4d..b01b0f3645e 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -154,8 +154,7 @@ async def attach_receiver( async def register_transmittable(var, config, key: str = CONF_TRANSMITTER_ID): - transmitter_ = await cg.get_variable(config[key]) - cg.add(var.set_transmitter(transmitter_)) + await cg.add_variable(var.set_transmitter, config[key]) # Registry names that share a protocol source file diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 8fa8aeb61c4..5b82f149799 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -86,8 +86,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await speaker.register_speaker(var, config) - output_spkr = await cg.get_variable(config[CONF_OUTPUT_SPEAKER]) - cg.add(var.set_output_speaker(output_spkr)) + await cg.add_variable(var.set_output_speaker, config[CONF_OUTPUT_SPEAKER]) cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) diff --git a/esphome/components/resistance/sensor.py b/esphome/components/resistance/sensor.py index cbf329713df..c76d910df53 100644 --- a/esphome/components/resistance/sensor.py +++ b/esphome/components/resistance/sensor.py @@ -43,8 +43,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) cg.add(var.set_configuration(config[CONF_CONFIGURATION])) cg.add(var.set_resistor(config[CONF_RESISTOR])) cg.add(var.set_reference_voltage(config[CONF_REFERENCE_VOLTAGE])) diff --git a/esphome/components/rgb/light.py b/esphome/components/rgb/light.py index cd553292569..0a1ec2eddef 100644 --- a/esphome/components/rgb/light.py +++ b/esphome/components/rgb/light.py @@ -21,9 +21,6 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) diff --git a/esphome/components/rgbct/light.py b/esphome/components/rgbct/light.py index 00626ec6ba2..43e31a011bf 100644 --- a/esphome/components/rgbct/light.py +++ b/esphome/components/rgbct/light.py @@ -42,17 +42,11 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) - - color_temp = await cg.get_variable(config[CONF_COLOR_TEMPERATURE]) - cg.add(var.set_color_temperature(color_temp)) - white_brightness = await cg.get_variable(config[CONF_WHITE_BRIGHTNESS]) - cg.add(var.set_white_brightness(white_brightness)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) + await cg.add_variable(var.set_color_temperature, config[CONF_COLOR_TEMPERATURE]) + await cg.add_variable(var.set_white_brightness, config[CONF_WHITE_BRIGHTNESS]) cg.add(var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE])) cg.add(var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE])) diff --git a/esphome/components/rgbw/light.py b/esphome/components/rgbw/light.py index 7aedf4affc3..2875eba1978 100644 --- a/esphome/components/rgbw/light.py +++ b/esphome/components/rgbw/light.py @@ -30,12 +30,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) - white = await cg.get_variable(config[CONF_WHITE]) - cg.add(var.set_white(white)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) + await cg.add_variable(var.set_white, config[CONF_WHITE]) cg.add(var.set_color_interlock(config[CONF_COLOR_INTERLOCK])) diff --git a/esphome/components/rgbww/light.py b/esphome/components/rgbww/light.py index 3626a57b8c1..8881af6af45 100644 --- a/esphome/components/rgbww/light.py +++ b/esphome/components/rgbww/light.py @@ -45,22 +45,16 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - red = await cg.get_variable(config[CONF_RED]) - cg.add(var.set_red(red)) - green = await cg.get_variable(config[CONF_GREEN]) - cg.add(var.set_green(green)) - blue = await cg.get_variable(config[CONF_BLUE]) - cg.add(var.set_blue(blue)) - - cwhite = await cg.get_variable(config[CONF_COLD_WHITE]) - cg.add(var.set_cold_white(cwhite)) + await cg.add_variable(var.set_red, config[CONF_RED]) + await cg.add_variable(var.set_green, config[CONF_GREEN]) + await cg.add_variable(var.set_blue, config[CONF_BLUE]) + await cg.add_variable(var.set_cold_white, config[CONF_COLD_WHITE]) if CONF_COLD_WHITE_COLOR_TEMPERATURE in config: cg.add( var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE]) ) - wwhite = await cg.get_variable(config[CONF_WARM_WHITE]) - cg.add(var.set_warm_white(wwhite)) + await cg.add_variable(var.set_warm_white, config[CONF_WARM_WHITE]) if CONF_WARM_WHITE_COLOR_TEMPERATURE in config: cg.add( var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE]) diff --git a/esphome/components/router/speaker/__init__.py b/esphome/components/router/speaker/__init__.py index f568aedcbdf..90e9febed5c 100644 --- a/esphome/components/router/speaker/__init__.py +++ b/esphome/components/router/speaker/__init__.py @@ -86,8 +86,7 @@ async def to_code(config: ConfigType) -> None: speakers = config[CONF_OUTPUT_SPEAKERS] cg.add(var.set_output_count(len(speakers))) for spk_id in speakers: - spk = await cg.get_variable(spk_id) - cg.add(var.add_output(spk)) + await cg.add_variable(var.add_output, spk_id) automation.register_apply_action( diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 4a3d3f788bf..fdfc2a1f967 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -95,8 +95,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_RP2040_BLE_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_RP2040_BLE_ID]) # The tracker registers itself as a controller scan listener in setup(); # request the codegen-sized StaticVector slot for it. rp2040_ble.request_scan_listener_slot() diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 8bff8bb58dc..c987e0327c5 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -84,13 +84,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) if CONF_OUTPUT in config: - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) cg.add_define("USE_OUTPUT") - if CONF_SPEAKER in config: - out = await cg.get_variable(config[CONF_SPEAKER]) - cg.add(var.set_speaker(out)) + await cg.add_variable(var.set_speaker, config.get(CONF_SPEAKER)) cg.add(var.set_gain(config[CONF_GAIN])) diff --git a/esphome/components/safe_mode/button/__init__.py b/esphome/components/safe_mode/button/__init__.py index 89e24757996..d0ef080f10e 100644 --- a/esphome/components/safe_mode/button/__init__.py +++ b/esphome/components/safe_mode/button/__init__.py @@ -31,5 +31,4 @@ async def to_code(config: ConfigType) -> None: var = await button.new_button(config) await cg.register_component(var, config) - safe_mode_component = await cg.get_variable(config[CONF_SAFE_MODE]) - cg.add(var.set_safe_mode(safe_mode_component)) + await cg.add_variable(var.set_safe_mode, config[CONF_SAFE_MODE]) diff --git a/esphome/components/safe_mode/switch/__init__.py b/esphome/components/safe_mode/switch/__init__.py index 529b023d689..432d7a76154 100644 --- a/esphome/components/safe_mode/switch/__init__.py +++ b/esphome/components/safe_mode/switch/__init__.py @@ -26,5 +26,4 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - safe_mode_component = await cg.get_variable(config[CONF_SAFE_MODE]) - cg.add(var.set_safe_mode(safe_mode_component)) + await cg.add_variable(var.set_safe_mode, config[CONF_SAFE_MODE]) diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 12a07a3f63a..4b67319efb1 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -112,9 +112,10 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(config[key]) cg.add(getattr(var, funcName)(sens)) - if CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE in config: - sens = await cg.get_variable(config[CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE]) - cg.add(var.set_ambient_pressure_source(sens)) + await cg.add_variable( + var.set_ambient_pressure_source, + config.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), + ) cg.add(var.set_measurement_mode(config[CONF_MEASUREMENT_MODE])) diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index b928f97774e..d961d05bc85 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -326,11 +326,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_altitude_compensation(altitude)) if (pressure := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION)) is not None: cg.add(var.set_ambient_pressure_compensation(pressure)) - if ( - source := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE) - ) is not None: - sens = await cg.get_variable(source) - cg.add(var.set_ambient_pressure_source(sens)) + await cg.add_variable( + var.set_ambient_pressure_source, + co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), + ) SEN6X_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index c557a7960d2..3caad51f919 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -428,8 +428,7 @@ async def to_code(config: ConfigType) -> None: # Service starts disabled and the hub enables it; always advertised where unsupported if mdns.request_service_enable_disable(): - mdns_var = await cg.get_variable(CORE.config[MDNS_DOMAIN][CONF_ID]) - cg.add(var.set_mdns(mdns_var)) + await cg.add_variable(var.set_mdns, CORE.config[MDNS_DOMAIN][CONF_ID]) data = _get_data() diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 27d69c97346..d1b4e889066 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -101,12 +101,10 @@ async def to_code(config: ConfigType) -> None: # port_type names the electrical interface (a USB RS485 adapter is RS485), so every # usb_uart channel reports USB identity whatever port type it declares if is_usb_uart_channel(config[CONF_UART_ID], CORE.config): - channel = await cg.get_variable(config[CONF_UART_ID]) - cg.add(var.set_usb_channel(channel)) + await cg.add_variable(var.set_usb_channel, config[CONF_UART_ID]) cg.add_define("USE_SERIAL_PROXY_USB_IDENTITY") if uart.is_host_uart(config[CONF_UART_ID], CORE.config): - host_uart = await cg.get_variable(config[CONF_UART_ID]) - cg.add(var.set_host_uart(host_uart)) + await cg.add_variable(var.set_host_uart, config[CONF_UART_ID]) cg.add_define("USE_SERIAL_PROXY_HOST_UART") cg.add_define("USE_SERIAL_PROXY") diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index 44ec294765b..fdfb892d3e1 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -43,8 +43,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) cg.add(var.set_min_level(config[CONF_MIN_LEVEL])) cg.add(var.set_idle_level(config[CONF_IDLE_LEVEL])) cg.add(var.set_max_level(config[CONF_MAX_LEVEL])) diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index 5d4c43fccae..87b0d3aa2c0 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -98,7 +98,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_tvoc_baseline(baseline_config[CONF_TVOC_BASELINE])) if compensation_config := config.get(CONF_COMPENSATION): - sens = await cg.get_variable(compensation_config[CONF_HUMIDITY_SOURCE]) - cg.add(var.set_humidity_sensor(sens)) - sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE_SOURCE]) - cg.add(var.set_temperature_sensor(sens)) + await cg.add_variable( + var.set_humidity_sensor, compensation_config[CONF_HUMIDITY_SOURCE] + ) + await cg.add_variable( + var.set_temperature_sensor, compensation_config[CONF_TEMPERATURE_SOURCE] + ) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 866445f2464..78792c80102 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -103,10 +103,12 @@ async def to_code(config: ConfigType) -> None: if CONF_COMPENSATION in config: compensation_config = config[CONF_COMPENSATION] - sens = await cg.get_variable(compensation_config[CONF_HUMIDITY_SOURCE]) - cg.add(var.set_humidity_sensor(sens)) - sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE_SOURCE]) - cg.add(var.set_temperature_sensor(sens)) + await cg.add_variable( + var.set_humidity_sensor, compensation_config[CONF_HUMIDITY_SOURCE] + ) + await cg.add_variable( + var.set_temperature_sensor, compensation_config[CONF_TEMPERATURE_SOURCE] + ) cg.add(var.set_store_baseline(config[CONF_STORE_BASELINE])) diff --git a/esphome/components/sm16716/output.py b/esphome/components/sm16716/output.py index 08cec24fd64..c4f6981c04e 100644 --- a/esphome/components/sm16716/output.py +++ b/esphome/components/sm16716/output.py @@ -24,6 +24,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM16716_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM16716_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/sm2135/output.py b/esphome/components/sm2135/output.py index 80b4cb1a4f2..b7a49374045 100644 --- a/esphome/components/sm2135/output.py +++ b/esphome/components/sm2135/output.py @@ -25,6 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM2135_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM2135_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/sm2235/output.py b/esphome/components/sm2235/output.py index 29940a651e8..be590a3bd95 100644 --- a/esphome/components/sm2235/output.py +++ b/esphome/components/sm2235/output.py @@ -25,6 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM2235_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM2235_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/sm2335/output.py b/esphome/components/sm2335/output.py index 30f653b087a..ce047cbb96e 100644 --- a/esphome/components/sm2335/output.py +++ b/esphome/components/sm2335/output.py @@ -25,6 +25,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_SM2335_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_SM2335_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index d34cfc32c0f..e1df66f6841 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -22,9 +22,7 @@ PlayAction = speaker_ns.class_( async def setup_speaker_core_(var, config): - if audio_dac_config := config.get(CONF_AUDIO_DAC): - aud_dac = await cg.get_variable(audio_dac_config) - cg.add(var.set_audio_dac(aud_dac)) + await cg.add_variable(var.set_audio_dac, config.get(CONF_AUDIO_DAC)) async def register_speaker(var, config): diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index b37a288d874..83f406e9e65 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -184,8 +184,9 @@ async def to_code(config): cg.add(var.set_volume_min(config[CONF_VOLUME_MIN])) announcement_pipeline_config = config[CONF_ANNOUNCEMENT_PIPELINE] - spkr = await cg.get_variable(announcement_pipeline_config[CONF_SPEAKER]) - cg.add(var.set_announcement_speaker(spkr)) + await cg.add_variable( + var.set_announcement_speaker, announcement_pipeline_config[CONF_SPEAKER] + ) if announcement_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_announcement_format( @@ -196,8 +197,9 @@ async def to_code(config): ) if media_pipeline_config := config.get(CONF_MEDIA_PIPELINE): - spkr = await cg.get_variable(media_pipeline_config[CONF_SPEAKER]) - cg.add(var.set_media_speaker(spkr)) + await cg.add_variable( + var.set_media_speaker, media_pipeline_config[CONF_SPEAKER] + ) if media_pipeline_config[CONF_FORMAT] != "NONE": cg.add( var.set_media_format( diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 151f228651e..8ff1842a7c1 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -484,8 +484,7 @@ def spi_device_schema( async def register_spi_device( var: cg.MockObj, config: ConfigType, write_only: bool = False ) -> None: - parent = await cg.get_variable(config[CONF_SPI_ID]) - cg.add(var.set_spi_parent(parent)) + await cg.add_variable(var.set_spi_parent, config[CONF_SPI_ID]) if write_only: cg.add(var.set_write_only(True)) await cg.add_pin(var.set_cs_pin, config.get(CONF_CS_PIN)) diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index fe096514d07..557b24505bd 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -733,8 +733,4 @@ async def to_code(config): var = await cg.get_variable(sprinkler_controller[CONF_ID]) for controller_to_add in config: if sprinkler_controller[CONF_ID] != controller_to_add[CONF_ID]: - cg.add( - var.add_controller( - await cg.get_variable(controller_to_add[CONF_ID]) - ) - ) + await cg.add_variable(var.add_controller, controller_to_add[CONF_ID]) diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py index b103aa39534..4ea1bc6ed81 100644 --- a/esphome/components/stcc4/sensor.py +++ b/esphome/components/stcc4/sensor.py @@ -107,13 +107,11 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) await sensors(CONF_HUMIDITY, var.set_humidity_sensor) - if (temperature_source := config.get(CONF_TEMPERATURE_SOURCE)) is not None: - sens = await cg.get_variable(temperature_source) - cg.add(var.set_temperature_source(sens)) + await cg.add_variable( + var.set_temperature_source, config.get(CONF_TEMPERATURE_SOURCE) + ) - if (humidity_source := config.get(CONF_HUMIDITY_SOURCE)) is not None: - sens = await cg.get_variable(humidity_source) - cg.add(var.set_humidity_source(sens)) + await cg.add_variable(var.set_humidity_source, config.get(CONF_HUMIDITY_SOURCE)) if ( ambient_pressure_compensation := config.get(CONF_AMBIENT_PRESSURE_COMPENSATION) @@ -124,12 +122,9 @@ async def to_code(config: ConfigType) -> None: ) ) - if ( - ambient_pressure_compensation_source := config.get( - CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE - ) - ) is not None: - sens = await cg.get_variable(ambient_pressure_compensation_source) - cg.add(var.set_ambient_pressure_source(sens)) + await cg.add_variable( + var.set_ambient_pressure_source, + config.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), + ) cg.add(var.set_measurement_mode(config[CONF_MEASUREMENT_MODE])) diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index 71441e97823..c104316d475 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -118,8 +118,7 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.add_variable(var.set_time, config[CONF_TIME_ID]) cg.add(var.set_latitude(config[CONF_LATITUDE])) cg.add(var.set_longitude(config[CONF_LONGITUDE])) diff --git a/esphome/components/sun/sensor/__init__.py b/esphome/components/sun/sensor/__init__.py index ef4e51d5e5e..b6981422a05 100644 --- a/esphome/components/sun/sensor/__init__.py +++ b/esphome/components/sun/sensor/__init__.py @@ -40,5 +40,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_type(config[CONF_TYPE])) - paren = await cg.get_variable(config[CONF_SUN_ID]) - cg.add(var.set_parent(paren)) + await cg.register_parented(var, config[CONF_SUN_ID]) diff --git a/esphome/components/sun/text_sensor/__init__.py b/esphome/components/sun/text_sensor/__init__.py index 523471bd41e..b7db7cd39d9 100644 --- a/esphome/components/sun/text_sensor/__init__.py +++ b/esphome/components/sun/text_sensor/__init__.py @@ -53,8 +53,7 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_SUN_ID]) - cg.add(var.set_parent(paren)) + await cg.register_parented(var, config[CONF_SUN_ID]) cg.add(var.set_sunrise(SUN_TYPES[config[CONF_TYPE]])) cg.add(var.set_elevation(config[CONF_ELEVATION])) cg.add(var.set_format(config[CONF_FORMAT])) diff --git a/esphome/components/switch/binary_sensor/__init__.py b/esphome/components/switch/binary_sensor/__init__.py index 61ca1a14a24..ece53c0deb3 100644 --- a/esphome/components/switch/binary_sensor/__init__.py +++ b/esphome/components/switch/binary_sensor/__init__.py @@ -27,5 +27,4 @@ async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/sx126x/packet_transport/__init__.py b/esphome/components/sx126x/packet_transport/__init__.py index fc3bbf6df25..0c3ec56fcc0 100644 --- a/esphome/components/sx126x/packet_transport/__init__.py +++ b/esphome/components/sx126x/packet_transport/__init__.py @@ -23,5 +23,4 @@ CONFIG_SCHEMA = transport_schema(SX126xTransport).extend( async def to_code(config: ConfigType) -> None: var, _ = await new_packet_transport(config) - sx126x = await cg.get_variable(config[CONF_SX126X_ID]) - cg.add(var.set_parent(sx126x)) + await cg.register_parented(var, config[CONF_SX126X_ID]) diff --git a/esphome/components/sx127x/packet_transport/__init__.py b/esphome/components/sx127x/packet_transport/__init__.py index 33204a7d83d..0ec1a0f6950 100644 --- a/esphome/components/sx127x/packet_transport/__init__.py +++ b/esphome/components/sx127x/packet_transport/__init__.py @@ -23,5 +23,4 @@ CONFIG_SCHEMA = transport_schema(SX127xTransport).extend( async def to_code(config: ConfigType) -> None: var, _ = await new_packet_transport(config) - sx127x = await cg.get_variable(config[CONF_SX127X_ID]) - cg.add(var.set_parent(sx127x)) + await cg.register_parented(var, config[CONF_SX127X_ID]) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index bebdd7d0660..3e6d63adbb7 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -716,9 +716,7 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_sensor(sens)) - if CONF_HUMIDITY_SENSOR in config: - sens = await cg.get_variable(config[CONF_HUMIDITY_SENSOR]) - cg.add(var.set_humidity_sensor(sens)) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) cg.add(var.set_cool_deadband(config[CONF_COOL_DEADBAND])) cg.add(var.set_cool_overrun(config[CONF_COOL_OVERRUN])) diff --git a/esphome/components/tm1638/output/__init__.py b/esphome/components/tm1638/output/__init__.py index 961abfee476..4b85e73c88b 100644 --- a/esphome/components/tm1638/output/__init__.py +++ b/esphome/components/tm1638/output/__init__.py @@ -23,5 +23,4 @@ async def to_code(config: ConfigType) -> None: await output.register_output(var, config) await cg.register_component(var, config) cg.add(var.set_lednum(config[CONF_LED])) - hub = await cg.get_variable(config[CONF_TM1638_ID]) - cg.add(var.set_tm1638(hub)) + await cg.add_variable(var.set_tm1638, config[CONF_TM1638_ID]) diff --git a/esphome/components/tm1638/switch/__init__.py b/esphome/components/tm1638/switch/__init__.py index f42b835e039..eee55dc118e 100644 --- a/esphome/components/tm1638/switch/__init__.py +++ b/esphome/components/tm1638/switch/__init__.py @@ -25,5 +25,4 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) cg.add(var.set_lednum(config[CONF_LED])) - hub = await cg.get_variable(config[CONF_TM1638_ID]) - cg.add(var.set_tm1638(hub)) + await cg.add_variable(var.set_tm1638, config[CONF_TM1638_ID]) diff --git a/esphome/components/total_daily_energy/sensor.py b/esphome/components/total_daily_energy/sensor.py index af2f5ff561c..304688de07a 100644 --- a/esphome/components/total_daily_energy/sensor.py +++ b/esphome/components/total_daily_energy/sensor.py @@ -83,9 +83,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_POWER_ID]) - cg.add(var.set_parent(sens)) - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.register_parented(var, config[CONF_POWER_ID]) + await cg.add_variable(var.set_time, config[CONF_TIME_ID]) cg.add(var.set_restore(config[CONF_RESTORE])) cg.add(var.set_method(config[CONF_METHOD])) diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index 7a23d140690..36679e828b7 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -155,8 +155,7 @@ TOUCHSCREEN_SCHEMA = touchscreen_schema(cv.UNDEFINED) async def register_touchscreen(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - disp = await cg.get_variable(config[CONF_DISPLAY]) - cg.add(var.set_display(disp)) + await cg.add_variable(var.set_display, config[CONF_DISPLAY]) if CONF_TOUCH_TIMEOUT in config: cg.add(var.set_touch_timeout(config[CONF_TOUCH_TIMEOUT])) diff --git a/esphome/components/touchscreen/binary_sensor/__init__.py b/esphome/components/touchscreen/binary_sensor/__init__.py index 6a66d00ea6c..fccaf26c39a 100644 --- a/esphome/components/touchscreen/binary_sensor/__init__.py +++ b/esphome/components/touchscreen/binary_sensor/__init__.py @@ -83,5 +83,4 @@ async def to_code(config: ConfigType) -> None: ) for page_id in config.get(CONF_PAGES, []): - page = await cg.get_variable(page_id) - cg.add(var.add_page(page)) + await cg.add_variable(var.add_page, page_id) diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index 4a334e3e518..0ec7452e379 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -85,8 +85,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await uart.new_uart_component(config) if (time_id := config.get(CONF_TIME_ID)) is not None: - time_ = await cg.get_variable(time_id) - cg.add(var.set_time_id(time_)) + await cg.add_variable(var.set_time_id, time_id) if config[CONF_FORCE_TIME_SYNC]: cg.add(var.set_force_time_sync(True)) await cg.add_pin(var.set_status_pin, config.get(CONF_STATUS_PIN)) diff --git a/esphome/components/tuya/binary_sensor/__init__.py b/esphome/components/tuya/binary_sensor/__init__.py index 767d1c636e5..049386461af 100644 --- a/esphome/components/tuya/binary_sensor/__init__.py +++ b/esphome/components/tuya/binary_sensor/__init__.py @@ -28,7 +28,6 @@ async def to_code(config): var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_sensor_id(config[CONF_SENSOR_DATAPOINT])) diff --git a/esphome/components/tuya/climate/__init__.py b/esphome/components/tuya/climate/__init__.py index 91ef9a6a68a..65045e2cd27 100644 --- a/esphome/components/tuya/climate/__init__.py +++ b/esphome/components/tuya/climate/__init__.py @@ -196,8 +196,7 @@ async def to_code(config): var = await climate.new_climate(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) diff --git a/esphome/components/tuya/cover/__init__.py b/esphome/components/tuya/cover/__init__.py index 6e1daef884a..c93fdf55445 100644 --- a/esphome/components/tuya/cover/__init__.py +++ b/esphome/components/tuya/cover/__init__.py @@ -69,5 +69,4 @@ async def to_code(config): cg.add(var.set_invert_position(config[CONF_INVERT_POSITION])) cg.add(var.set_invert_position_report(config[CONF_INVERT_POSITION_REPORT])) cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) diff --git a/esphome/components/tuya/light/__init__.py b/esphome/components/tuya/light/__init__.py index 5ba55b6ccbb..9811995ad0c 100644 --- a/esphome/components/tuya/light/__init__.py +++ b/esphome/components/tuya/light/__init__.py @@ -112,5 +112,4 @@ async def to_code(config): ) cg.add(var.set_color_interlock(config[CONF_COLOR_INTERLOCK])) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) diff --git a/esphome/components/tuya/number/__init__.py b/esphome/components/tuya/number/__init__.py index 7a61e69c5cf..dcc2c15347b 100644 --- a/esphome/components/tuya/number/__init__.py +++ b/esphome/components/tuya/number/__init__.py @@ -85,8 +85,7 @@ async def to_code(config): ) cg.add(var.set_write_multiply(config[CONF_MULTIPLY])) - parent = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(parent)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_number_id(config[CONF_NUMBER_DATAPOINT])) if hidden_config := config.get(CONF_DATAPOINT_HIDDEN): diff --git a/esphome/components/tuya/select/__init__.py b/esphome/components/tuya/select/__init__.py index 9f2b6f1e12a..84819f8f497 100644 --- a/esphome/components/tuya/select/__init__.py +++ b/esphome/components/tuya/select/__init__.py @@ -52,8 +52,7 @@ async def to_code(config): var = await select.new_select(config, options=list(options_map.values())) await cg.register_component(var, config) cg.add(var.set_select_mappings(list(options_map.keys()))) - parent = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(parent)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) if (enum_datapoint := config.get(CONF_ENUM_DATAPOINT, None)) is not None: cg.add(var.set_select_id(enum_datapoint, False)) if (int_datapoint := config.get(CONF_INT_DATAPOINT, None)) is not None: diff --git a/esphome/components/tuya/sensor/__init__.py b/esphome/components/tuya/sensor/__init__.py index 861bbc656aa..191f1095e6d 100644 --- a/esphome/components/tuya/sensor/__init__.py +++ b/esphome/components/tuya/sensor/__init__.py @@ -27,7 +27,6 @@ async def to_code(config): await cg.register_component(var, config) await sensor.register_sensor(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_sensor_id(config[CONF_SENSOR_DATAPOINT])) diff --git a/esphome/components/tuya/switch/__init__.py b/esphome/components/tuya/switch/__init__.py index e9987050cb0..fc43b39b9c0 100644 --- a/esphome/components/tuya/switch/__init__.py +++ b/esphome/components/tuya/switch/__init__.py @@ -26,7 +26,6 @@ async def to_code(config): var = await switch.new_switch(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) diff --git a/esphome/components/tuya/text_sensor/__init__.py b/esphome/components/tuya/text_sensor/__init__.py index 3bae109b93a..af84d32f124 100644 --- a/esphome/components/tuya/text_sensor/__init__.py +++ b/esphome/components/tuya/text_sensor/__init__.py @@ -27,7 +27,6 @@ async def to_code(config): var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_sensor_id(config[CONF_SENSOR_DATAPOINT])) diff --git a/esphome/components/tuya/water_heater/__init__.py b/esphome/components/tuya/water_heater/__init__.py index 7d1af791b4f..96b7136c725 100644 --- a/esphome/components/tuya/water_heater/__init__.py +++ b/esphome/components/tuya/water_heater/__init__.py @@ -89,8 +89,7 @@ async def to_code(config: ConfigType) -> None: var = await water_heater.new_water_heater(config) await cg.register_component(var, config) - paren = await cg.get_variable(config[CONF_TUYA_ID]) - cg.add(var.set_tuya_parent(paren)) + await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) cg.add(var.set_switch_id(config[CONF_SWITCH_DATAPOINT])) diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 6a632e05cd5..de68f56046b 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -68,9 +68,9 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) await sensors(CONF_EC, var.set_ec_sensor) - if CONF_TEMPERATURE_SENSOR in config: - sens = await cg.get_variable(config[CONF_TEMPERATURE_SENSOR]) - cg.add(var.set_temperature_sensor_external(sens)) + await cg.add_variable( + var.set_temperature_sensor_external, config.get(CONF_TEMPERATURE_SENSOR) + ) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index 6f205af784d..f9aae747689 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -60,9 +60,9 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) await sensors(CONF_PH, var.set_ph_sensor) - if CONF_TEMPERATURE_SENSOR in config: - sens = await cg.get_variable(config[CONF_TEMPERATURE_SENSOR]) - cg.add(var.set_temperature_sensor_external(sens)) + await cg.add_variable( + var.set_temperature_sensor_external, config.get(CONF_TEMPERATURE_SENSOR) + ) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index e23f4157a7e..d4d03d3e0f5 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -69,8 +69,7 @@ async def to_code(config: ConfigType) -> None: var = await uart.new_uart_component(config) if time_id := config.get(CONF_TIME_ID): - time_ = await cg.get_variable(time_id) - cg.add(var.set_time_id(time_)) + await cg.add_variable(var.set_time_id, time_id) if time_device_address := config.get(CONF_TIME_DEVICE_ADDRESS): cg.add(var.set_time_device_address(time_device_address)) diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index 04140ba431d..619d9ca8864 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -61,8 +61,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) if time_id_config := config.get(CONF_TIME_ID): - time_id = await cg.get_variable(time_id_config) - cg.add(var.set_time(time_id)) + await cg.add_variable(var.set_time, time_id_config) cg.add_define("USE_UPTIME_TIMESTAMP") diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 488e16a3839..1b7ad38c1c5 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -207,17 +207,9 @@ async def to_code(config: ConfigType) -> None: mic_source2 = await microphone.microphone_source_to_code(mic_sources[1]) cg.add(var.set_microphone_source2(mic_source2)) - if CONF_MICRO_WAKE_WORD in config: - mww = await cg.get_variable(config[CONF_MICRO_WAKE_WORD]) - cg.add(var.set_micro_wake_word(mww)) - - if CONF_MEDIA_PLAYER in config: - mp = await cg.get_variable(config[CONF_MEDIA_PLAYER]) - cg.add(var.set_media_player(mp)) - - if CONF_SPEAKER in config: - spkr = await cg.get_variable(config[CONF_SPEAKER]) - cg.add(var.set_speaker(spkr)) + await cg.add_variable(var.set_micro_wake_word, config.get(CONF_MICRO_WAKE_WORD)) + await cg.add_variable(var.set_media_player, config.get(CONF_MEDIA_PLAYER)) + await cg.add_variable(var.set_speaker, config.get(CONF_SPEAKER)) cg.add(var.set_use_wake_word(config[CONF_USE_WAKE_WORD])) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index e9641a5957c..07cb154c91c 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -137,7 +137,7 @@ async def to_code(config): ) ) - cg.add(var.set_srctime(await cg.get_variable(config[CONF_TIME_ID]))) + await cg.add_variable(var.set_srctime, config[CONF_TIME_ID]) if config[CONF_REQUIRE_CONNECTION_TO_PROCEED]: cg.add(var.disable_auto_proceed()) diff --git a/esphome/components/yashima/climate.py b/esphome/components/yashima/climate.py index cfc4744d82c..25e06b37586 100644 --- a/esphome/components/yashima/climate.py +++ b/esphome/components/yashima/climate.py @@ -32,9 +32,5 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) - if CONF_SENSOR in config: - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) - - transmitter = await cg.get_variable(config[CONF_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) + await cg.add_variable(var.set_transmitter, config[CONF_TRANSMITTER_ID]) diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 0572e591cfc..dbbb6734920 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -113,5 +113,4 @@ async def _add_time_zephyr(config: ConfigType) -> None: cg.add(var.set_endpoint(slot_index + 1)) cg.add(var.set_cluster_attributes(time_attrs)) - hub = await cg.get_variable(config[CONF_ZIGBEE_ID]) - cg.add(var.set_parent(hub)) + await cg.register_parented(var, config[CONF_ZIGBEE_ID]) From b2d6c7849928b082d0af909b85bf1cb20886f2a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 07:18:47 -1000 Subject: [PATCH 106/179] [light] Add `LightState::set_external_state()` for outputs that mirror a light elsewhere (#20787) --- esphome/components/light/light_state.cpp | 23 +++ esphome/components/light/light_state.h | 5 + .../light/test_light_set_external_state.cpp | 179 ++++++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 tests/components/light/test_light_set_external_state.cpp diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 885440187f9..d8293338897 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -181,6 +181,29 @@ void LightState::loop() { } } +bool LightState::set_external_state(const LightColorValues &values) { + // Either would write its own values over the reported ones + if (this->next_write_ || this->transformer_ != nullptr) { + return false; + } + const bool had_effect = this->active_effect_index_ != 0; + this->stop_effect_(); // schedules no write, unlike a call that sets effect 0 + if (!had_effect && values == this->remote_values && values == this->current_values) { + return true; // nothing new to tell + } + this->current_values = values; + this->remote_values = values; + // Reached at once, as after an instant call, and told before the publish like there: an + // automation run by the publish may change the light again, and its own call reports that + if (this->target_state_reached_listeners_) { + for (auto *listener : *this->target_state_reached_listeners_) { + listener->on_light_target_state_reached(); + } + } + this->publish_state(); + return true; +} + void LightState::publish_state() { if (this->remote_values_listeners_) { for (auto *listener : *this->remote_values_listeners_) { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 2a99943e1df..946b4efae58 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -92,6 +92,11 @@ class LightState : public EntityBase, public Component { bool is_transitioning() const { return this->transformer_ != nullptr; } LightCall make_call(); + /// Take over the state of a light mirrored from elsewhere: current and target values at once, + /// published but not written or saved; a running effect stops. False while a write is pending or + /// a transition or flash runs. + bool set_external_state(const LightColorValues &values); + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Load state from preferences diff --git a/tests/components/light/test_light_set_external_state.cpp b/tests/components/light/test_light_set_external_state.cpp new file mode 100644 index 00000000000..624d374519a --- /dev/null +++ b/tests/components/light/test_light_set_external_state.cpp @@ -0,0 +1,179 @@ +#include + +#include "esphome/components/light/light_effect.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +// Counts the writes, like an output that mirrors a light elsewhere and sends each one on +class CountingOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::BRIGHTNESS}); + return traits; + } + void write_state(LightState *state) override { this->writes++; } + int writes{0}; +}; + +class NoopEffect : public LightEffect { + public: + using LightEffect::LightEffect; + void apply() override {} +}; + +class ReachedListener : public LightTargetStateReachedListener { + public: + void on_light_target_state_reached() override { this->reached++; } + int reached{0}; +}; + +class PublishListener : public LightRemoteValuesListener { + public: + void on_light_remote_values_update() override { this->published++; } + int published{0}; +}; + +// Counts the target-reached calls that find the light off, as the on_turn_off trigger does +class OffReachedListener : public LightTargetStateReachedListener { + public: + explicit OffReachedListener(LightState *state) : state_(state) {} + void on_light_target_state_reached() override { this->reached_off += !this->state_->current_values.is_on(); } + int reached_off{0}; + + protected: + LightState *state_; +}; + +// An automation that turns the light off as soon as it is reported on +class TurnOffOnPublish : public LightRemoteValuesListener { + public: + explicit TurnOffOnPublish(LightState *state) : state_(state) {} + void on_light_remote_values_update() override { + if (this->state_->remote_values.is_on()) { + this->state_->make_call().set_state(false).perform(); + } + } + + protected: + LightState *state_; +}; + +LightColorValues on_at(float brightness) { + LightColorValues values; + values.set_color_mode(ColorMode::BRIGHTNESS); + values.set_state(true); + values.set_brightness(brightness); + return values; +} + +} // namespace + +TEST(LightStateExternal, TakesTheValuesWithoutAWrite) { + CountingOutput output; + LightState state(&output); + ReachedListener listener; + state.add_target_state_reached_listener(&listener); + state.loop(); // the initial write of a fresh state + const int writes = output.writes; + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(state.remote_values, on_at(0.5f)); + EXPECT_EQ(state.current_values, on_at(0.5f)); + EXPECT_EQ(listener.reached, 1); + state.loop(); + EXPECT_EQ(output.writes, writes); +} + +TEST(LightStateExternal, RefusesWhileALocalChangeWaitsToBeWritten) { + CountingOutput output; + LightState state(&output); + state.loop(); + + state.make_call().set_state(true).set_brightness(0.8f).perform(); + EXPECT_FALSE(state.set_external_state(on_at(0.5f))); + EXPECT_FLOAT_EQ(state.remote_values.get_brightness(), 0.8f); + state.loop(); // the local change is written + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); +} + +TEST(LightStateExternal, RefusesDuringATransition) { + CountingOutput output; + LightState state(&output); + state.loop(); + + state.make_call().set_state(true).set_brightness(1.0f).set_transition_length(1000).perform(); + state.loop(); + EXPECT_TRUE(state.is_transitioning()); + EXPECT_FALSE(state.set_external_state(on_at(0.5f))); +} + +TEST(LightStateExternal, StopsARunningEffect) { + CountingOutput output; + LightState state(&output); + NoopEffect effect("Noop"); + LightEffect *effects[] = {&effect}; + state.add_effects(effects, 1); + state.loop(); + + state.make_call().set_state(true).set_effect("Noop").perform(); + state.loop(); + ASSERT_EQ(state.get_current_effect_index(), 1u); + const auto writes = output.writes; + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(state.get_current_effect_index(), 0u); + EXPECT_EQ(state.remote_values, on_at(0.5f)); + state.loop(); + EXPECT_EQ(output.writes, writes); // the stopped effect schedules no write either +} + +TEST(LightStateExternal, PublishesWhenOnlyAnEffectStops) { + CountingOutput output; + LightState state(&output); + NoopEffect effect("Noop"); + LightEffect *effects[] = {&effect}; + state.add_effects(effects, 1); + PublishListener listener; + state.add_remote_values_listener(&listener); + state.loop(); + + state.make_call().set_state(true).set_effect("Noop").perform(); + state.loop(); + const int published = listener.published; + // The values stay, but the effect name clients show has to go + EXPECT_TRUE(state.set_external_state(state.remote_values)); + EXPECT_EQ(state.get_current_effect_index(), 0u); + EXPECT_EQ(listener.published, published + 1); +} + +TEST(LightStateExternal, AnAutomationRunByThePublishReportsItsOwnCall) { + CountingOutput output; + LightState state(&output); + OffReachedListener reached(&state); + state.add_target_state_reached_listener(&reached); + TurnOffOnPublish automation(&state); + state.add_remote_values_listener(&automation); + state.loop(); + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_FALSE(state.remote_values.is_on()); + EXPECT_EQ(reached.reached_off, 1); // once from the turn-off call, not again from the report +} + +TEST(LightStateExternal, SaysNothingWhenNothingChanges) { + CountingOutput output; + LightState state(&output); + ReachedListener listener; + state.add_target_state_reached_listener(&listener); + state.loop(); + + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_TRUE(state.set_external_state(on_at(0.5f))); + EXPECT_EQ(listener.reached, 1); +} + +} // namespace esphome::light::testing From c35d98b622cd04e508e050d8731680444909b1f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 07:19:14 -1000 Subject: [PATCH 107/179] [core] Add `progmem_string_table_find()` (#20790) --- esphome/core/progmem.h | 14 +++++++ .../core/test_progmem_string_table_find.cpp | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/components/core/test_progmem_string_table_find.cpp diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 02ec4778237..983f2e31f85 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -155,4 +155,18 @@ struct LogString; } \ } +/// The index of the string value (len bytes, in RAM) in a PROGMEM_STRING_TABLE, or -1 when it is +/// not one of its strings. One pass over the blob, so a table without offsets costs the same. +template int16_t progmem_string_table_find(const char *value, size_t len) { + const char *entry = Table::BLOB.data(); + for (uint8_t i = 0; i < Table::COUNT; i++) { + const size_t entry_len = ESPHOME_strlen_P(entry); + if (entry_len == len && ESPHOME_strncmp_P(value, entry, len) == 0) { + return i; + } + entry += entry_len + 1; + } + return -1; +} + } // namespace esphome diff --git a/tests/components/core/test_progmem_string_table_find.cpp b/tests/components/core/test_progmem_string_table_find.cpp new file mode 100644 index 00000000000..b344e6d2920 --- /dev/null +++ b/tests/components/core/test_progmem_string_table_find.cpp @@ -0,0 +1,37 @@ +#include + +#include "esphome/core/progmem.h" + +namespace esphome::testing { + +PROGMEM_STRING_TABLE(Names, "light.turn_on", "entity_id", "rgb_color"); + +TEST(ProgmemStringTableFind, FindsWholeStringsOnly) { + EXPECT_EQ(progmem_string_table_find("entity_id", 9), 1); + EXPECT_EQ(progmem_string_table_find("rgb_color", 9), 2); + EXPECT_EQ(progmem_string_table_find("entity", 6), -1); + EXPECT_EQ(progmem_string_table_find("entity_id_x", 11), -1); + EXPECT_EQ(progmem_string_table_find("", 0), -1); +} + +// 130 strings, 520 bytes: over the one-byte offsets, so the blob is scanned +PROGMEM_STRING_TABLE(Big, "s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10", "s11", "s12", "s13", "s14", + "s15", "s16", "s17", "s18", "s19", "s20", "s21", "s22", "s23", "s24", "s25", "s26", "s27", "s28", + "s29", "s30", "s31", "s32", "s33", "s34", "s35", "s36", "s37", "s38", "s39", "s40", "s41", "s42", + "s43", "s44", "s45", "s46", "s47", "s48", "s49", "s50", "s51", "s52", "s53", "s54", "s55", "s56", + "s57", "s58", "s59", "s60", "s61", "s62", "s63", "s64", "s65", "s66", "s67", "s68", "s69", "s70", + "s71", "s72", "s73", "s74", "s75", "s76", "s77", "s78", "s79", "s80", "s81", "s82", "s83", "s84", + "s85", "s86", "s87", "s88", "s89", "s90", "s91", "s92", "s93", "s94", "s95", "s96", "s97", "s98", + "s99", "s100", "s101", "s102", "s103", "s104", "s105", "s106", "s107", "s108", "s109", "s110", + "s111", "s112", "s113", "s114", "s115", "s116", "s117", "s118", "s119", "s120", "s121", "s122", + "s123", "s124", "s125", "s126", "s127", "s128", "s129"); + +TEST(ProgmemStringTableFind, ReachesEveryIndexOfAScannedTable) { + EXPECT_EQ(progmem_string_table_find("s0", 2), 0); + EXPECT_EQ(progmem_string_table_find("s127", 4), 127); + EXPECT_EQ(progmem_string_table_find("s128", 4), 128); + EXPECT_EQ(progmem_string_table_find("s129", 4), 129); + EXPECT_EQ(progmem_string_table_find("s130", 4), -1); +} + +} // namespace esphome::testing From 619728b686c65adfa50a42e2ba7fc3de69b37284 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 07:23:06 -1000 Subject: [PATCH 108/179] [components] Drop stale UART receive bytes with discard_available (#21030) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/bl0906/bl0906.cpp | 4 +--- esphome/components/cm1106/cm1106.cpp | 4 +--- esphome/components/cse7761/cse7761.cpp | 4 +--- .../dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp | 6 +----- esphome/components/dlms_meter/dlms_meter.cpp | 12 +++--------- esphome/components/dlms_meter/dlms_meter.h | 1 - .../components/fingerprint_grow/fingerprint_grow.cpp | 3 +-- esphome/components/hc8/hc8.cpp | 7 ++----- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 11 +++-------- esphome/components/hydreon_rgxx/hydreon_rgxx.cpp | 4 +--- esphome/components/mhz19/mhz19.cpp | 4 +--- esphome/components/pylontech/pylontech.cpp | 6 +----- esphome/components/pzem004t/pzem004t.cpp | 7 ++----- esphome/components/shelly_dimmer/shelly_dimmer.cpp | 5 +---- esphome/components/t6615/t6615.cpp | 7 ++----- esphome/components/tmc22xx/tmc22xx.cpp | 6 ++---- 16 files changed, 23 insertions(+), 68 deletions(-) diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 6f59da77d34..681c7ad15eb 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -134,9 +134,7 @@ void BL0906::handle_actions_() { } } - while (this->available()) { - this->read(); - } + this->discard_available(); this->action_queue_.clear(); } diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index 55b7556cda5..8739e6c0805 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -115,9 +115,7 @@ bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t co bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len) { - // Empty RX Buffer - while (this->available()) - this->read(); + this->discard_available(); this->write_array(command, command_len - 1); this->write_byte(cm1106_checksum(command, command_len)); this->flush(); diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 0c56c741fe6..8ec520b7396 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -116,9 +116,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) { } bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) { - while (this->available()) { - this->read(); - } + this->discard_available(); this->write_(reg, 0); diff --git a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp index ed1c6733abb..08d6024c16b 100644 --- a/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp +++ b/esphome/components/dfrobot_sen0465_uart/dfrobot_sen0465_uart.cpp @@ -40,11 +40,7 @@ void DFRobotSEN0465UARTComponent::loop() { bool DFRobotSEN0465UARTComponent::write_frame(const uint8_t *frame) { // Drop anything left over, such as the answer to the mode change or readings sent before passive mode was set - uint8_t byte; - while (this->available() > 0) { - if (!this->read_byte(&byte)) - break; - } + this->discard_available(); this->rx_count_ = 0; this->write_array(frame, FRAME_SIZE); return true; diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index 32d1817e0e7..7c0ad009205 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -73,7 +73,7 @@ DlmsMeterComponent::DlmsMeterComponent(const uint32_t receive_timeout_ms, const } } -void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); } +void DlmsMeterComponent::setup() { this->discard_available(); } void DlmsMeterComponent::dump_config() { ESP_LOGCONFIG(TAG, "DLMS Meter:"); @@ -122,12 +122,6 @@ void DlmsMeterComponent::loop() { } } -void DlmsMeterComponent::flush_rx_buffer_() { - while (this->available()) { - this->read(); - } -} - void DlmsMeterComponent::read_rx_buffer_() { int available = this->available(); if (available == 0) @@ -137,7 +131,7 @@ void DlmsMeterComponent::read_rx_buffer_() { ESP_LOGW(TAG, "RX Buffer overflow. Frame too large! Dropping frame."); this->bytes_accumulated_ = 0; - this->flush_rx_buffer_(); + this->discard_available(); return; } @@ -145,7 +139,7 @@ void DlmsMeterComponent::read_rx_buffer_() { if (!success) { ESP_LOGW(TAG, "UART read failed. Dropping frame."); this->bytes_accumulated_ = 0; - this->flush_rx_buffer_(); + this->discard_available(); return; } diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index 2d7898f4d3e..9ff2cabf2b5 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -104,7 +104,6 @@ class DlmsMeterComponent final : public Component, public uart::UARTDevice { protected: void read_rx_buffer_(); - void flush_rx_buffer_(); void process_frame_(); void on_data_(const dlms_parser::AxdrCapture &capture); diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 99e88af5b74..3abd1862f34 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -362,8 +362,7 @@ void FingerprintGrowComponent::aura_led_control(uint8_t state, uint8_t speed, ui } uint8_t FingerprintGrowComponent::transfer_(std::vector &data_buffer) { - while (this->available()) - this->read(); + this->discard_available(); this->write((uint8_t) (START_CODE >> 8)); this->write((uint8_t) (START_CODE & 0xFF)); this->write(this->address_[0]); diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp index 4fbfbc84d70..2197a086c36 100644 --- a/esphome/components/hc8/hc8.cpp +++ b/esphome/components/hc8/hc8.cpp @@ -18,9 +18,7 @@ void HC8Component::setup() { this->write_array(HC8_COMMAND_GET_PPM); this->flush(); - // ensure the buffer is empty - while (this->available()) - this->read(); + this->discard_available(); } void HC8Component::update() { @@ -36,8 +34,7 @@ void HC8Component::update() { this->status_clear_warning(); } - while (this->available()) - this->read(); + this->discard_available(); this->write_array(HC8_COMMAND_GET_PPM); this->flush(); diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 8674a1de97c..43311f3d968 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -11,9 +11,7 @@ static constexpr uint32_t PAYLOAD_TIMEOUT_MS = 20; void HlkFm22xComponent::setup() { ESP_LOGCONFIG(TAG, "Setting up HLK-FM22X..."); this->set_enrolling_(false); - while (this->available() > 0) { - this->read(); - } + this->discard_available(); this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_STATUS); }); } @@ -85,8 +83,7 @@ void HlkFm22xComponent::send_command_(HlkFm22xCommand command, const uint8_t *da } this->wait_cycles_ = 0; this->active_command_ = command; - while (this->available() > 0) - this->read(); + this->discard_available(); this->write((uint8_t) (START_CODE >> 8)); this->write((uint8_t) (START_CODE & 0xFF)); this->write((uint8_t) command); @@ -141,9 +138,7 @@ void HlkFm22xComponent::recv_command_() { if (millis() - start > PAYLOAD_TIMEOUT_MS) { ESP_LOGE(TAG, "Timeout waiting for payload (%u bytes)", length); // Drain any partial payload bytes to resync the parser - while (this->available() > 0) { - this->read(); - } + this->discard_available(); return; } delay(1); diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index a1d6553073a..4a479874f53 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -38,9 +38,7 @@ void HydreonRGxxComponent::dump_config() { } void HydreonRGxxComponent::setup() { - while (this->available() != 0) { - this->read(); - } + this->discard_available(); this->schedule_reboot_(); } diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index 0f623bfd726..eda4b4d8e78 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -120,9 +120,7 @@ void MHZ19Component::range_set(MHZ19DetectionRange detection_range) { } bool MHZ19Component::mhz19_write_command_(const uint8_t *command, uint8_t *response) { - // Empty RX Buffer - while (this->available()) - this->read(); + this->discard_available(); this->write_array(command, MHZ19_REQUEST_LENGTH); this->write_byte(mhz19_checksum(command)); this->flush(); diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 90cb4a8b855..acc80b0f839 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -99,11 +99,7 @@ void PylontechComponent::dump_config() { LOG_UPDATE_INTERVAL(this); } -void PylontechComponent::setup() { - while (this->available() != 0) { - this->read(); - } -} +void PylontechComponent::setup() { this->discard_available(); } void PylontechComponent::update() { #ifdef PYLONTECH_CELL_BATTERY_COUNT diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index 2c5e349bd31..c27fb07ce08 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -9,9 +9,7 @@ namespace esphome::pzem004t { ESPHOME_LOG_TAG(TAG, "pzem004t"); void PZEM004T::setup() { - // Clear UART buffer - while (this->available()) - this->read(); + this->discard_available(); // Set module address this->write_state_(SET_ADDRESS); } @@ -19,8 +17,7 @@ void PZEM004T::setup() { void PZEM004T::loop() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_read_ > 500 && this->available() < 7) { - while (this->available()) - this->read(); + this->discard_available(); this->last_read_ = now; } diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index eedf7ff7ce2..8c44c8c5a08 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -483,10 +483,7 @@ void ShellyDimmer::reset_(bool boot0) { // Wait 50ms for the STM32 to reset. delay(50); // NOLINT - // Clear receive buffer. - while (this->available()) { - this->read(); - } + this->discard_available(); this->pin_nrst_->digital_write(true); // Wait 50ms for the STM32 to boot. diff --git a/esphome/components/t6615/t6615.cpp b/esphome/components/t6615/t6615.cpp index d3997528890..6fb58345358 100644 --- a/esphome/components/t6615/t6615.cpp +++ b/esphome/components/t6615/t6615.cpp @@ -32,8 +32,7 @@ void T6615Component::loop() { if (this->available() < 5) { if (this->command_ == T6615Command::GET_PPM && millis() - this->command_time_ > T6615_TIMEOUT) { /* command got eaten, clear the buffer and fire another */ - while (this->available()) - this->read(); + this->discard_available(); this->send_ppm_command_(); } return; @@ -48,9 +47,7 @@ void T6615Component::loop() { if (response_buffer[0] != T6615_MAGIC || response_buffer[1] != T6615_ADDR_HOST) { ESP_LOGW(TAG, "Got bad data from T6615! Magic was %02X and address was %02X", response_buffer[0], response_buffer[1]); - /* make sure the buffer is empty */ - while (this->available()) - this->read(); + this->discard_available(); /* try again to read the sensor */ this->send_ppm_command_(); this->status_set_warning(); diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index f68496bd0ca..6ee4c9e3ee6 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -446,8 +446,7 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { 0}; datagram[7] = datagram_crc(datagram, 7); - while (this->available()) - this->read(); + this->discard_available(); this->write_array(datagram, DATAGRAM_SIZE); this->flush(); // On the single wire bus every byte sent is also received, drop that echo @@ -475,8 +474,7 @@ optional TMC22XXStepper::read_register(uint8_t reg) { uint8_t request[READ_REQUEST_SIZE] = {SYNC_BYTE, this->address_, reg, 0}; request[3] = datagram_crc(request, 3); - while (this->available()) - this->read(); + this->discard_available(); this->write_array(request, READ_REQUEST_SIZE); this->flush(); uint8_t reply[DATAGRAM_SIZE]; From deea006b396782aa27d708d648211c1d26e078b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 07:43:05 -1000 Subject: [PATCH 109/179] [components] Set referenced components with cg.add_variable and register_parented (a-l) (#21021) --- .../components/absolute_humidity/sensor.py | 7 ++--- .../analog_threshold/binary_sensor.py | 3 +- esphome/components/aqi/sensor.py | 7 ++--- esphome/components/bang_bang/climate.py | 8 ++---- esphome/components/bedjet/__init__.py | 4 +-- esphome/components/binary/fan/__init__.py | 13 ++------- esphome/components/binary/light/__init__.py | 3 +- .../components/bk72xx_ble_tracker/__init__.py | 3 +- esphome/components/bp1658cj/output.py | 3 +- esphome/components/bp5758d/output.py | 3 +- esphome/components/ccs811/sensor.py | 8 ++---- esphome/components/cd74hc4067/sensor.py | 3 +- esphome/components/climate_ir/__init__.py | 8 ++---- esphome/components/color_temperature/light.py | 7 ++--- .../components/copy/binary_sensor/__init__.py | 3 +- esphome/components/copy/button/__init__.py | 3 +- esphome/components/copy/cover/__init__.py | 3 +- esphome/components/copy/fan/__init__.py | 3 +- esphome/components/copy/lock/__init__.py | 3 +- esphome/components/copy/number/__init__.py | 3 +- esphome/components/copy/select/__init__.py | 3 +- esphome/components/copy/sensor/__init__.py | 3 +- esphome/components/copy/switch/__init__.py | 3 +- esphome/components/copy/text/__init__.py | 3 +- .../components/copy/text_sensor/__init__.py | 3 +- esphome/components/counter/sensor.py | 6 ++-- esphome/components/ct_clamp/sensor.py | 3 +- esphome/components/current_based/cover.py | 6 ++-- esphome/components/cwww/light.py | 6 ++-- esphome/components/datetime/__init__.py | 4 +-- esphome/components/deep_sleep/__init__.py | 3 +- esphome/components/dew_point/sensor.py | 7 ++--- .../components/display_menu_base/__init__.py | 9 ++---- esphome/components/duty_time/sensor.py | 4 +-- esphome/components/ecocomfort2/__init__.py | 3 +- esphome/components/endstop/cover.py | 6 ++-- esphome/components/ens160_base/__init__.py | 8 +++--- esphome/components/esp32_camera/__init__.py | 3 +- .../esp32_hosted/update/__init__.py | 3 +- esphome/components/feedback/cover.py | 28 +++++++------------ esphome/components/filter_lifetime/sensor.py | 8 +++--- esphome/components/gp2y1010au0f/sensor.py | 6 ++-- esphome/components/graph/__init__.py | 15 +++------- .../graphical_display_menu/__init__.py | 17 +++-------- esphome/components/hbridge/light/__init__.py | 6 ++-- .../http_request/update/__init__.py | 6 ++-- esphome/components/i2c/__init__.py | 3 +- esphome/components/improv_ble/__init__.py | 9 ++---- esphome/components/improv_serial/__init__.py | 2 +- esphome/components/integration/sensor.py | 3 +- esphome/components/key_collector/__init__.py | 3 +- esphome/components/lcd_menu/__init__.py | 3 +- .../components/ln882h_ble_tracker/__init__.py | 3 +- 53 files changed, 93 insertions(+), 203 deletions(-) diff --git a/esphome/components/absolute_humidity/sensor.py b/esphome/components/absolute_humidity/sensor.py index f11ba5af990..10ad57a8828 100644 --- a/esphome/components/absolute_humidity/sensor.py +++ b/esphome/components/absolute_humidity/sensor.py @@ -44,10 +44,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - temperature_sensor = await cg.get_variable(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(temperature_sensor)) - - humidity_sensor = await cg.get_variable(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(humidity_sensor)) + await cg.add_variable(var.set_temperature_sensor, config[CONF_TEMPERATURE]) + await cg.add_variable(var.set_humidity_sensor, config[CONF_HUMIDITY]) cg.add(var.set_equation(config[CONF_EQUATION])) diff --git a/esphome/components/analog_threshold/binary_sensor.py b/esphome/components/analog_threshold/binary_sensor.py index b2de1d6184c..f21efb09055 100644 --- a/esphome/components/analog_threshold/binary_sensor.py +++ b/esphome/components/analog_threshold/binary_sensor.py @@ -37,8 +37,7 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR_ID]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR_ID]) if isinstance(config[CONF_THRESHOLD], dict): lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], cg.float_) diff --git a/esphome/components/aqi/sensor.py b/esphome/components/aqi/sensor.py index a98d977227c..6e0a3e74963 100644 --- a/esphome/components/aqi/sensor.py +++ b/esphome/components/aqi/sensor.py @@ -53,11 +53,8 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - pm_2_5_sensor = await cg.get_variable(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(pm_2_5_sensor)) - - pm_10_0_sensor = await cg.get_variable(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor)) + await cg.add_variable(var.set_pm_2_5_sensor, config[CONF_PM_2_5]) + await cg.add_variable(var.set_pm_10_0_sensor, config[CONF_PM_10_0]) cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE])) cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False))) diff --git a/esphome/components/bang_bang/climate.py b/esphome/components/bang_bang/climate.py index 65c5eaed187..e09b187ae3d 100644 --- a/esphome/components/bang_bang/climate.py +++ b/esphome/components/bang_bang/climate.py @@ -46,12 +46,8 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) - - if CONF_HUMIDITY_SENSOR in config: - sens = await cg.get_variable(config[CONF_HUMIDITY_SENSOR]) - cg.add(var.set_humidity_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) normal_config = BangBangClimateTargetTempConfig( config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW], diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index 61dc73c73d5..406955084ea 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -44,8 +44,6 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) - if time_id := config.get(CONF_TIME_ID): - time_ = await cg.get_variable(time_id) - cg.add(var.set_time_id(time_)) + await cg.add_variable(var.set_time_id, config.get(CONF_TIME_ID)) if (receive_timeout := config.get(CONF_RECEIVE_TIMEOUT)) is not None: cg.add(var.set_status_timeout(receive_timeout)) diff --git a/esphome/components/binary/fan/__init__.py b/esphome/components/binary/fan/__init__.py index 03a03ec7cac..a97920b408a 100644 --- a/esphome/components/binary/fan/__init__.py +++ b/esphome/components/binary/fan/__init__.py @@ -25,13 +25,6 @@ async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) - - if oscillation_output_id := config.get(CONF_OSCILLATION_OUTPUT): - oscillation_output = await cg.get_variable(oscillation_output_id) - cg.add(var.set_oscillating(oscillation_output)) - - if direction_output_id := config.get(CONF_DIRECTION_OUTPUT): - direction_output = await cg.get_variable(direction_output_id) - cg.add(var.set_direction(direction_output)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) + await cg.add_variable(var.set_oscillating, config.get(CONF_OSCILLATION_OUTPUT)) + await cg.add_variable(var.set_direction, config.get(CONF_DIRECTION_OUTPUT)) diff --git a/esphome/components/binary/light/__init__.py b/esphome/components/binary/light/__init__.py index b6eddac3410..3883394050c 100644 --- a/esphome/components/binary/light/__init__.py +++ b/esphome/components/binary/light/__init__.py @@ -20,5 +20,4 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - out = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(out)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index abef3c7752b..5a03fdd293c 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -144,8 +144,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_BK72XX_BLE_ID]) # The tracker registers itself as a controller scan listener in setup(); # request the codegen-sized StaticVector slot for it. bk72xx_ble.request_scan_listener_slot() diff --git a/esphome/components/bp1658cj/output.py b/esphome/components/bp1658cj/output.py index 93e3c75daf9..ca9c94f733b 100644 --- a/esphome/components/bp1658cj/output.py +++ b/esphome/components/bp1658cj/output.py @@ -24,6 +24,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_BP1658CJ_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_BP1658CJ_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/bp5758d/output.py b/esphome/components/bp5758d/output.py index bbca7c18cc5..24fe8a04e50 100644 --- a/esphome/components/bp5758d/output.py +++ b/esphome/components/bp5758d/output.py @@ -25,7 +25,6 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) - parent = await cg.get_variable(config[CONF_BP5758D_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_BP5758D_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) cg.add(var.set_current(config[CONF_CURRENT])) diff --git a/esphome/components/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index 80948a6b433..703ae619c5d 100644 --- a/esphome/components/ccs811/sensor.py +++ b/esphome/components/ccs811/sensor.py @@ -72,9 +72,5 @@ async def to_code(config: ConfigType) -> None: if (baseline := config.get(CONF_BASELINE)) is not None: cg.add(var.set_baseline(baseline)) - if temperature_id := config.get(CONF_TEMPERATURE): - sens = await cg.get_variable(temperature_id) - cg.add(var.set_temperature(sens)) - if humidity_id := config.get(CONF_HUMIDITY): - sens = await cg.get_variable(humidity_id) - cg.add(var.set_humidity(sens)) + await cg.add_variable(var.set_temperature, config.get(CONF_TEMPERATURE)) + await cg.add_variable(var.set_humidity, config.get(CONF_HUMIDITY)) diff --git a/esphome/components/cd74hc4067/sensor.py b/esphome/components/cd74hc4067/sensor.py index 670b0502715..aac921178e5 100644 --- a/esphome/components/cd74hc4067/sensor.py +++ b/esphome/components/cd74hc4067/sensor.py @@ -53,5 +53,4 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_pin(config[CONF_NUMBER])) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_source(sens)) + await cg.add_variable(var.set_source, config[CONF_SENSOR]) diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index f4b43e3a523..27c24464595 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -74,12 +74,8 @@ async def register_climate_ir(var: MockObj, config: ConfigType) -> None: cg.add(var.set_supports_heat_cool(False)) if remote_base.CONF_RECEIVER_ID in config: await remote_base.register_listener(var, config) - if sensor_id := config.get(CONF_SENSOR): - sens = await cg.get_variable(sensor_id) - cg.add(var.set_sensor(sens)) - if sensor_id := config.get(CONF_HUMIDITY_SENSOR): - sens = await cg.get_variable(sensor_id) - cg.add(var.set_humidity_sensor(sens)) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) async def new_climate_ir(config: ConfigType, *args: SafeExpType) -> MockObj: diff --git a/esphome/components/color_temperature/light.py b/esphome/components/color_temperature/light.py index 7686ede1558..f66b51eff4e 100644 --- a/esphome/components/color_temperature/light.py +++ b/esphome/components/color_temperature/light.py @@ -33,11 +33,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - color_temperature = await cg.get_variable(config[CONF_COLOR_TEMPERATURE]) - cg.add(var.set_color_temperature(color_temperature)) - - brightness = await cg.get_variable(config[CONF_BRIGHTNESS]) - cg.add(var.set_brightness(brightness)) + await cg.add_variable(var.set_color_temperature, config[CONF_COLOR_TEMPERATURE]) + await cg.add_variable(var.set_brightness, config[CONF_BRIGHTNESS]) cg.add(var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE])) cg.add(var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE])) diff --git a/esphome/components/copy/binary_sensor/__init__.py b/esphome/components/copy/binary_sensor/__init__.py index cc8492f21e0..e21743ef296 100644 --- a/esphome/components/copy/binary_sensor/__init__.py +++ b/esphome/components/copy/binary_sensor/__init__.py @@ -38,5 +38,4 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/button/__init__.py b/esphome/components/copy/button/__init__.py index 768131bbe58..17155b2f744 100644 --- a/esphome/components/copy/button/__init__.py +++ b/esphome/components/copy/button/__init__.py @@ -38,5 +38,4 @@ async def to_code(config: ConfigType) -> None: await button.register_button(var, config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/cover/__init__.py b/esphome/components/copy/cover/__init__.py index d23602fa741..7d01cd93ac2 100644 --- a/esphome/components/copy/cover/__init__.py +++ b/esphome/components/copy/cover/__init__.py @@ -36,5 +36,4 @@ async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/fan/__init__.py b/esphome/components/copy/fan/__init__.py index ffa414c5f21..44c0d654073 100644 --- a/esphome/components/copy/fan/__init__.py +++ b/esphome/components/copy/fan/__init__.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/lock/__init__.py b/esphome/components/copy/lock/__init__.py index 8d9c4b6eca7..5f2f8f2eb1e 100644 --- a/esphome/components/copy/lock/__init__.py +++ b/esphome/components/copy/lock/__init__.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: var = await lock.new_lock(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/number/__init__.py b/esphome/components/copy/number/__init__.py index 9659a605f91..c0370afc18c 100644 --- a/esphome/components/copy/number/__init__.py +++ b/esphome/components/copy/number/__init__.py @@ -38,5 +38,4 @@ async def to_code(config: ConfigType) -> None: var = await number.new_number(config, min_value=0, max_value=0, step=0) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/select/__init__.py b/esphome/components/copy/select/__init__.py index 97776b1edd7..e6a4091be8f 100644 --- a/esphome/components/copy/select/__init__.py +++ b/esphome/components/copy/select/__init__.py @@ -31,5 +31,4 @@ async def to_code(config: ConfigType) -> None: await select.register_select(var, config, options=[]) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/sensor/__init__.py b/esphome/components/copy/sensor/__init__.py index 54687980475..746a48e19ce 100644 --- a/esphome/components/copy/sensor/__init__.py +++ b/esphome/components/copy/sensor/__init__.py @@ -42,5 +42,4 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/switch/__init__.py b/esphome/components/copy/switch/__init__.py index 0e714540f96..6bcd10ff200 100644 --- a/esphome/components/copy/switch/__init__.py +++ b/esphome/components/copy/switch/__init__.py @@ -36,5 +36,4 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/text/__init__.py b/esphome/components/copy/text/__init__.py index 59fdce6c96e..44d61a88506 100644 --- a/esphome/components/copy/text/__init__.py +++ b/esphome/components/copy/text/__init__.py @@ -31,5 +31,4 @@ async def to_code(config: ConfigType) -> None: var = await text.new_text(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/text_sensor/__init__.py b/esphome/components/copy/text_sensor/__init__.py index 146beae5eaa..9c5495938e0 100644 --- a/esphome/components/copy/text_sensor/__init__.py +++ b/esphome/components/copy/text_sensor/__init__.py @@ -30,5 +30,4 @@ async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - source = await cg.get_variable(config[CONF_SOURCE_ID]) - cg.add(var.set_source(source)) + await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) diff --git a/esphome/components/counter/sensor.py b/esphome/components/counter/sensor.py index 0f36b1721bf..0c3766cbe7c 100644 --- a/esphome/components/counter/sensor.py +++ b/esphome/components/counter/sensor.py @@ -55,10 +55,8 @@ async def to_code(config): ) await cg.register_component(var, config) await sensor.register_sensor(var, config) - if (source := config.get(CONF_SENSOR)) is not None: - cg.add(var.count_updates_from(await cg.get_variable(source))) - if (source := config.get(CONF_BINARY_SENSOR)) is not None: - cg.add(var.count_true_from(await cg.get_variable(source))) + await cg.add_variable(var.count_updates_from, config.get(CONF_SENSOR)) + await cg.add_variable(var.count_true_from, config.get(CONF_BINARY_SENSOR)) automation.register_apply_action( diff --git a/esphome/components/ct_clamp/sensor.py b/esphome/components/ct_clamp/sensor.py index 8ef211cb247..b9959d65f72 100644 --- a/esphome/components/ct_clamp/sensor.py +++ b/esphome/components/ct_clamp/sensor.py @@ -41,6 +41,5 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_source(sens)) + await cg.add_variable(var.set_source, config[CONF_SENSOR]) cg.add(var.set_sample_duration(config[CONF_SAMPLE_DURATION])) diff --git a/esphome/components/current_based/cover.py b/esphome/components/current_based/cover.py index a552956082a..4edcb98a1d9 100644 --- a/esphome/components/current_based/cover.py +++ b/esphome/components/current_based/cover.py @@ -77,8 +77,7 @@ async def to_code(config: ConfigType) -> None: ) # OPEN - bin = await cg.get_variable(config[CONF_OPEN_SENSOR]) - cg.add(var.set_open_sensor(bin)) + await cg.add_variable(var.set_open_sensor, config[CONF_OPEN_SENSOR]) cg.add( var.set_open_moving_current_threshold( config[CONF_OPEN_MOVING_CURRENT_THRESHOLD] @@ -97,8 +96,7 @@ async def to_code(config: ConfigType) -> None: ) # CLOSE - bin = await cg.get_variable(config[CONF_CLOSE_SENSOR]) - cg.add(var.set_close_sensor(bin)) + await cg.add_variable(var.set_close_sensor, config[CONF_CLOSE_SENSOR]) cg.add( var.set_close_moving_current_threshold( config[CONF_CLOSE_MOVING_CURRENT_THRESHOLD] diff --git a/esphome/components/cwww/light.py b/esphome/components/cwww/light.py index 90fe6d0bad8..3ea6a85923a 100644 --- a/esphome/components/cwww/light.py +++ b/esphome/components/cwww/light.py @@ -36,13 +36,11 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) - cwhite = await cg.get_variable(config[CONF_COLD_WHITE]) - cg.add(var.set_cold_white(cwhite)) + await cg.add_variable(var.set_cold_white, config[CONF_COLD_WHITE]) if cold_white_color_temperature := config.get(CONF_COLD_WHITE_COLOR_TEMPERATURE): cg.add(var.set_cold_white_temperature(cold_white_color_temperature)) - wwhite = await cg.get_variable(config[CONF_WARM_WHITE]) - cg.add(var.set_warm_white(wwhite)) + await cg.add_variable(var.set_warm_white, config[CONF_WARM_WHITE]) if warm_white_color_temperature := config.get(CONF_WARM_WHITE_COLOR_TEMPERATURE): cg.add(var.set_warm_white_temperature(warm_white_color_temperature)) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 20fd258cb00..e9ea05dcdab 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -148,9 +148,7 @@ async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None: forward=[automation.parent_ref(var).state_as_esptime()], ) - if CONF_TIME_ID in config: - rtc = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_rtc(rtc)) + await cg.add_variable(var.set_rtc, config.get(CONF_TIME_ID)) for conf in config.get(CONF_ON_TIME, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 7230b1e8e9c..69aa1120ba1 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -491,8 +491,7 @@ async def deep_sleep_enter_to_code( until = config[CONF_UNTIL] cg.add(var.set_until(until[CONF_HOUR], until[CONF_MINUTE], until[CONF_SECOND])) - time_ = await cg.get_variable(config[CONF_TIME_ID]) - cg.add(var.set_time(time_)) + await cg.add_variable(var.set_time, config[CONF_TIME_ID]) return var diff --git a/esphome/components/dew_point/sensor.py b/esphome/components/dew_point/sensor.py index 555fdef289e..e7361c8dbfe 100644 --- a/esphome/components/dew_point/sensor.py +++ b/esphome/components/dew_point/sensor.py @@ -40,8 +40,5 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - temperature_sensor = await cg.get_variable(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(temperature_sensor)) - - humidity_sensor = await cg.get_variable(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(humidity_sensor)) + await cg.add_variable(var.set_temperature_sensor, config[CONF_TEMPERATURE]) + await cg.add_variable(var.set_humidity_sensor, config[CONF_HUMIDITY]) diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 791242cda12..ba51bdccaae 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -280,15 +280,12 @@ async def menu_item_to_code(menu, config, parent): if CONF_IMMEDIATE_EDIT in config: cg.add(item.set_immediate_edit(config[CONF_IMMEDIATE_EDIT])) if config[CONF_TYPE] == CONF_SELECT: - var = await cg.get_variable(config[CONF_SELECT]) - cg.add(item.set_select_variable(var)) + await cg.add_variable(item.set_select_variable, config[CONF_SELECT]) if config[CONF_TYPE] == CONF_NUMBER: - var = await cg.get_variable(config[CONF_NUMBER]) - cg.add(item.set_number_variable(var)) + await cg.add_variable(item.set_number_variable, config[CONF_NUMBER]) cg.add(item.set_format(config[CONF_FORMAT])) if config[CONF_TYPE] == CONF_SWITCH: - var = await cg.get_variable(config[CONF_SWITCH]) - cg.add(item.set_switch_variable(var)) + await cg.add_variable(item.set_switch_variable, config[CONF_SWITCH]) cg.add(item.set_on_text(config[CONF_ON_TEXT])) cg.add(item.set_off_text(config[CONF_OFF_TEXT])) await _build_item_automations(item, config) diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 6b035d5c982..1f733fc6bb1 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -57,9 +57,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) cg.add(var.set_restore(config[CONF_RESTORE])) - if CONF_SENSOR in config: - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) if CONF_LAMBDA in config: lambda_ = await cg.process_lambda(config[CONF_LAMBDA], [], return_type=cg.bool_) cg.add(var.set_lambda(lambda_)) diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py index 8a0e1654188..c2540db644e 100644 --- a/esphome/components/ecocomfort2/__init__.py +++ b/esphome/components/ecocomfort2/__init__.py @@ -53,8 +53,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) - if (time_id := config.get(CONF_TIME_ID)) is not None: - cg.add(var.set_time(await cg.get_variable(time_id))) + await cg.add_variable(var.set_time, config.get(CONF_TIME_ID)) def _final_validate(config: ConfigType) -> ConfigType: diff --git a/esphome/components/endstop/cover.py b/esphome/components/endstop/cover.py index 0e27189500c..066326e9d46 100644 --- a/esphome/components/endstop/cover.py +++ b/esphome/components/endstop/cover.py @@ -43,15 +43,13 @@ async def to_code(config: ConfigType) -> None: var.get_stop_trigger(), [], config[CONF_STOP_ACTION] ) - bin = await cg.get_variable(config[CONF_OPEN_ENDSTOP]) - cg.add(var.set_open_endstop(bin)) + await cg.add_variable(var.set_open_endstop, config[CONF_OPEN_ENDSTOP]) cg.add(var.set_open_duration(config[CONF_OPEN_DURATION])) await automation.build_automation( var.get_open_trigger(), [], config[CONF_OPEN_ACTION] ) - bin = await cg.get_variable(config[CONF_CLOSE_ENDSTOP]) - cg.add(var.set_close_endstop(bin)) + await cg.add_variable(var.set_close_endstop, config[CONF_CLOSE_ENDSTOP]) cg.add(var.set_close_duration(config[CONF_CLOSE_DURATION])) await automation.build_automation( var.get_close_trigger(), [], config[CONF_CLOSE_ACTION] diff --git a/esphome/components/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index 8ef5971f17a..61342cbe5c0 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -70,9 +70,9 @@ async def to_code_base(config: ConfigType) -> MockObj: await sensors(CONF_AQI, var.set_aqi) if compensation_config := config.get(CONF_COMPENSATION): - sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - sens = await cg.get_variable(compensation_config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) + await cg.add_variable( + var.set_temperature, compensation_config[CONF_TEMPERATURE] + ) + await cg.add_variable(var.set_humidity, compensation_config[CONF_HUMIDITY]) return var diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 0014d38a5c4..a83a4817b1a 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -376,8 +376,7 @@ async def to_code(config: ConfigType) -> None: extclk = config[CONF_EXTERNAL_CLOCK] cg.add(var.set_external_clock(extclk[CONF_PIN], extclk[CONF_FREQUENCY])) if i2c_id := config.get(CONF_I2C_ID): - i2c_hub = await cg.get_variable(i2c_id) - cg.add(var.set_i2c_id(i2c_hub)) + await cg.add_variable(var.set_i2c_id, i2c_id) else: i2c_pins = config[CONF_I2C_PINS] cg.add(var.set_i2c_pins(i2c_pins[CONF_SDA], i2c_pins[CONF_SCL])) diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index 8e85cce75a5..4ccd6a12f8b 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -104,8 +104,7 @@ async def to_code(config: dict[str, Any]) -> None: cg.add(var.set_firmware_data(prog_arr)) cg.add(var.set_firmware_size(len(firmware_data))) else: - http_request_var = await cg.get_variable(config[CONF_HTTP_REQUEST_ID]) - cg.add(var.set_http_request_parent(http_request_var)) + await cg.add_variable(var.set_http_request_parent, config[CONF_HTTP_REQUEST_ID]) cg.add(var.set_source_url(config[CONF_SOURCE])) cg.add_define("USE_ESP32_HOSTED_HTTP_UPDATE") diff --git a/esphome/components/feedback/cover.py b/esphome/components/feedback/cover.py index 9fbda9a9737..f203e8d5186 100644 --- a/esphome/components/feedback/cover.py +++ b/esphome/components/feedback/cover.py @@ -129,30 +129,22 @@ async def to_code(config: ConfigType) -> None: var.get_open_trigger(), [], config[CONF_OPEN_ACTION] ) cg.add(var.set_open_duration(config[CONF_OPEN_DURATION])) - if CONF_OPEN_ENDSTOP in config: - bin = await cg.get_variable(config[CONF_OPEN_ENDSTOP]) - cg.add(var.set_open_endstop(bin)) - if CONF_OPEN_SENSOR in config: - bin = await cg.get_variable(config[CONF_OPEN_SENSOR]) - cg.add(var.set_open_sensor(bin)) - if CONF_OPEN_OBSTACLE_SENSOR in config: - bin = await cg.get_variable(config[CONF_OPEN_OBSTACLE_SENSOR]) - cg.add(var.set_open_obstacle_sensor(bin)) + await cg.add_variable(var.set_open_endstop, config.get(CONF_OPEN_ENDSTOP)) + await cg.add_variable(var.set_open_sensor, config.get(CONF_OPEN_SENSOR)) + await cg.add_variable( + var.set_open_obstacle_sensor, config.get(CONF_OPEN_OBSTACLE_SENSOR) + ) # CLOSE await automation.build_automation( var.get_close_trigger(), [], config[CONF_CLOSE_ACTION] ) cg.add(var.set_close_duration(config[CONF_CLOSE_DURATION])) - if CONF_CLOSE_ENDSTOP in config: - bin = await cg.get_variable(config[CONF_CLOSE_ENDSTOP]) - cg.add(var.set_close_endstop(bin)) - if CONF_CLOSE_SENSOR in config: - bin = await cg.get_variable(config[CONF_CLOSE_SENSOR]) - cg.add(var.set_close_sensor(bin)) - if CONF_CLOSE_OBSTACLE_SENSOR in config: - bin = await cg.get_variable(config[CONF_CLOSE_OBSTACLE_SENSOR]) - cg.add(var.set_close_obstacle_sensor(bin)) + await cg.add_variable(var.set_close_endstop, config.get(CONF_CLOSE_ENDSTOP)) + await cg.add_variable(var.set_close_sensor, config.get(CONF_CLOSE_SENSOR)) + await cg.add_variable( + var.set_close_obstacle_sensor, config.get(CONF_CLOSE_OBSTACLE_SENSOR) + ) # OTHER if CONF_MAX_DURATION in config: diff --git a/esphome/components/filter_lifetime/sensor.py b/esphome/components/filter_lifetime/sensor.py index 58af033f329..549f5a20a44 100644 --- a/esphome/components/filter_lifetime/sensor.py +++ b/esphome/components/filter_lifetime/sensor.py @@ -72,15 +72,15 @@ async def to_code(config: ConfigType) -> None: if (is_on := config.get(CONF_IS_ON)) is not None: cg.add(var.set_is_on_lambda(await cg.templatable(is_on, [], bool))) - elif (is_on_sensor := config.get(CONF_IS_ON_SENSOR)) is not None: - cg.add(var.set_is_on_sensor(await cg.get_variable(is_on_sensor))) + await cg.add_variable(var.set_is_on_sensor, config.get(CONF_IS_ON_SENSOR)) if (current_speed := config.get(CONF_CURRENT_SPEED)) is not None: cg.add( var.set_current_speed_lambda(await cg.templatable(current_speed, [], float)) ) - elif (speed_sensor := config.get(CONF_CURRENT_SPEED_SENSOR)) is not None: - cg.add(var.set_current_speed_sensor(await cg.get_variable(speed_sensor))) + await cg.add_variable( + var.set_current_speed_sensor, config.get(CONF_CURRENT_SPEED_SENSOR) + ) sensors = sensor.sub_sensors(config) await sensors(CONF_RUNTIME_HOURS, var.set_runtime_hours_sensor) diff --git a/esphome/components/gp2y1010au0f/sensor.py b/esphome/components/gp2y1010au0f/sensor.py index 3121aa1de50..9d541a3d88a 100644 --- a/esphome/components/gp2y1010au0f/sensor.py +++ b/esphome/components/gp2y1010au0f/sensor.py @@ -49,8 +49,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) # the ADC sensor to read voltage from - adc_sensor = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_adc_source(adc_sensor)) + await cg.add_variable(var.set_adc_source, config[CONF_SENSOR]) cg.add( var.set_voltage_refs( config[CONF_ADC_VOLTAGE_OFFSET], config[CONF_ADC_VOLTAGE_MULTIPLIER] @@ -58,5 +57,4 @@ async def to_code(config: ConfigType) -> None: ) # the binary output to control the module's internal IR LED - led_output = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_led_output(led_output)) + await cg.add_variable(var.set_led_output, config[CONF_OUTPUT]) diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index ce9035655c3..9dfa8df4cc8 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -169,8 +169,7 @@ async def to_code(config: ConfigType) -> None: # Trace options for trace in config[CONF_TRACES]: tr = cg.new_Pvariable(trace[CONF_ID], GraphTrace()) - sens = await cg.get_variable(trace[CONF_SENSOR]) - cg.add(tr.set_sensor(sens)) + await cg.add_variable(tr.set_sensor, trace[CONF_SENSOR]) if CONF_NAME in trace: cg.add(tr.set_name(trace[CONF_NAME])) else: @@ -179,9 +178,7 @@ async def to_code(config: ConfigType) -> None: cg.add(tr.set_line_thickness(trace[CONF_LINE_THICKNESS])) if CONF_LINE_TYPE in trace: cg.add(tr.set_line_type(trace[CONF_LINE_TYPE])) - if CONF_COLOR in trace: - c = await cg.get_variable(trace[CONF_COLOR]) - cg.add(tr.set_line_color(c)) + await cg.add_variable(tr.set_line_color, trace.get(CONF_COLOR)) if CONF_CONTINUOUS in trace: cg.add(tr.set_continuous(trace[CONF_CONTINUOUS])) cg.add(var.add_trace(tr)) @@ -189,12 +186,8 @@ async def to_code(config: ConfigType) -> None: if CONF_LEGEND in config: lgd = config[CONF_LEGEND] legend = cg.new_Pvariable(lgd[CONF_ID], GraphLegend()) - if CONF_NAME_FONT in lgd: - font = await cg.get_variable(lgd[CONF_NAME_FONT]) - cg.add(legend.set_name_font(font)) - if CONF_VALUE_FONT in lgd: - font = await cg.get_variable(lgd[CONF_VALUE_FONT]) - cg.add(legend.set_value_font(font)) + await cg.add_variable(legend.set_name_font, lgd.get(CONF_NAME_FONT)) + await cg.add_variable(legend.set_value_font, lgd.get(CONF_VALUE_FONT)) if CONF_WIDTH in lgd: cg.add(legend.set_width(lgd[CONF_WIDTH])) if CONF_HEIGHT in lgd: diff --git a/esphome/components/graphical_display_menu/__init__.py b/esphome/components/graphical_display_menu/__init__.py index a398aea6192..5534321dd7f 100644 --- a/esphome/components/graphical_display_menu/__init__.py +++ b/esphome/components/graphical_display_menu/__init__.py @@ -55,12 +55,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if display_config := config.get(CONF_DISPLAY): - drawing_display = await cg.get_variable(display_config) - cg.add(var.set_display(drawing_display)) - - menu_font = await cg.get_variable(config[CONF_FONT]) - cg.add(var.set_font(menu_font)) + await cg.add_variable(var.set_display, config.get(CONF_DISPLAY)) + await cg.add_variable(var.set_font, config[CONF_FONT]) if (menu_item_value_config := config.get(CONF_MENU_ITEM_VALUE, None)) is not None: if isinstance(menu_item_value_config, core.Lambda): @@ -73,13 +69,8 @@ async def to_code(config: ConfigType) -> None: else: cg.add(var.set_menu_item_value(menu_item_value_config)) - if foreground_color_config := config.get(CONF_FOREGROUND_COLOR): - foreground_color = await cg.get_variable(foreground_color_config) - cg.add(var.set_foreground_color(foreground_color)) - - if background_color_config := config.get(CONF_BACKGROUND_COLOR): - background_color = await cg.get_variable(background_color_config) - cg.add(var.set_background_color(background_color)) + await cg.add_variable(var.set_foreground_color, config.get(CONF_FOREGROUND_COLOR)) + await cg.add_variable(var.set_background_color, config.get(CONF_BACKGROUND_COLOR)) for conf in config.get(CONF_ON_REDRAW, []): await automation.build_callback_automation( diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index f7866cb9907..95cf9dc6dc8 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -28,7 +28,5 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await light.register_light(var, config) - hside = await cg.get_variable(config[CONF_PIN_A]) - cg.add(var.set_pina_pin(hside)) - lside = await cg.get_variable(config[CONF_PIN_B]) - cg.add(var.set_pinb_pin(lside)) + await cg.add_variable(var.set_pina_pin, config[CONF_PIN_A]) + await cg.add_variable(var.set_pinb_pin, config[CONF_PIN_B]) diff --git a/esphome/components/http_request/update/__init__.py b/esphome/components/http_request/update/__init__.py index 4bdc30e4cf3..e22270f1f2e 100644 --- a/esphome/components/http_request/update/__init__.py +++ b/esphome/components/http_request/update/__init__.py @@ -32,10 +32,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await update.new_update(config) - ota_parent = await cg.get_variable(config[CONF_OTA_ID]) - cg.add(var.set_ota_parent(ota_parent)) - request_parent = await cg.get_variable(config[CONF_HTTP_REQUEST_ID]) - cg.add(var.set_request_parent(request_parent)) + await cg.add_variable(var.set_ota_parent, config[CONF_OTA_ID]) + await cg.add_variable(var.set_request_parent, config[CONF_HTTP_REQUEST_ID]) cg.add(var.set_source_url(config[CONF_SOURCE])) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 84f6d0c2e66..8598998f563 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -390,8 +390,7 @@ async def register_i2c_device(var: MockObj, config: ConfigType) -> None: This is a coroutine, you need to await it with an 'await' expression! """ - parent = await cg.get_variable(config[CONF_I2C_ID]) - cg.add(var.set_i2c_bus(parent)) + await cg.add_variable(var.set_i2c_bus, config[CONF_I2C_ID]) cg.add(var.set_i2c_address(config[CONF_ADDRESS])) diff --git a/esphome/components/improv_ble/__init__.py b/esphome/components/improv_ble/__init__.py index e7d4be8cc3d..dbc6542ec81 100644 --- a/esphome/components/improv_ble/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -135,13 +135,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_wifi_timeout(config[CONF_WIFI_TIMEOUT])) - if CONF_AUTHORIZER in config and config[CONF_AUTHORIZER] is not None: - activator = await cg.get_variable(config[CONF_AUTHORIZER]) - cg.add(var.set_authorizer(activator)) - - if CONF_STATUS_INDICATOR in config: - status_indicator = await cg.get_variable(config[CONF_STATUS_INDICATOR]) - cg.add(var.set_status_indicator(status_indicator)) + await cg.add_variable(var.set_authorizer, config.get(CONF_AUTHORIZER)) + await cg.add_variable(var.set_status_indicator, config.get(CONF_STATUS_INDICATOR)) if any(config.get(entry.conf_key) for entry in _CALLBACK_AUTOMATIONS): cg.add_define("USE_IMPROV_BLE_STATE_CALLBACK") diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index 3e1aef21bb6..6bb5725b416 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -68,5 +68,5 @@ async def to_code(config: ConfigType) -> None: await improv_base.setup_improv_core(var, config) cg.add_define("USE_IMPROV_SERIAL") if (uart_id := config.get(CONF_UART_ID)) is not None: - cg.add(var.set_uart(await cg.get_variable(uart_id))) + await cg.add_variable(var.set_uart, uart_id) cg.add_define("USE_IMPROV_SERIAL_UART") diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index d8e2c82cf6f..6a7dc63dbd5 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -93,8 +93,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await sensor.register_sensor(var, config) - sens = await cg.get_variable(config[CONF_SENSOR]) - cg.add(var.set_sensor(sens)) + await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) cg.add(var.set_time(config[CONF_TIME_UNIT])) cg.add(var.set_method(config[CONF_INTEGRATION_METHOD])) cg.add(var.set_restore(config[CONF_RESTORE])) diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index 8dafb5da070..dc50f8cdc0d 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -93,8 +93,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) for source_conf in config.get(CONF_SOURCE_ID, ()): - source = await cg.get_variable(source_conf) - cg.add(var.add_provider(source)) + await cg.add_variable(var.add_provider, source_conf) if CONF_MIN_LENGTH in config: cg.add(var.set_min_length(config[CONF_MIN_LENGTH])) if CONF_MAX_LENGTH in config: diff --git a/esphome/components/lcd_menu/__init__.py b/esphome/components/lcd_menu/__init__.py index 483a36491db..b314a0ae02d 100644 --- a/esphome/components/lcd_menu/__init__.py +++ b/esphome/components/lcd_menu/__init__.py @@ -61,8 +61,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - disp = await cg.get_variable(config[CONF_DISPLAY_ID]) - cg.add(var.set_display(disp)) + await cg.add_variable(var.set_display, config[CONF_DISPLAY_ID]) cg.add(var.set_dimensions(config[CONF_DIMENSIONS][0], config[CONF_DIMENSIONS][1])) await display_menu_to_code(var, config) cg.add(var.set_mark_selected(config[CONF_MARK_SELECTED])) diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 0d2b3a51804..607ea4448fd 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -126,8 +126,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_LN882H_BLE_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_LN882H_BLE_ID]) # The tracker registers itself as a controller scan listener in setup(); # request the codegen-sized StaticVector slot for it. ln882h_ble.request_scan_listener_slot() From 85724892cf034be0068cad11a826a26bb942327d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Vo=C3=9F?= <46140304+mvoss96@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:08:31 +0200 Subject: [PATCH 110/179] [light] Add power estimation and current limiting for addressable LED strips (#15348) 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: J. Nick Koston --- esphome/components/light/__init__.py | 34 +++ .../components/light/addressable_light.cpp | 122 ++++++++ esphome/components/light/addressable_light.h | 53 ++++ .../components/light/esp_color_correction.cpp | 24 +- .../components/light/esp_color_correction.h | 36 ++- esphome/components/light/esp_color_view.h | 9 + esphome/components/light/esp_range_view.cpp | 7 +- esphome/components/light/light_state.cpp | 8 +- esphome/components/light/light_state.h | 3 + esphome/components/partition/light.py | 4 + .../components/partition/light_partition.h | 6 + esphome/core/defines.h | 1 + tests/component_tests/partition/__init__.py | 0 tests/component_tests/partition/test_light.py | 29 ++ tests/components/light/__init__.py | 2 + tests/components/light/test_power_limit.cpp | 276 ++++++++++++++++++ tests/components/spi_led_strip/common.yaml | 13 + 17 files changed, 615 insertions(+), 12 deletions(-) create mode 100644 tests/component_tests/partition/__init__.py create mode 100644 tests/component_tests/partition/test_light.py create mode 100644 tests/components/light/test_power_limit.cpp diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index c490300b58c..056ec7c44f9 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -90,6 +90,25 @@ DOMAIN = "light" CONF_GAMMA_TABLE_ID = "gamma_table_id" CONF_RESUME_EFFECT = "resume_effect" +CONF_POWER_ESTIMATION = "power_estimation" +CONF_MA_PER_LED_RED = "ma_per_led_red" +CONF_MA_PER_LED_GREEN = "ma_per_led_green" +CONF_MA_PER_LED_BLUE = "ma_per_led_blue" +CONF_MA_PER_LED_WHITE = "ma_per_led_white" +CONF_IDLE_MA_PER_LED = "idle_ma_per_led" +CONF_MAX_CURRENT_MA = "max_current_ma" + +POWER_ESTIMATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_MA_PER_LED_RED, default=20.0): cv.positive_float, + cv.Optional(CONF_MA_PER_LED_GREEN, default=20.0): cv.positive_float, + cv.Optional(CONF_MA_PER_LED_BLUE, default=20.0): cv.positive_float, + cv.Optional(CONF_MA_PER_LED_WHITE, default=20.0): cv.positive_float, + cv.Optional(CONF_IDLE_MA_PER_LED, default=1.0): cv.positive_float, + cv.Optional(CONF_MAX_CURRENT_MA): cv.positive_not_null_float, + } +) + @dataclass class EffectRef: @@ -446,6 +465,7 @@ ADDRESSABLE_LIGHT_SCHEMA = RGB_LIGHT_SCHEMA.extend( [cv.percentage], cv.Length(min=3, max=4) ), cv.Optional(CONF_POWER_SUPPLY): cv.use_id(power_supply.PowerSupply), + cv.Optional(CONF_POWER_ESTIMATION): POWER_ESTIMATION_SCHEMA, } ) @@ -617,6 +637,20 @@ async def setup_light_core_(light_var, config, output_var): var_ = await cg.get_variable(power_supply_id) cg.add(output_var.set_power_supply(var_)) + if (power_estimation := config.get(CONF_POWER_ESTIMATION)) is not None: + cg.add_define("USE_LIGHT_POWER_ESTIMATION") + cg.add( + output_var.set_power_estimation( + power_estimation[CONF_MA_PER_LED_RED], + power_estimation[CONF_MA_PER_LED_GREEN], + power_estimation[CONF_MA_PER_LED_BLUE], + power_estimation[CONF_MA_PER_LED_WHITE], + power_estimation[CONF_IDLE_MA_PER_LED], + ) + ) + if (max_current_ma := power_estimation.get(CONF_MAX_CURRENT_MA)) is not None: + cg.add(output_var.set_max_current_ma(max_current_ma)) + if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, light_var) await mqtt.register_mqtt_component(mqtt_, config) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index a6440700fa9..5292db13ac0 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -1,12 +1,134 @@ #include "addressable_light.h" #include "esphome/core/log.h" +#include +#include + namespace esphome::light { ESPHOME_LOG_TAG(TAG, "light.addressable"); +#ifdef USE_LIGHT_POWER_ESTIMATION +float AddressableLightState::get_estimated_current_ma() const { + return static_cast(this->output_)->get_estimated_current_ma(); +} + +void AddressableLightState::loop() { + this->apply_effect_and_transformer_(); + if (this->next_write_) + static_cast(this->output_)->limit_power_(); + this->write_if_pending_(); +} + +float AddressableLight::get_estimated_current_ma() const { + return this->power_estimation_ == nullptr ? NAN : this->frame_ma_(); +} + +// Idle draw plus the weighted levels in the buffer +float AddressableLight::frame_ma_(float *slack_ma) const { + uint32_t sums[4]{}; + uint32_t lit[4]{}; + const int32_t count = this->size(); + for (int32_t i = 0; i < count; i++) { + const auto view = this->get_view_internal(i); + const uint8_t levels[4] = {view.get_red_raw(), view.get_green_raw(), view.get_blue_raw(), view.get_white_raw()}; + for (size_t channel = 0; channel < 4; channel++) { + sums[channel] += levels[channel]; + lit[channel] += levels[channel] != 0; + } + } + if (slack_ma != nullptr) { + const auto &power = *this->power_estimation_; + *slack_ma = 0.0f; + for (size_t channel = 0; channel < 4; channel++) + *slack_ma += lit[channel] * power.ma_per_level[channel]; + } + return this->draw_ma_(sums); +} + +float AddressableLight::draw_ma_(const uint32_t sums[4]) const { + const auto &power = *this->power_estimation_; + float ma = power.idle_ma; + for (size_t channel = 0; channel < 4; channel++) + ma += sums[channel] * power.ma_per_level[channel]; + return ma; +} + +void AddressableLight::setup_power_budget_() { + auto &power = *this->power_estimation_; + power.idle_ma = this->size() * power.idle_ma_per_led; + power.last_frame_ma = NAN; + if (std::isnan(power.max_current_ma)) + return; + power.budget_ma = power.max_current_ma - power.idle_ma; + if (power.budget_ma <= 0.0f) { + ESP_LOGW(TAG, "max_current_ma %.0f leaves nothing for the pixels after the idle draw of %.0f mA", + power.max_current_ma, power.idle_ma); + } +} + +// Rounded up on the way down so a lit channel stays lit, down on the way up so a level does not creep +static uint8_t rescale_level(uint8_t level, uint8_t scale, uint8_t previous) { + if (level == 0) + return 0; + const uint32_t scaled = static_cast(level) * scale; + const uint32_t moved = scale < previous ? (scaled + previous - 1) / previous : scaled / previous; + // A level written past the scale in force (set_raw, a partition over another strip) must not wrap + return std::clamp(moved, 1, 255); +} + +// The frame is moved to the new scale before it goes out, so every write path is limited +void AddressableLight::limit_power_() { + auto *power = this->power_estimation_; + if (power == nullptr || std::isnan(power->budget_ma)) + return; + // A frame the last pass already left in the buffer, as a driver retry sends, is not limited again: its + // levels were rounded up on the way down and read as a hair over budget, which would walk the scale down + float slack_ma; + const float frame_ma = this->frame_ma_(&slack_ma); + if (frame_ma == power->last_frame_ma) + return; + power->last_frame_ma = frame_ma; + // The stored levels already carry the limit in force; the scale comes from the draw the frame asked for + const uint8_t previous = this->correction_.get_power_brightness(); + const float intended_ma = (frame_ma - power->idle_ma) * 255.0f / previous; + // Never 0, which would black the strip out and swing the next estimate back to full + const uint8_t scale = intended_ma <= power->budget_ma + ? 255 + : static_cast(std::max(power->budget_ma / intended_ma * 255.0f, 1.0f)); + if (scale == previous) + return; + // Below full scale the levels were rounded up on the way down, so a frame can read as over budget by up + // to one level per lit channel. A drop the frame does not need once that is taken off would only move the + // scale under levels that stay where they are, and inflate what they read back as + if (previous < 255 && scale < previous && frame_ma - power->idle_ma - slack_ma <= power->budget_ma) + return; + this->correction_.set_power_brightness(scale); + // One divide per level instead of one per channel; ESP8266 has no hardware divide + uint8_t map[256]; + for (size_t level = 0; level < 256; level++) + map[level] = rescale_level(level, scale, previous); + uint32_t sums[4]{}; + for (auto view : *this) { + const uint8_t r = map[view.get_red_raw()], g = map[view.get_green_raw()], b = map[view.get_blue_raw()], + w = map[view.get_white_raw()]; + view.set_raw(r, g, b, w); + sums[0] += r; + sums[1] += g; + sums[2] += b; + sums[3] += w; + } + power->last_frame_ma = this->draw_ma_(sums); +} + +#endif + void AddressableLight::call_setup() { this->setup(); +#ifdef USE_LIGHT_POWER_ESTIMATION + if (this->power_estimation_ != nullptr) + this->setup_power_budget_(); +#endif #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE this->set_interval(5000, [this]() { diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 57e9caf289c..b6446f2b9d3 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -10,6 +10,8 @@ #include "light_state.h" #include "light_transformer.h" +#include + #ifdef USE_POWER_SUPPLY #include "esphome/components/power_supply/power_supply.h" #endif @@ -23,8 +25,28 @@ Color color_from_light_color_values(LightColorValues val); /// non-addressable lights. class AddressableLightState final : public LightState { using LightState::LightState; + + public: +#ifdef USE_LIGHT_POWER_ESTIMATION + float get_estimated_current_ma() const; + void loop() override; +#endif }; +#ifdef USE_LIGHT_POWER_ESTIMATION +// Allocated only for a strip with power_estimation configured +struct PowerEstimation { + float ma_per_level[4]; + float idle_ma_per_led; + float max_current_ma{NAN}; + // Set by call_setup() once size() is known; the budget is what is left for the pixels, NAN for no limit + float idle_ma{0.0f}; + float budget_ma{NAN}; + // The draw the last pass left in the buffer; a pass that sees it again has nothing new to limit + float last_frame_ma{NAN}; +}; +#endif + class AddressableLight : public LightOutput, public Component { public: virtual int32_t size() const = 0; @@ -65,6 +87,23 @@ class AddressableLight : public LightOutput, public Component { this->correction_.set_max_brightness( Color(to_uint8_scale(red), to_uint8_scale(green), to_uint8_scale(blue), to_uint8_scale(white))); } +#ifdef USE_LIGHT_POWER_ESTIMATION + // Per-channel draw at full level and idle draw per LED + void set_power_estimation(float ma_red, float ma_green, float ma_blue, float ma_white, float idle_ma_per_led) { + if (this->power_estimation_ == nullptr) + this->power_estimation_ = new PowerEstimation(); // NOLINT(cppcoreguidelines-owning-memory) + auto *power = this->power_estimation_; + power->ma_per_level[0] = ma_red / 255.0f; + power->ma_per_level[1] = ma_green / 255.0f; + power->ma_per_level[2] = ma_blue / 255.0f; + power->ma_per_level[3] = ma_white / 255.0f; + power->idle_ma_per_led = idle_ma_per_led; + } + // Called after set_power_estimation() + void set_max_current_ma(float max_current_ma) { this->power_estimation_->max_current_ma = max_current_ma; } + // The draw of the frame in the buffer, NAN without power_estimation + float get_estimated_current_ma() const; +#endif void setup_state(LightState *state) override { #ifdef USE_LIGHT_GAMMA_LUT this->correction_.set_gamma_table(state->get_gamma_table()); @@ -96,8 +135,22 @@ class AddressableLight : public LightOutput, public Component { } virtual ESPColorView get_view_internal(int32_t index) const = 0; +#ifdef USE_LIGHT_POWER_ESTIMATION + friend class AddressableLightState; + // Run by the light state before each frame goes out + void limit_power_(); + void setup_power_budget_(); + // slack_ma, when asked for, is one level's draw for every lit channel: the most the round-up on the way + // down can have added to the frame + float frame_ma_(float *slack_ma = nullptr) const; + float draw_ma_(const uint32_t sums[4]) const; +#endif + ESPColorCorrection correction_{}; LightState *state_parent_{nullptr}; +#ifdef USE_LIGHT_POWER_ESTIMATION + PowerEstimation *power_estimation_{nullptr}; +#endif #ifdef USE_POWER_SUPPLY power_supply::PowerSupplyRequester power_; #endif diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index 12eb6a30089..567d1c3c0b3 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -32,12 +32,34 @@ Color ESPColorCorrection::color_uncorrect(Color color) const { this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white)); } +// Under the limit, gamma and the limit are undone together: the largest level that encodes to at most the +// stored one, so a read, change and write of a pixel re-encodes to what was stored +uint8_t ESPColorCorrection::uncorrect_level_(uint8_t value) const { +#ifdef USE_LIGHT_POWER_ESTIMATION + if (this->power_brightness_ == 255) + return this->gamma_uncorrect_(value); + uint8_t lo = 0; + uint8_t hi = 255; + while (lo < hi) { + const uint8_t mid = (lo + hi + 1) / 2; + if (this->limit_(this->gamma_correct_(mid)) <= value) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo; +#else + return this->gamma_uncorrect_(value); +#endif +} + uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const { if (max_brightness == 0 || this->local_brightness_ == 0) return 0; // Use 32-bit intermediates: when max_brightness and local_brightness_ are small but non-zero, // (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs. - uint32_t uncorrected = this->gamma_uncorrect_(value) * 255UL; + uint32_t uncorrected = this->uncorrect_level_(value) * 255UL; uint32_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_; return static_cast(std::min(res, uint32_t(255))); } diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 0295fb36b8c..46b8e9db5da 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -1,8 +1,11 @@ #pragma once #include "esphome/core/color.h" +#include "esphome/core/defines.h" #include "esphome/core/hal.h" +#include + namespace esphome::light { /// A gamma curve as codegen emits it into PROGMEM: the lookup table and the gamma it was built from @@ -30,6 +33,11 @@ class ESPColorCorrection { public: void set_max_brightness(const Color &max_brightness) { this->max_brightness_ = max_brightness; } void set_local_brightness(uint8_t local_brightness) { this->local_brightness_ = local_brightness; } +#ifdef USE_LIGHT_POWER_ESTIMATION + // The current limit's factor, applied after gamma + void set_power_brightness(uint8_t power_brightness) { this->power_brightness_ = power_brightness; } + uint8_t get_power_brightness() const { return this->power_brightness_; } +#endif void set_gamma_table(const uint16_t *table) { this->gamma_table_ = table; } inline Color color_correct(Color color) const ESPHOME_ALWAYS_INLINE { // corrected = (uncorrected * max_brightness * local_brightness) ^ gamma @@ -37,20 +45,16 @@ class ESPColorCorrection { this->color_correct_blue(color.blue), this->color_correct_white(color.white)); } inline uint8_t color_correct_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(red, this->max_brightness_.red, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(red, this->max_brightness_.red); } inline uint8_t color_correct_green(uint8_t green) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(green, this->max_brightness_.green, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(green, this->max_brightness_.green); } inline uint8_t color_correct_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(blue, this->max_brightness_.blue, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(blue, this->max_brightness_.blue); } inline uint8_t color_correct_white(uint8_t white) const ESPHOME_ALWAYS_INLINE { - uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_); - return this->gamma_correct_(res); + return this->color_correct_channel_(white, this->max_brightness_.white); } Color color_uncorrect(Color color) const; inline uint8_t color_uncorrect_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { @@ -67,8 +71,21 @@ class ESPColorCorrection { } protected: + inline uint8_t color_correct_channel_(uint8_t value, uint8_t max_brightness) const ESPHOME_ALWAYS_INLINE { + return this->limit_(this->gamma_correct_(esp_scale8_twice(value, max_brightness, this->local_brightness_))); + } + // The limit on a gamma-corrected level; rounded up, so a lit channel stays lit + inline uint8_t limit_(uint8_t res) const ESPHOME_ALWAYS_INLINE { +#ifdef USE_LIGHT_POWER_ESTIMATION + if (res != 0) + res = std::max(esp_scale8(res, this->power_brightness_), 1); +#endif + return res; + } /// Forward gamma: read uint16 PROGMEM table, convert to uint8 uint8_t gamma_correct_(uint8_t value) const; + /// Reverse gamma and limit for one stored level + uint8_t uncorrect_level_(uint8_t value) const; /// Reverse gamma: binary search the forward PROGMEM table uint8_t gamma_uncorrect_(uint8_t value) const; /// Shared body of color_uncorrect_{red,green,blue,white}. Kept out-of-line @@ -78,6 +95,9 @@ class ESPColorCorrection { const uint16_t *gamma_table_{nullptr}; Color max_brightness_{255, 255, 255, 255}; uint8_t local_brightness_{255}; +#ifdef USE_LIGHT_POWER_ESTIMATION + uint8_t power_brightness_{255}; +#endif }; } // namespace esphome::light diff --git a/esphome/components/light/esp_color_view.h b/esphome/components/light/esp_color_view.h index 018e30a9a81..f764f7667da 100644 --- a/esphome/components/light/esp_color_view.h +++ b/esphome/components/light/esp_color_view.h @@ -89,9 +89,18 @@ class ESPColorView : public ESPColorSettable { return 0; return *this->effect_data_; } + const ESPColorCorrection *get_color_correction() const { return this->color_correction_; } void raw_set_color_correction(const ESPColorCorrection *color_correction) { this->color_correction_ = color_correction; } + /// Store hardware levels as they are, without the correction + void set_raw(uint8_t red, uint8_t green, uint8_t blue, uint8_t white) { + *this->red_ = red; + *this->green_ = green; + *this->blue_ = blue; + if (this->white_ != nullptr) + *this->white_ = white; + } protected: uint8_t *const red_; diff --git a/esphome/components/light/esp_range_view.cpp b/esphome/components/light/esp_range_view.cpp index 5d372983d90..fe7c4a086eb 100644 --- a/esphome/components/light/esp_range_view.cpp +++ b/esphome/components/light/esp_range_view.cpp @@ -72,15 +72,18 @@ ESPRangeView &ESPRangeView::operator=(const ESPRangeView &rhs) { // NOLINT if (rhs.begin_ == this->begin_) return *this; + // The source is read before the destination view is fetched: a partition's views share one correction if (rhs.begin_ > this->begin_) { // Copy from left for (int32_t i = 0; i < this->size(); i++) { - (*this)[i].set(rhs[i].get()); + const Color color = rhs[i].get(); + (*this)[i].set(color); } } else { // Copy from right for (int32_t i = this->size() - 1; i >= 0; i--) { - (*this)[i].set(rhs[i].get()); + const Color color = rhs[i].get(); + (*this)[i].set(color); } } diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index d8293338897..84db3653a36 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -121,6 +121,11 @@ void LightState::dump_config() { } } void LightState::loop() { + this->apply_effect_and_transformer_(); + this->write_if_pending_(); +} + +void LightState::apply_effect_and_transformer_() { // Apply effect (if any) auto *effect = this->get_active_effect_(); if (effect != nullptr) { @@ -171,8 +176,9 @@ void LightState::loop() { this->disable_loop_if_idle_(); } } +} - // Write state to the light +void LightState::write_if_pending_() { if (this->next_write_) { this->next_write_ = false; this->output_->write_state(this); diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 946b4efae58..86a132c1434 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -313,6 +313,9 @@ class LightState : public EntityBase, public Component { bool is_transformer_active() const { return this->is_transformer_active_; } protected: + // The two halves of loop() + void apply_effect_and_transformer_(); + void write_if_pending_(); friend LightOutput; friend LightCall; friend class AddressableLight; diff --git a/esphome/components/partition/light.py b/esphome/components/partition/light.py index b2341f95b7f..eab2e1ac660 100644 --- a/esphome/components/partition/light.py +++ b/esphome/components/partition/light.py @@ -84,6 +84,10 @@ CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend( ), cv.Length(min=1), ), + cv.Optional(light.CONF_POWER_ESTIMATION): cv.invalid( + "power_estimation belongs on the strip a partition draws from; " + "the strip's limit covers every partition on it" + ), } ) diff --git a/esphome/components/partition/light_partition.h b/esphome/components/partition/light_partition.h index adadde068cb..c843474ab4c 100644 --- a/esphome/components/partition/light_partition.h +++ b/esphome/components/partition/light_partition.h @@ -85,6 +85,12 @@ class PartitionLightOutput final : public light::AddressableLight { } auto view = (*seg.get_src())[src_off]; +#ifdef USE_LIGHT_POWER_ESTIMATION + // Writes go through the strip's power factor; a partition has no limit of its own. A view kept alive + // across two strips with different limits carries the factor of the strip fetched last. + const_cast(this->correction_) + .set_power_brightness(view.get_color_correction()->get_power_brightness()); +#endif view.raw_set_color_correction(&this->correction_); return view; } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 852ea3439a9..ed8f7b984a0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -114,6 +114,7 @@ #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT +#define USE_LIGHT_POWER_ESTIMATION #define USE_LIGHT_RESUME_EFFECT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL #define USE_LINE_READER diff --git a/tests/component_tests/partition/__init__.py b/tests/component_tests/partition/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/partition/test_light.py b/tests/component_tests/partition/test_light.py new file mode 100644 index 00000000000..eee7d8a48fe --- /dev/null +++ b/tests/component_tests/partition/test_light.py @@ -0,0 +1,29 @@ +"""Partition light schema.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.light import CONF_POWER_ESTIMATION +from esphome.components.partition.light import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_partition_rejects_its_own_power_estimation( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="belongs on the strip") as exc: + CONFIG_SCHEMA( + { + "name": "Part", + "segments": [{"id": "strip", "from": 0, "to": 3}], + CONF_POWER_ESTIMATION: { + "ma_per_led_red": 16.0, + "ma_per_led_green": 11.0, + "ma_per_led_blue": 15.0, + "idle_ma_per_led": 1.0, + }, + } + ) + assert exc.value.path == [CONF_POWER_ESTIMATION] diff --git a/tests/components/light/__init__.py b/tests/components/light/__init__.py index 2d5eb9223dc..f15cad320ba 100644 --- a/tests/components/light/__init__.py +++ b/tests/components/light/__init__.py @@ -10,6 +10,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_WEBSERVER_PORT", 80) # The gamma table and its stored gamma are behind this define cg.add_define("USE_LIGHT_GAMMA_LUT") + # The current estimate and limit of addressable lights + cg.add_define("USE_LIGHT_POWER_ESTIMATION") manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + ["json"] diff --git a/tests/components/light/test_power_limit.cpp b/tests/components/light/test_power_limit.cpp new file mode 100644 index 00000000000..62183e5da33 --- /dev/null +++ b/tests/components/light/test_power_limit.cpp @@ -0,0 +1,276 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/esp_color_correction.h" + +namespace esphome::light::testing { + +namespace { + +std::array build_gamma_table(double gamma) { + std::array table{}; + for (int i = 1; i < 256; i++) { + double raw = std::round(std::pow(i / 255.0, gamma) * 65535.0); + table[i] = static_cast(std::max(1.0, std::min(65535.0, raw))); + } + return table; +} + +// A strip of RGBW pixels in memory, with the protected hooks the tests drive +class Strip : public AddressableLight { + public: + explicit Strip(int32_t count) : buffer_(count * 4) {} + ~Strip() { delete this->power_estimation_; } // NOLINT(cppcoreguidelines-owning-memory) + int32_t size() const override { return this->buffer_.size() / 4; } + void clear_effect_data() override {} + LightTraits get_traits() override { return {}; } + void write_state(LightState *state) override { this->sent_red = (*this)[0].get_red_raw(); } + uint8_t sent_red{0}; + // Every channel draws ma_per_channel at full level; setup turns the settings into the idle draw and budget + void configure_power(float ma_per_channel, float idle_ma_per_led, float max_current_ma) { + this->set_power_estimation(ma_per_channel, ma_per_channel, ma_per_channel, ma_per_channel, idle_ma_per_led); + this->set_max_current_ma(max_current_ma); + this->call_setup(); + } + // What the light state does before a frame goes out + void limit() { this->limit_power_(); } + uint8_t power_brightness() const { return this->correction_.get_power_brightness(); } + + protected: + ESPColorView get_view_internal(int32_t index) const override { + auto *pixel = const_cast(&this->buffer_[index * 4]); + return {pixel, pixel + 1, pixel + 2, pixel + 3, nullptr, &this->correction_}; + } + + std::vector buffer_; +}; + +} // namespace + +TEST(PowerLimit, CorrectionRoundTripsUnderTheLimit) { + const auto table = build_gamma_table(2.2); + for (uint8_t scale : {128, 200}) { + for (bool with_gamma : {false, true}) { + ESPColorCorrection correction; + if (with_gamma) + correction.set_gamma_table(table.data()); + correction.set_power_brightness(scale); + for (int value = 0; value < 256; value++) { + const uint8_t stored = correction.color_correct_red(value); + const int back = correction.color_uncorrect_red(stored); + // A read, change-nothing and write re-encodes to the stored level, so the dimming never compounds + EXPECT_EQ(correction.color_correct_red(back), stored) + << "scale=" << int(scale) << " gamma=" << with_gamma << " value=" << value; + // Below 96 the gamma curve leaves the limited hardware range too few levels to tell apart + if (with_gamma ? value >= 96 : value >= 2) { + EXPECT_LE(std::abs(back - value), with_gamma ? 4 : 2) + << "scale=" << int(scale) << " gamma=" << with_gamma << " value=" << value; + } + } + } + } +} + +TEST(PowerLimit, StaysAtFullScaleUnderBudget) { + Strip strip(10); + // 10 pixels at 20 mA per channel draw 10 x 60 + 10 mA idle; the budget is twice that + strip.configure_power(20, 1, 1300); + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + EXPECT_EQ(strip.power_brightness(), 255); + EXPECT_NEAR(strip.get_estimated_current_ma(), 610.0f, 0.5f); +} + +TEST(PowerLimit, SettlesOnTheBudgetAndKeepsLogicalLevels) { + Strip strip(10); + strip.configure_power(20, 1, 310); // 300 mA for the pixels of a 600 mA frame + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t scale = strip.power_brightness(); + EXPECT_NEAR(scale, 128, 2); + // The estimate is of the frame that goes out, not of what was asked for + EXPECT_NEAR(strip.get_estimated_current_ma(), 310.0f, 5.0f); + // The sent frame was rescaled, so a second pass sees the same draw and keeps the scale + strip.limit(); + EXPECT_EQ(strip.power_brightness(), scale); + EXPECT_NEAR(strip.get_estimated_current_ma(), 310.0f, 5.0f); + // Read-back still gives the levels that were written + EXPECT_NEAR(strip[0].get_red(), 255, 2); + EXPECT_NEAR(strip[0].get_green(), 255, 2); +} + +TEST(PowerLimit, APixelWrittenUnderTheLimitLandsLimited) { + Strip strip(4); + strip.configure_power(20, 1, 124); // 120 mA for pixels that ask for 240 mA + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t scale = strip.power_brightness(); + ASSERT_LT(scale, 255); + // A single pixel rewritten by a lambda lands at the limited level straight away + strip[1] = Color(128, 0, 0, 0); + EXPECT_NEAR(strip[1].get_red_raw(), (128 * scale) / 255.0f, 2); + EXPECT_NEAR(strip[1].get_red(), 128, 2); +} + +TEST(PowerLimit, LevelsDoNotCreepOverALimitCycle) { + Strip strip(4); + // Levels written once and never touched again, as a lambda would leave them + for (int i = 0; i < 4; i++) + strip[i].set_raw(1 + i * 40, 2, 100, 0); + // 1020 mA at full level, so the budget of 300 mA limits the strip + strip.configure_power(255, 0, 300); + strip.limit(); + const uint8_t limited = strip.power_brightness(); + ASSERT_LT(limited, 255); + // Lit channels stay lit while limited + for (int i = 0; i < 4; i++) + EXPECT_GE(strip[i].get_green_raw(), 1) << i; + // A budget the strip cannot reach, and the next frame restores the scale + strip.configure_power(255, 0, 100000); + strip.limit(); + ASSERT_EQ(strip.power_brightness(), 255); + // Each level comes back within one quantisation step of where it started, never below it + const int step = (255 + limited - 1) / limited; + for (int i = 0; i < 4; i++) { + const int red = 1 + i * 40; + EXPECT_GE(strip[i].get_red_raw(), red) << i; + EXPECT_LE(strip[i].get_red_raw(), red + step) << i; + EXPECT_GE(strip[i].get_blue_raw(), 100) << i; + EXPECT_LE(strip[i].get_blue_raw(), 100 + step) << i; + } +} + +TEST(PowerLimit, ADimPixelSettlesWhileDemandAlternates) { + Strip strip(2); + strip.configure_power(20, 0, 10.04f); // two full red pixels would draw 40 mA + strip[0].set_raw(1, 0, 0, 0); + uint8_t dim = 0; + for (int cycle = 0; cycle < 4; cycle++) { + strip[1].set_raw(255, 0, 0, 0); + strip.limit(); + ASSERT_LT(strip.power_brightness(), 255) << cycle; + strip[1].set_raw(0, 0, 0, 0); + strip.limit(); + ASSERT_EQ(strip.power_brightness(), 255) << cycle; + // The first cycle may move the level by one quantisation step; after that it stays put + if (cycle > 0) + EXPECT_EQ(strip[0].get_red_raw(), dim) << cycle; + dim = strip[0].get_red_raw(); + EXPECT_GE(dim, 1) << cycle; + EXPECT_LE(dim, 3) << cycle; + } +} + +TEST(PowerLimit, TheStateLimitsAFrameBeforeTheDriverSendsIt) { + Strip strip(2); + AddressableLightState state(&strip); + strip.setup_state(&state); + strip.configure_power(20, 0, 10); // two full red pixels would draw 40 mA + strip.all() = Color(255, 0, 0, 0); + strip.schedule_show(); + state.loop(); + EXPECT_LT(strip.sent_red, 255); + EXPECT_EQ(strip.sent_red, strip[0].get_red_raw()); + // Nothing pending: the next loop neither limits nor writes + strip[0].set_raw(255, 0, 0, 0); + state.loop(); + EXPECT_EQ(strip[0].get_red_raw(), 255); +} + +TEST(PowerLimit, ALevelPastTheScaleInForceSaturatesWhenTheScaleRises) { + Strip strip(2); + strip.configure_power(20, 0, 30); // two full red pixels would draw 40 mA + strip.all() = Color(255, 0, 0, 0); + strip.limit(); + ASSERT_LT(strip.power_brightness(), 255); + // A raw write past the scale, as a lambda or a partition over another strip can make; one such pixel + // alone fits the budget, so the scale goes back up + strip[0].set_raw(255, 0, 0, 0); + strip[1].set_raw(0, 0, 0, 0); + strip.limit(); + ASSERT_EQ(strip.power_brightness(), 255); + EXPECT_EQ(strip[0].get_red_raw(), 255); +} + +TEST(PowerLimit, RepeatedPassesOverAnUnchangedFrameHoldTheScale) { + Strip strip(1); + strip.configure_power(20, 0, 5); // a half-red pixel asks for 10 mA + strip[0] = Color(128, 0, 0, 0); + strip.limit(); + const uint8_t scale = strip.power_brightness(); + EXPECT_NEAR(scale, 127, 2); + // A driver retry re-runs the pass with nothing rewritten: the scale and the read-back stay put + for (int pass = 0; pass < 20; pass++) + strip.limit(); + EXPECT_EQ(strip.power_brightness(), scale); + EXPECT_NEAR(strip[0].get_red(), 128, 2); +} + +TEST(PowerLimit, AHeavyLimitStaysWithinOneStepOfTheBudget) { + Strip strip(1); + strip.configure_power(20, 0, 0.3f); // a full red pixel asks for 20 mA against a 0.3 mA budget + strip[0] = Color(255, 0, 0, 0); + for (int pass = 0; pass < 20; pass++) + strip.limit(); + const uint8_t scale = strip.power_brightness(); + EXPECT_GE(scale, 1); + EXPECT_LE(scale, 4); + // No dead band this low: the frame is within one level of the budget + EXPECT_LE(strip.get_estimated_current_ma(), 0.3f + 20.0f / 255.0f); +} + +TEST(PowerLimit, AChangedFrameTakesTheDropAtALowScale) { + Strip strip(10); + strip.configure_power(20, 1, 20); // white pixels draw 60 mA each against 10 mA left for the pixels + for (int i = 0; i < 5; i++) + strip[i] = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t five = strip.power_brightness(); + EXPECT_NEAR(five, 8, 1); + // Twice the pixels lit: well past one level per channel over the budget, so the scale drops + strip.all() = Color(255, 255, 255, 0); + strip.limit(); + const uint8_t ten = strip.power_brightness(); + EXPECT_LT(ten, five); + // A retry with nothing rewritten then holds it + strip.limit(); + EXPECT_EQ(strip.power_brightness(), ten); +} + +TEST(PowerLimit, AChangingPixelOverAStaticBackgroundDoesNotWalkTheScaleDown) { + Strip strip(100); + strip.configure_power(20, 0, 60); // 99 pixels at red 100 ask for 776 mA + for (int i = 1; i < 100; i++) + strip[i] = Color(100, 0, 0, 0); + strip[0] = Color(255, 0, 0, 0); + strip.limit(); + const uint8_t first = strip.power_brightness(); + EXPECT_NEAR(first, 19, 2); + // A dot blinks over the background: every frame differs, so every pass runs + for (int pass = 0; pass < 40; pass++) { + strip[0] = Color(pass % 2 ? 255 : 0, 0, 0, 0); + strip.limit(); + } + EXPECT_NEAR(strip.power_brightness(), first, 2); + // One raw level at this scale is about 13 logical levels + EXPECT_NEAR(strip[50].get_red(), 100, 16); +} + +TEST(PowerLimit, AFrameJustOverBudgetAtFullScaleIsLimited) { + Strip strip(10); + strip.configure_power(20, 0, 199); // ten full red pixels draw 200 mA + strip.all() = Color(255, 0, 0, 0); + strip.limit(); + // Nothing was rounded at full scale, so the overshoot is real and the scale drops + EXPECT_LT(strip.power_brightness(), 255); + EXPECT_LE(strip.get_estimated_current_ma(), 199.0f + 10 * 20.0f / 255.0f); +} + +} // namespace esphome::light::testing diff --git a/tests/components/spi_led_strip/common.yaml b/tests/components/spi_led_strip/common.yaml index 30e2ef22f78..1927f53fae4 100644 --- a/tests/components/spi_led_strip/common.yaml +++ b/tests/components/spi_led_strip/common.yaml @@ -5,3 +5,16 @@ light: id: rgb_led name: RGB LED data_rate: 8MHz + power_estimation: + ma_per_led_red: 16.0 + ma_per_led_green: 11.0 + ma_per_led_blue: 15.0 + idle_ma_per_led: 1.0 + max_current_ma: 100.0 + +# get_estimated_current_ma() is callable from a lambda +interval: + - interval: 60s + then: + - lambda: |- + ESP_LOGI("test", "Estimated current: %.1f mA", id(rgb_led).get_estimated_current_ma()); From a153fcc8eede4d218b3d3c801cf3a9a59a47fe21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 08:30:10 -1000 Subject: [PATCH 111/179] [bl0906][kamstrup_kmp][ufm01] Drop stale receive bytes with discard_available (#21071) --- esphome/components/bl0906/bl0906.cpp | 6 ++---- esphome/components/kamstrup_kmp/kamstrup_kmp.cpp | 9 +-------- esphome/components/kamstrup_kmp/kamstrup_kmp.h | 1 - esphome/components/ufm01/ufm01.cpp | 5 +---- 4 files changed, 4 insertions(+), 17 deletions(-) diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 681c7ad15eb..12ea9f40faa 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -20,8 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data) } void BL0906::loop() { - while (this->available()) - this->read(); + this->discard_available(); if (this->current_stage_ == STAGE_IDLE) { // Woken up between cycles to drain the action queue. Go back to sleep. @@ -92,8 +91,7 @@ void BL0906::advance_stage_() { } void BL0906::setup() { - while (this->available()) - this->read(); + this->discard_available(); this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE)); // Calibration (1: register address; 2: value before calibration; 3: value after calibration) this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1 diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 396f4514cfe..f69bafcaf16 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -77,7 +77,7 @@ void KamstrupKMPComponent::send_command_(uint16_t command) { msg[3] = command >> 8; msg[4] = command & 0xFF; - this->clear_uart_rx_buffer_(); + this->discard_available(); this->send_message_(msg, msg_len); this->read_command_(command); } @@ -122,13 +122,6 @@ void KamstrupKMPComponent::send_message_(const uint8_t *msg, int msg_len) { this->write_array(tx_msg, tx_msg_len); } -void KamstrupKMPComponent::clear_uart_rx_buffer_() { - uint8_t tmp; - while (this->available()) { - this->read_byte(&tmp); - } -} - void KamstrupKMPComponent::read_command_(uint16_t command) { uint8_t buffer[20] = {0}; size_t buffer_len = 0; diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.h b/esphome/components/kamstrup_kmp/kamstrup_kmp.h index 57a89f77a1c..49a80d73157 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.h +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.h @@ -114,7 +114,6 @@ class KamstrupKMPComponent final : public PollingComponent, public uart::UARTDev // Sends a message to the meter. A prefix/suffix and CRC are added void send_message_(const uint8_t *msg, int msg_len); // Clears and data that might be in the UART Rx buffer - void clear_uart_rx_buffer_(); // Reads and validates the response to a send command void read_command_(uint16_t command); // Parses a received message diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 47ff2165f8c..15d645ce1a9 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -214,10 +214,7 @@ static bool read_flow_rate_out_of_range(const uint8_t data[FRAME_SIZE]) { } void UFM01Component::flush_rx_() { - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - } + this->discard_available(); this->read_index_ = 0; } From 71c0f46e35ffb099b1ab6389be8c91d379a09dad Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:33:17 +0200 Subject: [PATCH 112/179] [bme68x_bsec2] disable loop (#20994) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../components/bme68x_bsec2/bme68x_bsec2.cpp | 44 ++++++++++++++----- .../components/bme68x_bsec2/bme68x_bsec2.h | 1 + 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index f5a674d6b92..c563e120842 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -1,3 +1,4 @@ +#include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -16,6 +17,7 @@ namespace esphome::bme68x_bsec2 { ESPHOME_LOG_TAG(TAG, "bme68x_bsec2.sensor"); static constexpr uint32_t READ_TIMEOUT_ID = 0; +static constexpr uint32_t RUN_TIMEOUT_ID = 1; static constexpr const char *const IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; @@ -61,7 +63,9 @@ void BME68xBSEC2Component::setup() { return; } + this->disable_loop(); this->load_state_(); + this->run_(); } void BME68xBSEC2Component::dump_config() { @@ -115,9 +119,7 @@ void BME68xBSEC2Component::dump_config() { #endif } -void BME68xBSEC2Component::loop() { - this->run_(); - +void BME68xBSEC2Component::check_status_() { if (this->bsec_status_ < BSEC_OK || this->bme68x_status_ < BME68X_OK) { this->status_set_error(); } else { @@ -129,12 +131,17 @@ void BME68xBSEC2Component::loop() { } else { this->status_clear_warning(); } +} + +void BME68xBSEC2Component::loop() { // Process a single action from the queue. These are primarily sensor state publishes // that in totality take too long to send in a single call. if (this->queue_.size()) { auto action = std::move(this->queue_.front()); this->queue_.pop(); action(); + } else { + this->disable_loop(); } } @@ -219,15 +226,15 @@ void BME68xBSEC2Component::update_subscription_() { void BME68xBSEC2Component::run_() { this->op_mode_ = this->bsec_settings_.op_mode; int64_t curr_time_ns = this->get_time_ns_(); - if (curr_time_ns < this->bsec_settings_.next_call) { - return; - } ESP_LOGV(TAG, "Performing sensor run"); struct bme68x_conf bme68x_conf; this->bsec_status_ = bsec_sensor_control_m(&this->bsec_instance_, curr_time_ns, &this->bsec_settings_); if (this->bsec_status_ < BSEC_OK) { ESP_LOGW(TAG, "Fetching control settings failed (BSEC2 error code %d)", this->bsec_status_); + this->check_status_(); + // schedule next run after short delay + this->set_timeout(RUN_TIMEOUT_ID, 50, [this]() { this->run_(); }); return; } @@ -287,12 +294,25 @@ void BME68xBSEC2Component::run_() { uint32_t meas_dur = bme68x_get_meas_dur(this->op_mode_, &bme68x_conf, &this->bme68x_); ESP_LOGV(TAG, "Queueing read in %" PRIu32 "us", meas_dur); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout(READ_TIMEOUT_ID, meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, meas_dur / 1000, [this]() { + this->read_(this->trigger_time_ns_); + this->check_status_(); + }); } else { ESP_LOGV(TAG, "Measurement not required, queueing immediate read"); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout(READ_TIMEOUT_ID, 0, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, 0, [this]() { + this->read_(this->trigger_time_ns_); + this->check_status_(); + }); } + + // schedule next run + int64_t delay_ns = this->bsec_settings_.next_call - this->get_time_ns_(); + uint32_t delay_ms = delay_ns > 0 ? static_cast((delay_ns + 999999) / 1000000) : 1; + ESP_LOGV(TAG, "Next run in: %" PRIu32 "ms", delay_ms); + this->set_timeout(RUN_TIMEOUT_ID, delay_ms, [this]() { this->run_(); }); + this->check_status_(); } void BME68xBSEC2Component::read_(int64_t trigger_time_ns) { @@ -454,6 +474,7 @@ void BME68xBSEC2Component::publish_(const bsec_output_t *outputs, uint8_t num_ou // Queue up an opportunity to save state this->queue_push_([this, max_accuracy]() { this->save_state_(max_accuracy); }); } + this->enable_loop(); } int64_t BME68xBSEC2Component::get_time_ns_() { @@ -496,13 +517,15 @@ void BME68xBSEC2Component::load_state_() { bsec_set_state_m(&this->bsec_instance_, state, BSEC_MAX_STATE_BLOB_SIZE, work_buffer, sizeof(work_buffer)); if (this->bsec_status_ != BSEC_OK) { ESP_LOGW(TAG, "Failed to load state (BSEC2 error code %d)", this->bsec_status_); + // no need to set error here } ESP_LOGI(TAG, "Loaded state"); } } void BME68xBSEC2Component::save_state_(uint8_t accuracy) { - if (accuracy < 3 || (millis() - this->last_state_save_ms_ < this->state_save_interval_ms_)) { + if (accuracy < 3 || + (App.get_loop_component_start_time() - this->last_state_save_ms_ < this->state_save_interval_ms_)) { return; } @@ -516,6 +539,7 @@ void BME68xBSEC2Component::save_state_(uint8_t accuracy) { BSEC_MAX_STATE_BLOB_SIZE, &num_serialized_state); if (this->bsec_status_ != BSEC_OK) { ESP_LOGW(TAG, "Failed fetch state for save (BSEC2 error code %d)", this->bsec_status_); + this->check_status_(); return; } @@ -523,7 +547,7 @@ void BME68xBSEC2Component::save_state_(uint8_t accuracy) { ESP_LOGW(TAG, "Failed to save state"); return; } - this->last_state_save_ms_ = millis(); + this->last_state_save_ms_ = App.get_loop_component_start_time(); ESP_LOGI(TAG, "Saved state"); } diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.h b/esphome/components/bme68x_bsec2/bme68x_bsec2.h index 9317229a1fb..9e1e46c8043 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.h +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.h @@ -91,6 +91,7 @@ class BME68xBSEC2Component : public Component { void read_(int64_t trigger_time_ns); void publish_(const bsec_output_t *outputs, uint8_t num_outputs); int64_t get_time_ns_(); + void check_status_(); #ifdef USE_SENSOR void publish_sensor_(sensor::Sensor *sensor, float value, bool change_only = false); From a3bb17d2c688092e3b834cd7d15956fe826ae619 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 09:09:58 -1000 Subject: [PATCH 113/179] [components] Create BLE and modbus devices with the new helpers (#21026) --- esphome/components/alpha3/sensor.py | 5 +---- esphome/components/aranet/sensor.py | 7 +++---- esphome/components/atc_mithermometer/sensor.py | 5 +---- esphome/components/b_parasite/sensor.py | 5 +---- esphome/components/ecocomfort2/__init__.py | 6 ++---- esphome/components/growatt_solar/sensor.py | 5 +---- esphome/components/havells_solar/sensor.py | 5 +---- esphome/components/inkbird_ibsth1_mini/sensor.py | 5 +---- esphome/components/kuntze/sensor.py | 5 +---- esphome/components/mopeka_pro_check/sensor.py | 5 +---- esphome/components/mopeka_std_check/sensor.py | 5 +---- esphome/components/pvvx_mithermometer/sensor.py | 5 +---- esphome/components/pzem6l24/sensor.py | 4 +--- esphome/components/pzemac/sensor.py | 4 +--- esphome/components/pzemdc/sensor.py | 4 +--- esphome/components/renogy_inverter_ble/__init__.py | 5 +---- esphome/components/ruuvitag/sensor.py | 5 +---- esphome/components/sdm_meter/sensor.py | 5 +---- esphome/components/selec_meter/sensor.py | 5 +---- esphome/components/thermopro_ble/sensor.py | 5 +---- esphome/components/xiaomi_body_scale/sensor.py | 7 ++----- esphome/components/xiaomi_cgd1/sensor.py | 5 +---- esphome/components/xiaomi_cgdk2/sensor.py | 5 +---- esphome/components/xiaomi_cgg1/sensor.py | 5 +---- esphome/components/xiaomi_gcls002/sensor.py | 5 +---- esphome/components/xiaomi_hhccjcy01/sensor.py | 5 +---- esphome/components/xiaomi_hhccjcy10/sensor.py | 5 +---- esphome/components/xiaomi_hhccpot002/sensor.py | 5 +---- esphome/components/xiaomi_jqjcy01ym/sensor.py | 5 +---- esphome/components/xiaomi_lywsd02/sensor.py | 5 +---- esphome/components/xiaomi_lywsd02mmc/sensor.py | 5 +---- esphome/components/xiaomi_lywsd03mmc/sensor.py | 5 +---- esphome/components/xiaomi_lywsdcgq/sensor.py | 5 +---- esphome/components/xiaomi_mhoc303/sensor.py | 5 +---- esphome/components/xiaomi_mhoc401/sensor.py | 5 +---- esphome/components/xiaomi_miscale/sensor.py | 5 +---- esphome/components/xiaomi_rtcgq02lm/__init__.py | 6 ++---- esphome/components/xiaomi_xmwsdj04mmc/sensor.py | 5 +---- 38 files changed, 43 insertions(+), 150 deletions(-) diff --git a/esphome/components/alpha3/sensor.py b/esphome/components/alpha3/sensor.py index d8d59242fac..ada426ca095 100644 --- a/esphome/components/alpha3/sensor.py +++ b/esphome/components/alpha3/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_CURRENT, CONF_FLOW, CONF_HEAD, - CONF_ID, CONF_POWER, CONF_SPEED, CONF_VOLTAGE, @@ -70,9 +69,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_client.register_ble_node(var, config) + var = await ble_client.new_ble_node_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FLOW, var.set_flow_sensor) diff --git a/esphome/components/aranet/sensor.py b/esphome/components/aranet/sensor.py index f22d242f9cc..c31325df933 100644 --- a/esphome/components/aranet/sensor.py +++ b/esphome/components/aranet/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CO2, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_PRESSURE, CONF_RADON, @@ -131,9 +130,9 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component( + config, config[CONF_MAC_ADDRESS].as_hex + ) sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) diff --git a/esphome/components/atc_mithermometer/sensor.py b/esphome/components/atc_mithermometer/sensor.py index cde1a41f657..bacb6e46b87 100644 --- a/esphome/components/atc_mithermometer/sensor.py +++ b/esphome/components/atc_mithermometer/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_SIGNAL_STRENGTH, CONF_TEMPERATURE, @@ -79,9 +78,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/b_parasite/sensor.py b/esphome/components/b_parasite/sensor.py index cb8f569c0d6..74573fee262 100644 --- a/esphome/components/b_parasite/sensor.py +++ b/esphome/components/b_parasite/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -76,9 +75,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py index c2540db644e..9cb9bc0e970 100644 --- a/esphome/components/ecocomfort2/__init__.py +++ b/esphome/components/ecocomfort2/__init__.py @@ -6,7 +6,7 @@ from esphome.components.esp32_ble import ( CONF_MAX_KEY_SIZE, ) import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TIME_ID +from esphome.const import CONF_TIME_ID from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -50,9 +50,7 @@ async def register_ecocomfort2_child(var: MockObj, config: ConfigType) -> None: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_client.register_ble_node(var, config) + var = await ble_client.new_ble_node_component(config) await cg.add_variable(var.set_time, config.get(CONF_TIME_ID)) diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 0f49a30e6da..2f277fbbebf 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_ACTIVE_POWER, CONF_CURRENT, CONF_FREQUENCY, - CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C, @@ -168,9 +167,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION])) diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index d2db05381d8..20047f9ea81 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_ACTIVE_POWER, CONF_CURRENT, CONF_FREQUENCY, - CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C, @@ -225,9 +224,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_FREQUENCY, var.set_frequency_sensor) diff --git a/esphome/components/inkbird_ibsth1_mini/sensor.py b/esphome/components/inkbird_ibsth1_mini/sensor.py index f6524021c08..03543cba3b3 100644 --- a/esphome/components/inkbird_ibsth1_mini/sensor.py +++ b/esphome/components/inkbird_ibsth1_mini/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_EXTERNAL_TEMPERATURE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -65,9 +64,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index 40e7447d669..0278bb93e68 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -3,7 +3,6 @@ from esphome.components import modbus, sensor import esphome.config_validation as cv from esphome.const import ( CONF_EC, - CONF_ID, CONF_PH, CONF_TEMPERATURE, DEVICE_CLASS_EMPTY, @@ -97,9 +96,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_PH, var.set_ph_sensor) diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 761e5831c11..8f4147d0188 100644 --- a/esphome/components/mopeka_pro_check/sensor.py +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_DISTANCE, - CONF_ID, CONF_LEVEL, CONF_MAC_ADDRESS, CONF_TEMPERATURE, @@ -132,9 +131,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/mopeka_std_check/sensor.py b/esphome/components/mopeka_std_check/sensor.py index b3e55766d8a..644684afa67 100644 --- a/esphome/components/mopeka_std_check/sensor.py +++ b/esphome/components/mopeka_std_check/sensor.py @@ -6,7 +6,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_DISTANCE, - CONF_ID, CONF_LEVEL, CONF_MAC_ADDRESS, CONF_TEMPERATURE, @@ -103,9 +102,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/pvvx_mithermometer/sensor.py b/esphome/components/pvvx_mithermometer/sensor.py index e4a7781538d..a2add0b88a9 100644 --- a/esphome/components/pvvx_mithermometer/sensor.py +++ b/esphome/components/pvvx_mithermometer/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_SIGNAL_STRENGTH, CONF_TEMPERATURE, @@ -79,9 +78,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py index 7788caca442..1202f8a15c2 100644 --- a/esphome/components/pzem6l24/sensor.py +++ b/esphome/components/pzem6l24/sensor.py @@ -184,9 +184,7 @@ FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device("pzem6l24", role="cl async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors("voltage_a", var.set_voltage_a_sensor) diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 90e0abb14c1..9ed0284cff0 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -99,9 +99,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index b3f59720ad3..80008f6b592 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -81,9 +81,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_VOLTAGE, var.set_voltage_sensor) diff --git a/esphome/components/renogy_inverter_ble/__init__.py b/esphome/components/renogy_inverter_ble/__init__.py index 808f27a67a8..162ba871f9a 100644 --- a/esphome/components/renogy_inverter_ble/__init__.py +++ b/esphome/components/renogy_inverter_ble/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import ble_client import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType AUTO_LOAD = ["sensor"] @@ -35,6 +34,4 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_client.register_ble_node(var, config) + await ble_client.new_ble_node_component(config) diff --git a/esphome/components/ruuvitag/sensor.py b/esphome/components/ruuvitag/sensor.py index abbda5872b2..bad973c6315 100644 --- a/esphome/components/ruuvitag/sensor.py +++ b/esphome/components/ruuvitag/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_ACCELERATION_Z, CONF_BATTERY_VOLTAGE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_MEASUREMENT_SEQUENCE_NUMBER, CONF_MOVEMENT_COUNTER, @@ -123,9 +122,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 74c56acf120..806b7ee27b5 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( CONF_EXPORT_ACTIVE_ENERGY, CONF_EXPORT_REACTIVE_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, CONF_PHASE_A, @@ -156,9 +155,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_TOTAL_POWER, var.set_total_power_sensor) diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 5e6709ec97d..6cf79592eb0 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_EXPORT_ACTIVE_ENERGY, CONF_EXPORT_REACTIVE_ENERGY, CONF_FREQUENCY, - CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, CONF_MODEL, @@ -337,9 +336,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await modbus.register_modbus_client_device(var, config) + var = await modbus.new_modbus_client_component(config) cg.add(var.set_model(config[CONF_MODEL])) cg.add(var.set_word_swap(config[CONF_BYTE_ORDER])) if config[CONF_MODEL] == MODEL_EM4M: diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py index ed42312a26b..c050ffd94fe 100644 --- a/esphome/components/thermopro_ble/sensor.py +++ b/esphome/components/thermopro_ble/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_EXTERNAL_TEMPERATURE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_SIGNAL_STRENGTH, CONF_TEMPERATURE, @@ -76,9 +75,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_body_scale/sensor.py b/esphome/components/xiaomi_body_scale/sensor.py index cb0802d270a..bd66e2eb400 100644 --- a/esphome/components/xiaomi_body_scale/sensor.py +++ b/esphome/components/xiaomi_body_scale/sensor.py @@ -3,7 +3,6 @@ from esphome.components import binary_sensor, ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BINDKEY, - CONF_ID, CONF_MAC_ADDRESS, CONF_WEIGHT, DEVICE_CLASS_WEIGHT, @@ -72,11 +71,9 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable( - config[CONF_ID], config[CONF_MAC_ADDRESS].as_hex, config[CONF_BINDKEY] + var = await ble_device_base.new_ble_device_component( + config, config[CONF_MAC_ADDRESS].as_hex, config[CONF_BINDKEY] ) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) sensors = sensor.sub_sensors(config) await sensors(CONF_WEIGHT, var.set_weight) diff --git a/esphome/components/xiaomi_cgd1/sensor.py b/esphome/components/xiaomi_cgd1/sensor.py index 162ba7016be..5649a1d9746 100644 --- a/esphome/components/xiaomi_cgd1/sensor.py +++ b/esphome/components/xiaomi_cgd1/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgdk2/sensor.py b/esphome/components/xiaomi_cgdk2/sensor.py index e1d44d6bac5..7e96b231371 100644 --- a/esphome/components/xiaomi_cgdk2/sensor.py +++ b/esphome/components/xiaomi_cgdk2/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_cgg1/sensor.py b/esphome/components/xiaomi_cgg1/sensor.py index 01ca8d1f84d..a81d8d2f4e4 100644 --- a/esphome/components/xiaomi_cgg1/sensor.py +++ b/esphome/components/xiaomi_cgg1/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -59,9 +58,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) if CONF_BINDKEY in config: diff --git a/esphome/components/xiaomi_gcls002/sensor.py b/esphome/components/xiaomi_gcls002/sensor.py index 75138970246..f9e44fb94fd 100644 --- a/esphome/components/xiaomi_gcls002/sensor.py +++ b/esphome/components/xiaomi_gcls002/sensor.py @@ -3,7 +3,6 @@ from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -65,9 +64,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy01/sensor.py b/esphome/components/xiaomi_hhccjcy01/sensor.py index 11bbceef5e3..f9d06017f0e 100644 --- a/esphome/components/xiaomi_hhccjcy01/sensor.py +++ b/esphome/components/xiaomi_hhccjcy01/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CONDUCTIVITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -75,9 +74,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccjcy10/sensor.py b/esphome/components/xiaomi_hhccjcy10/sensor.py index c5d256db1d3..76fe80b19c8 100644 --- a/esphome/components/xiaomi_hhccjcy10/sensor.py +++ b/esphome/components/xiaomi_hhccjcy10/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_CONDUCTIVITY, - CONF_ID, CONF_ILLUMINANCE, CONF_MAC_ADDRESS, CONF_MOISTURE, @@ -75,9 +74,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_hhccpot002/sensor.py b/esphome/components/xiaomi_hhccpot002/sensor.py index 73d5f3ff192..555c6a164ae 100644 --- a/esphome/components/xiaomi_hhccpot002/sensor.py +++ b/esphome/components/xiaomi_hhccpot002/sensor.py @@ -3,7 +3,6 @@ from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CONDUCTIVITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_MOISTURE, ICON_FLOWER, @@ -47,9 +46,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_jqjcy01ym/sensor.py b/esphome/components/xiaomi_jqjcy01ym/sensor.py index 4bfe9c8f756..5c4600f5d12 100644 --- a/esphome/components/xiaomi_jqjcy01ym/sensor.py +++ b/esphome/components/xiaomi_jqjcy01ym/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_FORMALDEHYDE, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -66,9 +65,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsd02/sensor.py b/esphome/components/xiaomi_lywsd02/sensor.py index d37d7540c76..d29cd7c789a 100644 --- a/esphome/components/xiaomi_lywsd02/sensor.py +++ b/esphome/components/xiaomi_lywsd02/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -57,9 +56,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_lywsd02mmc/sensor.py b/esphome/components/xiaomi_lywsd02mmc/sensor.py index 5b088318ae7..a1a6cef3a4a 100644 --- a/esphome/components/xiaomi_lywsd02mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd02mmc/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsd03mmc/sensor.py b/esphome/components/xiaomi_lywsd03mmc/sensor.py index 32977d0ed6f..a1d7af7590f 100644 --- a/esphome/components/xiaomi_lywsd03mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd03mmc/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -61,9 +60,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_lywsdcgq/sensor.py b/esphome/components/xiaomi_lywsdcgq/sensor.py index 75a8a2d847e..7f98c88df7f 100644 --- a/esphome/components/xiaomi_lywsdcgq/sensor.py +++ b/esphome/components/xiaomi_lywsdcgq/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -57,9 +56,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_mhoc303/sensor.py b/esphome/components/xiaomi_mhoc303/sensor.py index 44fb9c76548..76acc91dfd3 100644 --- a/esphome/components/xiaomi_mhoc303/sensor.py +++ b/esphome/components/xiaomi_mhoc303/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -57,9 +56,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/xiaomi_mhoc401/sensor.py b/esphome/components/xiaomi_mhoc401/sensor.py index e74c656e512..d9f2f3f5cf3 100644 --- a/esphome/components/xiaomi_mhoc401/sensor.py +++ b/esphome/components/xiaomi_mhoc401/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_miscale/sensor.py b/esphome/components/xiaomi_miscale/sensor.py index c6277a515d7..9c3120d064a 100644 --- a/esphome/components/xiaomi_miscale/sensor.py +++ b/esphome/components/xiaomi_miscale/sensor.py @@ -3,7 +3,6 @@ from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_CLEAR_IMPEDANCE, - CONF_ID, CONF_IMPEDANCE, CONF_MAC_ADDRESS, CONF_WEIGHT, @@ -51,9 +50,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_clear_impedance(config[CONF_CLEAR_IMPEDANCE])) diff --git a/esphome/components/xiaomi_rtcgq02lm/__init__.py b/esphome/components/xiaomi_rtcgq02lm/__init__.py index 71d7e0a265b..9cbf9456959 100644 --- a/esphome/components/xiaomi_rtcgq02lm/__init__.py +++ b/esphome/components/xiaomi_rtcgq02lm/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import ble_device_base import esphome.config_validation as cv -from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS +from esphome.const import CONF_BINDKEY, CONF_MAC_ADDRESS from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] @@ -29,9 +29,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) diff --git a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py index 8c47b526b1d..6a57b3699e0 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py +++ b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py @@ -5,7 +5,6 @@ from esphome.const import ( CONF_BATTERY_LEVEL, CONF_BINDKEY, CONF_HUMIDITY, - CONF_ID, CONF_MAC_ADDRESS, CONF_TEMPERATURE, DEVICE_CLASS_BATTERY, @@ -60,9 +59,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_device_base.register_ble_device(var, config) + var = await ble_device_base.new_ble_device_component(config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_bindkey(config[CONF_BINDKEY])) From e54d66e62069e2e17a0485e40a7c811fedc80f48 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 18 Oct 2026 14:36:35 -0500 Subject: [PATCH 114/179] [api] Poll the outgoing dial every loop on deep sleep builds (#21077) --- esphome/components/api/api_outgoing_connection.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index 16d50c34255..fff11522d79 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -59,17 +59,20 @@ class OutgoingConnectionManager { static constexpr uint32_t BACKOFF_MIN_MS = 5000; static constexpr uint32_t BACKOFF_MAX_MS = 300000; static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; - static constexpr uint32_t NETWORK_RETRY_MS = 500; static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; // A deep sleep wake window is too short to spend on the delay, so those - // builds dial out as soon as the target is gone + // builds dial out as soon as the target is gone, recheck the network on + // every loop and notice the connect completing on the loop it does #ifdef USE_DEEP_SLEEP static constexpr uint32_t BOOT_WAIT_MS = 0; static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS; + static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 0; + static constexpr uint32_t NETWORK_RETRY_MS = 0; #else static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; + static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; + static constexpr uint32_t NETWORK_RETRY_MS = 500; #endif void try_dial_(APIServer *server, uint32_t now); From dd456ea0b2d895911a5ae44bed400d58dec9a86e Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Sun, 18 Oct 2026 23:02:31 +0300 Subject: [PATCH 115/179] [homeassistant] Add the light platform (#11354) 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: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/api/wizard.py | 10 +- .../homeassistant/homeassistant.cpp | 11 + .../components/homeassistant/homeassistant.h | 4 + .../homeassistant/light/__init__.py | 81 +++ .../light/homeassistant_light.cpp | 477 ++++++++++++++++++ .../homeassistant/light/homeassistant_light.h | 70 +++ tests/component_tests/api/test_wizard.py | 10 + .../homeassistant/mixed_entities.yaml | 6 + .../test_state_subscription_count.py | 4 +- tests/components/homeassistant/common.yaml | 6 + .../fixtures/api_homeassistant_light.yaml | 39 ++ .../test_api_homeassistant_light.py | 393 +++++++++++++++ 13 files changed, 1109 insertions(+), 3 deletions(-) create mode 100644 esphome/components/homeassistant/homeassistant.cpp create mode 100644 esphome/components/homeassistant/light/__init__.py create mode 100644 esphome/components/homeassistant/light/homeassistant_light.cpp create mode 100644 esphome/components/homeassistant/light/homeassistant_light.h create mode 100644 tests/integration/fixtures/api_homeassistant_light.yaml create mode 100644 tests/integration/test_api_homeassistant_light.py diff --git a/CODEOWNERS b/CODEOWNERS index 1f1375ba968..61f1f4722cc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -279,6 +279,7 @@ esphome/components/hmac_sha256/* @dwmw2 esphome/components/hoermann_hcp/* @zweckj esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/button/* @jesserockz +esphome/components/homeassistant/light/* @egormanga esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/select/* @jesserockz esphome/components/homeassistant/switch/* @Links2004 diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index 8e5c808e24c..200c4f9f120 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -208,6 +208,7 @@ WIZARD_SCHEMA = cv.All( WIZARD_INPUT_DOMAINS = ( "binary_sensor", "button", + "light", "number", "select", "sensor", @@ -216,7 +217,14 @@ WIZARD_INPUT_DOMAINS = ( "text_sensor", ) # Platforms that act on one family of Home Assistant domains, which the input's filters must stay within -WIZARD_DOMAIN_LIMITED_PLATFORMS = ("button", "number", "select", "switch", "text") +WIZARD_DOMAIN_LIMITED_PLATFORMS = ( + "button", + "light", + "number", + "select", + "switch", + "text", +) def wizard_input_ids(api_config: ConfigType) -> set[str]: diff --git a/esphome/components/homeassistant/homeassistant.cpp b/esphome/components/homeassistant/homeassistant.cpp new file mode 100644 index 00000000000..5d6a41c677b --- /dev/null +++ b/esphome/components/homeassistant/homeassistant.cpp @@ -0,0 +1,11 @@ +#include "homeassistant.h" + +#include "esphome/core/progmem.h" + +namespace esphome::homeassistant { + +PROGMEM_STRING_TABLE(UnsetValues, "None", "unavailable", "unknown"); + +bool is_unset(StringRef value) { return progmem_string_table_find(value.c_str(), value.size()) >= 0; } + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/homeassistant.h b/esphome/components/homeassistant/homeassistant.h index 65eb6ef5fd2..580c1d89a90 100644 --- a/esphome/components/homeassistant/homeassistant.h +++ b/esphome/components/homeassistant/homeassistant.h @@ -7,6 +7,10 @@ namespace esphome::homeassistant { +/// True for what Home Assistant reports when there is no value: "None" for an attribute that is not +/// set, "unavailable" and "unknown" for the state of an entity that is not ready +bool is_unset(StringRef value); + /// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the item early. template bool emit_utf8(uint32_t code_point, C &on_char) { if (code_point == 0 || code_point > 0x10FFFF) diff --git a/esphome/components/homeassistant/light/__init__.py b/esphome/components/homeassistant/light/__init__.py new file mode 100644 index 00000000000..abb6b7289f7 --- /dev/null +++ b/esphome/components/homeassistant/light/__init__.py @@ -0,0 +1,81 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEFAULT_TRANSITION_LENGTH, + CONF_EFFECTS, + CONF_FLASH_TRANSITION_LENGTH, + CONF_GAMMA_CORRECT, + CONF_INITIAL_STATE, + CONF_RESTORE_MODE, + CONF_RESTORE_STATE, +) +from esphome.core import TimePeriod +from esphome.types import ConfigType + +from .. import ( + HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, + homeassistant_ns, + setup_home_assistant_entity, + validate_entity_domain, +) + +CODEOWNERS = ["@egormanga"] +DEPENDENCIES = ["api"] +AUTO_LOAD = ["homeassistant"] + +CONF_REPORT_TIMEOUT = "report_timeout" +SUPPORTED_DOMAINS = ["light"] + +HomeassistantLight = homeassistant_ns.class_( + "HomeassistantLight", light.LightOutput, cg.Component +) + +_RENDERED_BY_HOME_ASSISTANT = cv.invalid( + "Home Assistant renders this light and keeps its state; this option has no effect here" +) + +CONFIG_SCHEMA = cv.All( + light.light_schema(HomeassistantLight, light.LightType.RGB) + .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) + .extend( + { + # A transition would be stepped over the API; the target is sent once instead + cv.Optional( + CONF_DEFAULT_TRANSITION_LENGTH, default="0s" + ): cv.positive_time_period_milliseconds, + # Stored in a byte of seconds; a light polled by Home Assistant can take a minute + cv.Optional(CONF_REPORT_TIMEOUT, default="10s"): cv.All( + cv.positive_time_period_seconds, + cv.Range(min=TimePeriod(seconds=1), max=TimePeriod(seconds=255)), + ), + **{ + cv.Optional(key): _RENDERED_BY_HOME_ASSISTANT + for key in ( + CONF_GAMMA_CORRECT, + CONF_EFFECTS, + CONF_FLASH_TRANSITION_LENGTH, + light.CONF_TRANSITION_STATE_PUBLISH_INTERVAL, + CONF_RESTORE_MODE, + CONF_RESTORE_STATE, + CONF_INITIAL_STATE, + ) + }, + } + ) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("light", SUPPORTED_DOMAINS), +) + +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + var = await light.new_light(config) + await cg.register_component(var, config) + cg.add(var.set_report_timeout(config[CONF_REPORT_TIMEOUT].total_seconds)) + # 1 state + supported_color_modes, min/max_color_temp_kelvin, color_mode, brightness, + # color_temp_kelvin and rgb_color in HomeassistantLight::setup() + setup_home_assistant_entity(var, config, state_subscription_count=8) diff --git a/esphome/components/homeassistant/light/homeassistant_light.cpp b/esphome/components/homeassistant/light/homeassistant_light.cpp new file mode 100644 index 00000000000..6ebe0cadbfb --- /dev/null +++ b/esphome/components/homeassistant/light/homeassistant_light.cpp @@ -0,0 +1,477 @@ +#include "homeassistant_light.h" + +#include +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "../homeassistant.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +ESPHOME_LOG_TAG(TAG, "homeassistant.light"); + +static constexpr uint32_t ECHO_TIMEOUT_ID = 0; + +using namespace esphome::light; + +namespace { + +// Home Assistant's color mode names, in flash on the ESP8266. The names from "hs" on are every +// color mode, all handled as RGB: Home Assistant reports rgb_color for all of them and derives +// the white channels from rgb_color itself. +PROGMEM_STRING_TABLE(ColorModeNames, "onoff", "brightness", "white", "color_temp", "hs", "xy", "rgb", "rgbw", "rgbww"); +const uint8_t COLOR_MODES[] PROGMEM = { + static_cast(ColorMode::ON_OFF), + static_cast(ColorMode::BRIGHTNESS), + static_cast(ColorMode::WHITE), + static_cast(ColorMode::COLOR_TEMPERATURE), +}; + +optional parse_color_mode(StringRef name) { + const int16_t i = progmem_string_table_find(name.c_str(), name.size()); + if (i < 0) { + return nullopt; + } + return i < sizeof(COLOR_MODES) ? static_cast(progmem_read_byte(&COLOR_MODES[i])) : ColorMode::RGB; +} + +/// Reads a Python tuple of three integers such as "(255, 0, 0)"; false for anything else +bool parse_rgb(const char *text, std::array &values) { + for (auto &value : values) { + while (*text == ' ' || *text == ',' || *text == '(') { + text++; + } + if (*text < '0' || *text > '9') { + return false; + } + uint16_t number = 0; + while (*text >= '0' && *text <= '9') { + number = number * 10 + (*text++ - '0'); + if (number > 255) { + return false; + } + } + value = static_cast(number); + } + while (*text == ' ' || *text == ',' || *text == ')') { + text++; + } + return *text == '\0'; +} + +} // namespace + +void HomeassistantLight::setup() { + auto *server = api::global_api_server; + // Home Assistant sends these on subscribe and when they change, so a light that comes up late gets them + server->subscribe_home_assistant_state(this->entity_id_, "supported_color_modes", [this](StringRef value) { + if (is_unset(value)) { + return; // the light is unavailable; its traits stay + } + // A Python list of the mode names, as strings or as the enum members + ColorModeMask modes; + char name[ColorModeNames::MAX_LENGTH + 1]; + size_t length = 0; + const bool valid = parse_python_list( + value, + [&](char c) { + if (length < sizeof(name)) { + name[length] = c; + } + length++; + }, + [&]() { + auto mode = length <= ColorModeNames::MAX_LENGTH ? parse_color_mode(StringRef(name, length)) : nullopt; + if (mode.has_value()) { + modes.insert(*mode); + } else { + ESP_LOGW(TAG, "'%s': Unknown color mode in '%s'", this->entity_id_, value.c_str()); + } + length = 0; + }); + if (!valid) { + ESP_LOGW(TAG, "'%s': Can't parse 'supported_color_modes' value '%s'", this->entity_id_, value.c_str()); + return; + } + if (modes.empty()) { + ESP_LOGW(TAG, "'%s': No color mode found in '%s', keeping the current ones", this->entity_id_, value.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Supported color modes retrieved: %s", this->entity_id_, value.c_str()); + this->traits_.set_supported_color_modes(modes); + // Attributes that arrived before the traits were validated against empty ones; apply them again + this->schedule_apply_(); + }); + // Home Assistant reports the temperature range in Kelvin, the light core works in mireds + server->subscribe_home_assistant_state(this->entity_id_, "min_color_temp_kelvin", [this](StringRef value) { + if (auto kelvin = this->parse_integer_("min_color_temp_kelvin", value, UINT16_MAX)) { + this->traits_.set_max_mireds(1e6f / *kelvin); + this->schedule_apply_(); + } + }); + server->subscribe_home_assistant_state(this->entity_id_, "max_color_temp_kelvin", [this](StringRef value) { + if (auto kelvin = this->parse_integer_("max_color_temp_kelvin", value, UINT16_MAX)) { + this->traits_.set_min_mireds(1e6f / *kelvin); + this->schedule_apply_(); + } + }); + + // An attribute is "None" while the light is off or does not report it; the last value stays + server->subscribe_home_assistant_state(this->entity_id_, nullptr, [this](StringRef value) { + if (is_unset(value)) { + return; // normal while Home Assistant or the light is starting up or offline + } + auto on_off = parse_on_off(value.c_str()); + if (on_off != PARSE_ON && on_off != PARSE_OFF) { + ESP_LOGW(TAG, "'%s': Can't parse '%s' as state!", this->entity_id_, value.c_str()); + return; + } + const bool state = on_off == PARSE_ON; + this->on_report_(this->imported_.state != state); + this->imported_.state = state; + }); + server->subscribe_home_assistant_state(this->entity_id_, "color_mode", [this](StringRef value) { + if (is_unset(value)) { + return; + } + auto mode = parse_color_mode(value); + if (!mode.has_value()) { + ESP_LOGE(TAG, "'%s': Can't parse 'color_mode' value '%s' as color mode!", this->entity_id_, value.c_str()); + return; + } + this->on_report_(this->imported_.color_mode != *mode); + this->imported_.color_mode = *mode; + }); + server->subscribe_home_assistant_state(this->entity_id_, "brightness", [this](StringRef value) { + if (auto brightness = this->parse_integer_("brightness", value, 255)) { + this->on_report_(this->imported_.brightness != *brightness); + this->imported_.brightness = *brightness; + } + }); + server->subscribe_home_assistant_state(this->entity_id_, "color_temp_kelvin", [this](StringRef value) { + if (auto kelvin = this->parse_integer_("color_temp_kelvin", value, UINT16_MAX)) { + this->on_report_(this->imported_.color_temp_kelvin != *kelvin); + this->imported_.color_temp_kelvin = *kelvin; + } + }); + server->subscribe_home_assistant_state(this->entity_id_, "rgb_color", [this](StringRef value) { + if (is_unset(value)) { + return; + } + std::array rgb; + if (!parse_rgb(value.c_str(), rgb)) { + ESP_LOGE(TAG, "'%s': Can't parse 'rgb_color' value '%s'!", this->entity_id_, value.c_str()); + return; + } + this->on_report_(!this->imported_.has_rgb || this->imported_.rgb != rgb); + this->imported_.rgb = rgb; + this->imported_.has_rgb = true; + }); +} + +void HomeassistantLight::dump_config() { + ESP_LOGCONFIG(TAG, + "Homeassistant Light:\n" + " Entity ID: '%s'", + this->entity_id_); +} + +optional HomeassistantLight::parse_integer_(const char *name, StringRef value, uint16_t max) { + if (is_unset(value)) { + return nullopt; + } + auto number = parse_number(value.c_str()); + if (!number.has_value()) { + ESP_LOGE(TAG, "'%s': Can't convert '%s' value '%s' to number!", this->entity_id_, name, value.c_str()); + return nullopt; + } + // Written so that NaN fails too + const bool in_range = *number >= 1.0f && *number <= max; + if (!in_range) { + ESP_LOGW(TAG, "'%s': '%s' value %.0f is out of range!", this->entity_id_, name, *number); + return nullopt; + } + return static_cast(*number); +} + +void HomeassistantLight::on_report_(bool changed) { + // A report that changed an attribute answers the change that armed the echo timeout; a repeated + // value (or a trait) does not, so a refused change still gets its warning + if (changed && this->echo_pending_) { + this->echo_pending_ = false; + this->cancel_timeout(ECHO_TIMEOUT_ID); + } + this->report_changed_ |= changed; + this->schedule_apply_(); +} + +void HomeassistantLight::schedule_apply_() { + // One update changes several attributes, each arriving as its own message; apply them as one call + if (!this->apply_scheduled_) { + this->apply_scheduled_ = true; + this->defer([this]() { this->apply_imported_(); }); + } +} + +void HomeassistantLight::apply_imported_() { + this->apply_scheduled_ = false; + // Nothing to apply before the first state; while a sent change waits for its report the reported + // state is the old one, so a trait that arrives then does not bring it back + if (!this->imported_.state.has_value() || this->echo_pending_) { + return; + } + // Attributes Home Assistant has not reported keep what the light has. An "off" report carries + // no levels, so the ones reported last stay with the light: a turn_on without a brightness + // then sends what Home Assistant last had, not a level that never reached it + const Attributes &imported = this->imported_; + LightColorValues values = this->state_->remote_values; + values.set_state(*imported.state); + if (imported.color_mode != ColorMode::UNKNOWN) { + values.set_color_mode(imported.color_mode); + } + if (imported.brightness != 0) { + values.set_brightness(imported.brightness / 255.0f); + } + // Home Assistant reports rgb_color in every mode and keeps the attributes of the other + // modes, so only the ones of the mode it reports are taken over + const ColorMode mode = values.get_color_mode(); + if (mode == ColorMode::COLOR_TEMPERATURE) { + values.set_color_temperature_kelvin(imported.color_temp_kelvin); // ignores an unreported 0 + } else if (mode == ColorMode::RGB) { + if (imported.has_rgb) { + values.set_red(imported.rgb[0] / 255.0f); + values.set_green(imported.rgb[1] / 255.0f); + values.set_blue(imported.rgb[2] / 255.0f); + } + values.set_color_brightness(1.0f); // brightness is the only level Home Assistant has + } else if (mode == ColorMode::WHITE) { + values.set_white(1.0f); + } + if (!this->state_->set_external_state(values)) { + // A report that changed something during a transition or flash is applied once it ends, in + // place of the values it ends at; a trait or a repeated report has nothing newer to say + if (this->state_->is_transitioning() && this->report_changed_) { + this->report_held_ = true; + this->held_hash_ = state_hash(this->state_->remote_values); + return; + } + // A local change still waits for LightState::loop() to send it. Home Assistant reports again + // once that change reaches it; when it does not, the echo timeout write_state() arms brings the + // light back to what was reported last + ESP_LOGD(TAG, "'%s': Report dropped, a local change is on its way", this->entity_id_); + return; + } + this->report_changed_ = false; + ESP_LOGV(TAG, "'%s': Took over the state reported by Home Assistant", this->entity_id_); +} + +uint32_t HomeassistantLight::state_hash(const LightColorValues &values) { + uint32_t hash = fnv1a_hash_extend(FNV1_OFFSET_BASIS, static_cast(values.get_color_mode())); + for (const float channel : + {values.get_state(), values.get_brightness(), values.get_color_brightness(), values.get_red(), + values.get_green(), values.get_blue(), values.get_white(), values.get_cold_white(), values.get_warm_white()}) { + hash = fnv1a_hash_extend(hash, to_uint8_scale(channel)); + } + return fnv1a_hash_extend(hash, static_cast(values.get_color_temperature())); // mireds exceed a byte +} + +void HomeassistantLight::update_state(LightState *state) { + // The light core's setup performs one call for the initial state; a second one before the first + // write (an on_boot automation) is a local change merged into that write, so it must be sent + if (this->initial_write_) { + if (this->initial_seen_) { + this->initial_write_ = false; + } + this->initial_seen_ = true; + } + // A call without a transition replaces whatever ran, so a report held for it is older than the call + if (!state->is_transitioning()) { + this->report_held_ = false; + } +} + +bool HomeassistantLight::is_imported_(const Attributes &outgoing) const { + const Attributes &imported = this->imported_; + if (!imported.state.has_value() || !outgoing.state.has_value() || *imported.state != *outgoing.state) { + return false; + } + if (!*outgoing.state) { + return true; + } + if (imported.color_mode != outgoing.color_mode || imported.brightness != outgoing.brightness) { + return false; + } + // rgb_color stays reported in the other modes, so only the attributes of the mode count + switch (outgoing.color_mode) { + case ColorMode::COLOR_TEMPERATURE: + return imported.color_temp_kelvin == outgoing.color_temp_kelvin; + case ColorMode::RGB: + return imported.has_rgb && imported.rgb == outgoing.rgb; + default: + return true; + } +} + +void HomeassistantLight::fall_back_() { + // Back to the state Home Assistant reported, which the light still has + if (!this->imported_.state.has_value()) { + ESP_LOGW(TAG, "'%s': Nothing reported yet to fall back to, the light keeps the unsent change", this->entity_id_); + return; + } + this->schedule_apply_(); +} + +void HomeassistantLight::write_state(LightState *state) { + // A reported state is taken over without a write, so every write is a local change, except + // the very first: the light core writes its initial state, which Home Assistant does not need + if (this->initial_write_) { + this->initial_write_ = false; + return; + } + if (state->is_transitioning()) { + // A transition or flash runs here alone; Home Assistant gets the state it ends at. A call that + // replaces the transition a report was held for retargets it and is newer than the report + if (this->report_held_ && state_hash(state->remote_values) != this->held_hash_) { + this->report_held_ = false; + } + this->transitioned_ = true; + return; + } + const bool held = this->report_held_; + const bool transitioned = this->transitioned_; + this->report_held_ = false; + this->transitioned_ = false; + this->report_changed_ = false; // this change is newer than any report so far, held or dropped + // Home Assistant changed meanwhile; its report is newer than what the transition ends at or + // what the flash goes back to + if (held) { + ESP_LOGD(TAG, "'%s': Applying the report that arrived during the transition", this->entity_id_); + this->schedule_apply_(); + return; + } + // What Home Assistant holds after the change. It has one level, so the channel brightness of the + // mode folds into it + const LightColorValues &values = state->current_values; + Attributes outgoing; + outgoing.state = values.is_on(); + if (values.is_on()) { + outgoing.color_mode = values.get_color_mode(); + switch (outgoing.color_mode) { + case ColorMode::ON_OFF: + break; + case ColorMode::WHITE: + outgoing.brightness = to_uint8_scale(values.get_brightness() * values.get_white()); + break; + case ColorMode::COLOR_TEMPERATURE: + outgoing.brightness = to_uint8_scale(values.get_brightness()); + if (values.get_color_temperature() > 0.0f) { + // A mired value below the reported range could give more than 65535 K + outgoing.color_temp_kelvin = lroundf(std::min(values.get_color_temperature_kelvin(), 65535.0f)); + } + break; + case ColorMode::RGB: + outgoing.brightness = to_uint8_scale(values.get_brightness() * values.get_color_brightness()); + outgoing.rgb = {to_uint8_scale(values.get_red()), to_uint8_scale(values.get_green()), + to_uint8_scale(values.get_blue())}; + outgoing.has_rgb = true; + break; + default: + outgoing.brightness = to_uint8_scale(values.get_brightness()); + break; + } + } + // Home Assistant reports only what changed, so a change that leaves it as it is gets no report: + // nothing to send and no echo to wait for. A change still on its way may alter it first, though + if (!this->echo_pending_ && this->is_imported_(outgoing)) { + if (transitioned) { + // A flash ends where it began, and Home Assistant only ever gets the end + ESP_LOGW(TAG, "'%s': Ended at the state Home Assistant already has, nothing to send (a flash never is)", + this->entity_id_); + } else { + ESP_LOGD(TAG, "'%s': Home Assistant already has this state", this->entity_id_); + } + return; + } + if (!api::global_api_server->is_connected()) { + ESP_LOGE(TAG, "'%s': No clients connected to API server, change lost", this->entity_id_); + this->fall_back_(); + return; + } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", state->get_name().c_str()); + this->fall_back_(); + return; + } +#endif + + // In flash on the ESP8266 and encoded from there + static const char SERVICE_TURN_ON[] PROGMEM = "light.turn_on"; + static const char SERVICE_TURN_OFF[] PROGMEM = "light.turn_off"; + static const char ENTITY_ID_KEY[] PROGMEM = "entity_id"; + static const char BRIGHTNESS_KEY[] PROGMEM = "brightness"; + static const char WHITE_KEY[] PROGMEM = "white"; + static const char COLOR_TEMP_KELVIN_KEY[] PROGMEM = "color_temp_kelvin"; + static const char RGB_COLOR_KEY[] PROGMEM = "rgb_color"; + + // The request refers to these buffers until it is sent below + char level_buf[4]; // up to 255 + char color_temp_buf[6]; // up to 65535 + char color_buf[16]; + + api::HomeassistantActionRequest resp; + resp.data.init(3); + auto &entity_id = resp.data.emplace_back(); + entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); + entity_id.value = ProgmemStringRef(StringRef(this->entity_id_)); + + auto add_number = [&](ProgmemStringRef key, auto &buf, unsigned number) { + auto &entry = resp.data.emplace_back(); + entry.key = key; + entry.value = ProgmemStringRef(StringRef(buf, buf_append_printf(buf, sizeof(buf), 0, "%u", number))); + }; + if (!*outgoing.state) { + resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_OFF); + } else { + resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_ON); + if (outgoing.color_mode != ColorMode::ON_OFF) { + // The level goes under "white" in that mode, which also selects the mode + add_number(outgoing.color_mode == ColorMode::WHITE ? ProgmemStringRef::from_lit(WHITE_KEY) + : ProgmemStringRef::from_lit(BRIGHTNESS_KEY), + level_buf, outgoing.brightness); + } + // Home Assistant converts the color to the light's own channels, which loses the white balance + // of a cold/warm white light, so the color goes only when it or the mode changed (or may have, + // while a change still waits for its report) + const Attributes &imported = this->imported_; + const bool new_color = this->echo_pending_ || outgoing.color_mode != imported.color_mode; + if (outgoing.color_temp_kelvin != 0 && (new_color || outgoing.color_temp_kelvin != imported.color_temp_kelvin)) { + add_number(ProgmemStringRef::from_lit(COLOR_TEMP_KELVIN_KEY), color_temp_buf, outgoing.color_temp_kelvin); + } + if (outgoing.has_rgb && (new_color || !imported.has_rgb || outgoing.rgb != imported.rgb)) { + // A list, which only a template renders as one + resp.data_template.init(1); + auto &color = resp.data_template.emplace_back(); + color.key = ProgmemStringRef::from_lit(RGB_COLOR_KEY); + color.value = + ProgmemStringRef(StringRef(color_buf, buf_append_printf(color_buf, sizeof(color_buf), 0, "[%u,%u,%u]", + outgoing.rgb[0], outgoing.rgb[1], outgoing.rgb[2]))); + } + } + api::global_api_server->send_homeassistant_action(resp); + // Home Assistant reports the change back in a moment, which cancels this; when it does not, the + // change got lost on the way (no subscriber yet, the entity unavailable, the action refused) + this->echo_pending_ = true; + this->set_timeout(ECHO_TIMEOUT_ID, this->report_timeout_s_ * 1000u, [this]() { + this->echo_pending_ = false; + ESP_LOGW(TAG, "'%s': Home Assistant did not report the change back", this->entity_id_); + this->fall_back_(); + }); +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/light/homeassistant_light.h b/esphome/components/homeassistant/light/homeassistant_light.h new file mode 100644 index 00000000000..a7f316a22f8 --- /dev/null +++ b/esphome/components/homeassistant/light/homeassistant_light.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +#include "esphome/components/light/light_output.h" +#include "esphome/core/component.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +/// A light in Home Assistant, driven from here: its state and attributes are imported over the +/// native API, and every change made on this side is sent back as a light.turn_on or +/// light.turn_off action. +class HomeassistantLight final : public light::LightOutput, public Component { + public: + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + /// How long a sent change may take to come back as a report before the light returns to the reported state + void set_report_timeout(uint8_t seconds) { this->report_timeout_s_ = seconds; } + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + light::LightTraits get_traits() override { return this->traits_; } + void setup_state(light::LightState *state) override { this->state_ = state; } + void update_state(light::LightState *state) override; + void write_state(light::LightState *state) override; + + protected: + /// What Home Assistant reported last, in its own units. Each attribute arrives as its own + /// message and stays until the next report of it, so an update may change any subset; zero, + /// ColorMode::UNKNOWN and has_rgb say an attribute was never reported. + struct Attributes { + uint16_t color_temp_kelvin{0}; + uint8_t brightness{0}; + std::array rgb{}; + light::ColorMode color_mode{light::ColorMode::UNKNOWN}; + bool has_rgb{false}; + optional state; + }; + + /// Parses a Home Assistant attribute as an integer from 1 to max, logging anything else + optional parse_integer_(const char *name, StringRef value, uint16_t max); + /// A state or attribute report: answers a pending echo when it changed something, and schedules the apply + void on_report_(bool changed); + /// A state at the resolution Home Assistant gets, to see a transition retargeted by a newer call + static uint32_t state_hash(const light::LightColorValues &values); + /// Schedules apply_imported_() once for the attributes of one update + void schedule_apply_(); + void apply_imported_(); + /// True when Home Assistant reported exactly what a change would make of it + bool is_imported_(const Attributes &outgoing) const; + /// Returns the light to the reported state after a change that did not reach Home Assistant + void fall_back_(); + + const char *entity_id_{nullptr}; + light::LightState *state_{nullptr}; + light::LightTraits traits_{}; + Attributes imported_{}; + uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at + bool apply_scheduled_ : 1 {false}; + bool initial_write_ : 1 {true}; // the light core's own first write is not a local change + bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state + bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back + bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends + bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent + bool report_changed_ : 1 {false}; // the reports since the last apply changed an attribute + uint8_t report_timeout_s_{10}; +}; + +} // namespace esphome::homeassistant diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index f55a6974e7f..d8ca84720ed 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -303,6 +303,16 @@ def test_entity_id_is_optional_only_for_wizard_inputs( ), errors +def test_light_is_a_wizard_input(tmp_path: Path) -> None: + entities = "light:\n - platform: homeassistant\n id: ha_light\n" + errors = config_errors( + write_config( + tmp_path, ESP32_HEADER, wizard_inputs("- entity: ha_light"), entities + ) + ) + assert errors == [] + + def test_entity_id_is_optional_for_an_input_and_its_domain_is_checked( tmp_path: Path, ) -> None: diff --git a/tests/component_tests/homeassistant/mixed_entities.yaml b/tests/component_tests/homeassistant/mixed_entities.yaml index 8c96e1ad521..da9246f2fea 100644 --- a/tests/component_tests/homeassistant/mixed_entities.yaml +++ b/tests/component_tests/homeassistant/mixed_entities.yaml @@ -63,3 +63,9 @@ button: id: ha_button name: HA Button entity_id: button.restart + +light: + - platform: homeassistant + id: ha_light + name: HA Light + entity_id: light.kitchen diff --git a/tests/component_tests/homeassistant/test_state_subscription_count.py b/tests/component_tests/homeassistant/test_state_subscription_count.py index c3832369117..5e7afed1ade 100644 --- a/tests/component_tests/homeassistant/test_state_subscription_count.py +++ b/tests/component_tests/homeassistant/test_state_subscription_count.py @@ -15,12 +15,12 @@ def test_mixed_entities_state_subscription_count( generate_main(DIR / "mixed_entities.yaml") # sensor (2*1) + binary_sensor (1) + text_sensor (1) + switch (1) - # + select (2) + number (4) + text (4) + button (0) = 15 + # + select (2) + number (4) + text (4) + button (0) + light (8) = 23 define_match = [ d for d in CORE.defines if d.name == "HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT" ] assert len(define_match) == 1 - assert str(define_match[0].value) == "15" + assert str(define_match[0].value) == "23" assert any(d.name == "USE_API_HOMEASSISTANT_STATES" for d in CORE.defines) diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 078036598a9..f39b3e1b2d2 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -54,6 +54,12 @@ switch: entity_id: switch.my_cool_switch id: my_cool_switch +light: + - platform: homeassistant + entity_id: light.my_cool_light + id: ha_my_cool_light + report_timeout: 30s + binary_sensor: - platform: homeassistant entity_id: binary_sensor.hello_world diff --git a/tests/integration/fixtures/api_homeassistant_light.yaml b/tests/integration/fixtures/api_homeassistant_light.yaml new file mode 100644 index 00000000000..e2cf9e5fbe5 --- /dev/null +++ b/tests/integration/fixtures/api_homeassistant_light.yaml @@ -0,0 +1,39 @@ +esphome: + name: ha-light + +host: + +api: + actions: + - action: set_force_off + variables: + value: bool + then: + - globals.set: + id: force_off + value: !lambda return value; + +logger: + level: DEBUG + +globals: + - id: force_off + type: bool + initial_value: "false" + +light: + - platform: homeassistant + name: HA Light + id: ha_light + entity_id: light.kitchen + internal: false + report_timeout: 2s + on_turn_off: + - logger.log: "turned off" + on_turn_on: + # An automation reacting to a state reported by Home Assistant with a local change + - if: + condition: + lambda: return id(force_off); + then: + - light.turn_off: ha_light diff --git a/tests/integration/test_api_homeassistant_light.py b/tests/integration/test_api_homeassistant_light.py new file mode 100644 index 00000000000..2cb9b4d6d05 --- /dev/null +++ b/tests/integration/test_api_homeassistant_light.py @@ -0,0 +1,393 @@ +"""Test the homeassistant light platform against a fake Home Assistant.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from aioesphomeapi import ( + APIClient, + EntityState, + HomeassistantServiceCall, + LightColorCapability, + LightState, + UserService, +) +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + +ENTITY = "light.kitchen" +SUBSCRIPTIONS = { + (ENTITY, None), + (ENTITY, "supported_color_modes"), + (ENTITY, "min_color_temp_kelvin"), + (ENTITY, "max_color_temp_kelvin"), + (ENTITY, "color_mode"), + (ENTITY, "brightness"), + (ENTITY, "color_temp_kelvin"), + (ENTITY, "rgb_color"), +} +COLOR_TEMPERATURE = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.COLOR_TEMPERATURE +) +RGB = ( + LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + | LightColorCapability.RGB +) + + +@dataclass +class FakeHomeAssistant: + """The light entity as the device sees it over the API, and what the device sends back.""" + + client: APIClient + key: int + services: list[UserService] + waiter: LineWaiter + calls: asyncio.Queue[HomeassistantServiceCall] + states: asyncio.Queue[LightState] + + async def next_call(self) -> HomeassistantServiceCall: + return await asyncio.wait_for(self.calls.get(), timeout=5.0) + + async def next_state(self, matches: Callable[[LightState], bool]) -> LightState: + async with asyncio.timeout(5.0): + while not matches(state := await self.states.get()): + pass + return state + + def report(self, attribute: str | None, value: str) -> None: + self.client.send_home_assistant_state(ENTITY, attribute, value) + + +@asynccontextmanager +async def light_entity( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + modes: str, +) -> AsyncIterator[FakeHomeAssistant]: + """Boots the device and answers its subscriptions with a light of the given color modes.""" + loop = asyncio.get_running_loop() + waiter = LineWaiter() + subscribed: set[tuple[str, str | None]] = set() + all_subscribed = loop.create_future() + calls: asyncio.Queue[HomeassistantServiceCall] = asyncio.Queue() + states: asyncio.Queue[LightState] = asyncio.Queue() + + def on_state_sub(entity_id: str, attribute: str | None) -> None: + subscribed.add((entity_id, attribute or None)) + if not all_subscribed.done() and subscribed >= SUBSCRIPTIONS: + all_subscribed.set_result(None) + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState): + states.put_nowait(state) + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + key = next(e.key for e in entities if e.name == "HA Light") + client.subscribe_service_calls(calls.put_nowait) + client.subscribe_states(on_state) + client.subscribe_home_assistant_states(on_state_sub) + try: + await asyncio.wait_for(all_subscribed, timeout=5.0) + except TimeoutError: + pytest.fail(f"never subscribed: {SUBSCRIPTIONS - subscribed}") + ha = FakeHomeAssistant(client, key, services, waiter, calls, states) + ha.report("supported_color_modes", modes) + ha.report("min_color_temp_kelvin", "2000") + ha.report("max_color_temp_kelvin", "6500") + await waiter.wait_for("Supported color modes retrieved", timeout=5.0) + yield ha + + +@pytest.mark.asyncio +async def test_api_homeassistant_light( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Imported attributes, echo suppression and the actions sent for local changes.""" + async with light_entity( + yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgb']" + ) as ha: + set_force_off = next(s for s in ha.services if s.name == "set_force_off") + # Normal while Home Assistant starts, so no warning + ha.report(None, "unavailable") + ha.report(None, "unknown") + # Rejected, so the color stays unknown and the warnings name the attribute + ha.report("rgb_color", "(127.5, 64.0, 0.0)") + ha.report("rgb_color", "(0, 256, 0)") + ha.report("color_temp_kelvin", "0") + await ha.waiter.wait_for( + "'color_temp_kelvin' value 0 is out of range", timeout=5.0 + ) + assert sum("Can't parse 'rgb_color'" in line for line in ha.waiter.lines) == 2 + assert not any("as state!" in line for line in ha.waiter.lines) + + # A report arrives as several attribute messages and is applied as one change + ha.report(None, "on") + ha.report("color_mode", "rgb") + ha.report("brightness", "128") + ha.report("rgb_color", "(255, 0, 0)") + state = await ha.next_state(lambda s: s.state) + assert state.color_mode == RGB + assert state.brightness == pytest.approx(128 / 255) + assert (state.red, state.green, state.blue) == (1.0, 0.0, 0.0) + # A rejected color leaves the reported one untouched + ha.report("rgb_color", "(0, 256, 0)") + ha.report("brightness", "255") + state = await ha.next_state(lambda s: s.brightness == 1.0) + assert (state.red, state.green, state.blue) == (1.0, 0.0, 0.0) + + # The attributes of other modes stay with their mode, whatever order they arrive in + ha.report("color_mode", "color_temp") + ha.report("color_temp_kelvin", "4000") + state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE) + assert state.color_temperature == 250.0 + ha.report("rgb_color", "(0, 255, 0)") + ha.report("brightness", "200") + state = await ha.next_state(lambda s: s.brightness == pytest.approx(200 / 255)) + assert state.color_mode == COLOR_TEMPERATURE + + # Local changes go to Home Assistant with its attribute names and scales; the color + # temperature it reported is not repeated + ha.client.light_command(ha.key, brightness=0.5) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert not call.data_template + ha.client.light_command(ha.key, rgb=(0.0, 0.0, 1.0)) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert call.data_template == {"rgb_color": "[0,0,255]"} + + # Home Assistant echoes the result; that is not sent back. Returning to the state it + # reported last is a local change and is sent. + ha.report(None, "off") + ha.report("brightness", "None") + await ha.next_state(lambda s: not s.state) + await ha.waiter.wait_for( + "turned off", timeout=5.0 + ) # a reported state runs the triggers too + ha.client.light_command(ha.key, state=True) + assert (await ha.next_call()).service == "light.turn_on" + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + + # Without a report of the change within report_timeout, the light goes back to the reported state + ha.report(None, "on") + await ha.next_state(lambda s: s.state) + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + await ha.next_state(lambda s: s.state) + await ha.waiter.wait_for("did not report the change back", timeout=5.0) + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # The fallback also drops the levels of the unsent change: the light keeps the brightness + # Home Assistant reported last, which a later turn_on without a brightness would send + ha.client.light_command(ha.key, state=True, brightness=0.4) + call = await ha.next_call() + assert call.data["brightness"] == "102" + state = await ha.next_state(lambda s: not s.state) + assert state.brightness == pytest.approx(200 / 255) + + # An automation changing the light while a report is applied is a local change too + await ha.client.execute_service(set_force_off, {"value": True}) + ha.report(None, "on") + assert (await ha.next_call()).service == "light.turn_off" + await ha.client.execute_service(set_force_off, {"value": False}) + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # A change that leaves Home Assistant as it is gets no report, so none is sent + ha.client.light_command(ha.key, state=False) + await ha.waiter.wait_for("already has this state", timeout=5.0) + + # A trait that arrives while a change waits for its report is not that report, and does + # not bring the old state back early either + ha.client.light_command(ha.key, state=True) + assert (await ha.next_call()).service == "light.turn_on" + await ha.next_state(lambda s: s.state) + ha.report("max_color_temp_kelvin", "6500") + await ha.next_state(lambda s: not s.state) + assert ( + sum("did not report the change back" in line for line in ha.waiter.lines) + == 3 + ) + + # A report that arrives during a flash is applied when the flash ends instead of being undone + ha.client.light_command(ha.key, state=True, flash_length=1.0) + ha.report(None, "on") + ha.report("brightness", "77") + state = await ha.next_state(lambda s: s.brightness == pytest.approx(77 / 255)) + assert state.state + await ha.waiter.wait_for("arrived during the transition", timeout=5.0) + + # A trait or a repeated report during a local transition is nothing new, so the change the + # transition ends at is still sent + ha.client.light_command(ha.key, state=False, transition_length=1.0) + ha.report("max_color_temp_kelvin", "6500") + ha.report(None, "on") + assert (await ha.next_call()).service == "light.turn_off" + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # A newer local command wins over a report held during a transition + ha.client.light_command(ha.key, state=True, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) # held before the command arrives + ha.client.light_command(ha.key, state=True, brightness=1.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data["brightness"] == "255" + ha.report(None, "on") + ha.report("brightness", "255") + await ha.next_state(lambda s: s.state and s.brightness == 1.0) + + # Also when it changes only the color, at the level the transition was heading for + ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, color_temperature=400.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == { + "entity_id": ENTITY, + "brightness": "128", + "color_temp_kelvin": "2500", + } + ha.report("brightness", "128") + ha.report("color_temp_kelvin", "2500") + await ha.next_state(lambda s: s.color_temperature == 400.0) + + # Or when it is a transition itself, replacing the one the report was held for + ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, color_temperature=250.0, transition_length=1.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == { + "entity_id": ENTITY, + "brightness": "255", + "color_temp_kelvin": "4000", + } + ha.report("brightness", "255") + ha.report("color_temp_kelvin", "4000") + await ha.next_state(lambda s: s.color_temperature == 250.0) + + # Or a transition back to where the one the report was held for started + ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0) + ha.report("brightness", "50") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data["brightness"] == "255" + ha.report("brightness", "255") + await ha.next_state(lambda s: s.brightness == 1.0) + + # And when it goes back to the state the transition started from + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + ha.client.light_command(ha.key, state=True, transition_length=1.0) + ha.report(None, "on") + ha.report("brightness", "60") + await asyncio.sleep(0.1) + ha.client.light_command(ha.key, state=False) + assert (await ha.next_call()).service == "light.turn_off" + ha.report(None, "off") + await ha.next_state(lambda s: not s.state) + + # A flash ends where it began, which Home Assistant has, so nothing is sent and the user is told + ha.client.light_command(ha.key, state=True, flash_length=0.5) + await ha.waiter.wait_for("nothing to send (a flash never is)", timeout=5.0) + + assert ha.calls.empty() + + +@pytest.mark.asyncio +@pytest.mark.shared_yaml("api_homeassistant_light") +async def test_api_homeassistant_light_rgbww( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A cold/warm white light keeps its white balance: the color goes only when it changes.""" + async with light_entity( + yaml_config, run_compiled, api_client_connected, "['color_temp', 'rgbww']" + ) as ha: + # Home Assistant reports rgb_color in every color mode, so the light is an RGB one here + ha.report(None, "on") + ha.report("color_mode", "rgbww") + ha.report("brightness", "200") + ha.report("rgb_color", "(255, 180, 120)") + state = await ha.next_state(lambda s: s.state) + assert state.color_mode == RGB + assert state.brightness == pytest.approx(200 / 255) + assert (state.red, state.green, state.blue) == pytest.approx( + (1.0, 180 / 255, 120 / 255) + ) + + # A level change sends the level alone; Home Assistant derived the color it reports from + # the white balance and would derive a different balance from the color sent back + ha.client.light_command(ha.key, brightness=0.5) + call = await ha.next_call() + assert call.service == "light.turn_on" + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert not call.data_template + ha.report("brightness", "128") + await ha.next_state(lambda s: s.brightness == pytest.approx(128 / 255)) + + # A new color goes along + ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0)) + call = await ha.next_call() + assert call.data == {"entity_id": ENTITY, "brightness": "128"} + assert call.data_template == {"rgb_color": "[0,255,0]"} + ha.report("rgb_color", "(0, 255, 0)") + await ha.next_state(lambda s: s.green == 1.0 and s.red == 0.0) + + # Switching to color temperature sends it, and the level alone once there + ha.client.light_command(ha.key, color_temperature=250.0) + call = await ha.next_call() + assert call.data == { + "entity_id": ENTITY, + "brightness": "128", + "color_temp_kelvin": "4000", + } + assert not call.data_template + ha.report("color_mode", "color_temp") + ha.report("color_temp_kelvin", "4000") + state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE) + assert state.color_temperature == 250.0 + ha.client.light_command(ha.key, brightness=1.0) + call = await ha.next_call() + assert call.data == {"entity_id": ENTITY, "brightness": "255"} + ha.report("brightness", "255") + await ha.next_state(lambda s: s.brightness == 1.0) + + # Back to the color Home Assistant still reports: the mode changed, so the color is sent + ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0)) + call = await ha.next_call() + assert call.data == {"entity_id": ENTITY, "brightness": "255"} + assert call.data_template == {"rgb_color": "[0,255,0]"} + ha.report("color_mode", "rgbww") + await ha.next_state(lambda s: s.color_mode == RGB) + + assert ha.calls.empty() From 3e68e0f5e161ed7575b4253e91b8d8e80c600cd2 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:40:16 +1100 Subject: [PATCH 116/179] [epaper_spi] Add the UC8151D controller and the Waveshare 2.13" B V3 (#21022) --- .../epaper_spi/epaper_waveshare_bwr.cpp | 9 ++- .../epaper_spi/epaper_waveshare_bwr.h | 8 +-- .../components/epaper_spi/models/uc8151.py | 60 +++++++++++++++++++ .../epaper_spi/models/waveshare_bwr.py | 14 +++-- tests/component_tests/epaper_spi/test_init.py | 4 +- .../epaper_spi/test.esp32-s3-idf.yaml | 22 +++++++ 6 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 esphome/components/epaper_spi/models/uc8151.py diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp index 004597b72bd..eecb1882613 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp @@ -20,7 +20,7 @@ static BwrState color_to_bwr(Color color) { return BwrState::BWR_BLACK; } -// UC8179 3-color display buffer layout: +// 3-color display buffer layout (UC8179 and UC8151D): // - 1 bit per pixel, 8 pixels per byte // - Buffer first half: Black/White plane (1=black, 0=white) // - Buffer second half: Red plane (1=red, 0=white) @@ -79,7 +79,7 @@ bool HOT EPaperWaveshareBWR::transfer_data() { uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; // Phase 1: send Black/White plane (first half) via command 0x10 (DTM1) - // UC8179 DTM1 (0x10): inverted to get 0=black, 1=white + // DTM1 (0x10): inverted to get 0=black, 1=white if (this->current_data_index_ < half_buffer) { if (this->current_data_index_ == 0) { this->command(0x10); // DATA START TRANSMISSION 1 (black channel) @@ -101,7 +101,7 @@ bool HOT EPaperWaveshareBWR::transfer_data() { } // Phase 2: send Red plane (second half) via command 0x13 (DTM2) - // UC8179 DTM2 (0x13): 1=red, 0=white + // DTM2 (0x13): 1=red, 0=white; on the UC8151D this relies on the 0x50 value in uc8151.py if (this->current_data_index_ < buffer_length) { if (this->current_data_index_ == half_buffer) { this->command(0x13); // DATA START TRANSMISSION 2 (red channel) @@ -127,8 +127,7 @@ bool HOT EPaperWaveshareBWR::transfer_data() { } void EPaperWaveshareBWR::power_on() { - this->cmd_data(0x01, {0x07, 0x17, 0x3F, 0x3F}); // POWER SETTING - this->command(0x04); // POWER ON + this->command(0x04); // POWER ON } void EPaperWaveshareBWR::refresh_screen(bool /*partial*/) { diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.h b/esphome/components/epaper_spi/epaper_waveshare_bwr.h index d34f05cbb2b..f5cbb5fde47 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_bwr.h +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.h @@ -5,8 +5,8 @@ namespace esphome::epaper_spi { /** - * Waveshare 3-color e-paper displays (UC8179 controller). - * Supports: 7.5" V2 BWR (EDP_7in5b_V2), 800x480 pixels. + * Waveshare 3-color e-paper displays (UC8179 and UC8151D controllers). + * Supports: 7.5" V2 BWR (EPD_7in5b_V2), 800x480 pixels; 2.13" B V3 (EPD_2in13b_V3), 104x212 pixels. * * Color scheme: Black, White, Red (BWR) * Buffer layout: 1 bit per pixel, separate planes @@ -14,8 +14,8 @@ namespace esphome::epaper_spi { * - Buffer second half: Red plane (1=red, 0=no red) * - Total buffer: width * height / 4 bytes (2 * width * height / 8) * - * The init sequence (INITIALISE state) sends panel configuration only. - * Power-on (0x01 + 0x04) is sent in the POWER_ON state after data transfer; + * The init sequence (INITIALISE state) sends the power and panel configuration. + * Power-on (0x04) is sent in the POWER_ON state after data transfer; * the state machine then busy-waits before triggering REFRESH_SCREEN (0x12). */ class EPaperWaveshareBWR : public EPaperBase { diff --git a/esphome/components/epaper_spi/models/uc8151.py b/esphome/components/epaper_spi/models/uc8151.py new file mode 100644 index 00000000000..49d0d6818b4 --- /dev/null +++ b/esphome/components/epaper_spi/models/uc8151.py @@ -0,0 +1,60 @@ +"""Black/white/red e-paper displays using the UltraChip UC8151D controller. + +Supported models: +- uc8151: the controller on its own; dimensions and pins must be configured +- waveshare-2.13in-bv3: 104x212 pixels (2.13" B V3, EPD_2in13b_V3) + +The busy pin of these displays is low while busy, so configure it with ``inverted: true``. +""" + +from typing import Any + +import esphome.config_validation as cv +from esphome.const import CONF_DIMENSIONS +from esphome.types import ConfigType + +from . import EpaperModel + +MAX_SOURCES = 160 +MAX_GATES = 296 + + +class UC8151(EpaperModel): + """EpaperModel class for black/white/red displays using the UC8151D controller.""" + + def __init__(self, name: str, **defaults: Any) -> None: + super().__init__(name, "EPaperWaveshareBWR", **defaults) + + def validate_config(self, config: ConfigType) -> ConfigType: + width, height = self.get_dimensions(config) + if width > MAX_SOURCES or height > MAX_GATES: + raise cv.Invalid( + f"{self.name} supports at most {MAX_SOURCES}x{MAX_GATES} pixels (width x height)", + path=[CONF_DIMENSIONS], + ) + return config + + def get_init_sequence(self, config: dict) -> tuple: + width, height = self.get_dimensions(config) + return ( + # PANEL SETTING (black/white/red mode, LUT from OTP) + (0x00, 0x0F, 0x89), + # RESOLUTION SETTING: sources, then gates + (0x61, width, height >> 8, height & 0xFF), + # VCOM AND DATA INTERVAL SETTING; 0x57, not Waveshare's 0x77, so that 1 in DTM2 is red + (0x50, 0x57), + ) + + +uc8151 = UC8151( + "uc8151", + data_rate="10MHz", + minimum_update_interval="15s", + width_multiple=8, +) + +uc8151.extend( + "waveshare-2.13in-bv3", + width=104, + height=212, +) diff --git a/esphome/components/epaper_spi/models/waveshare_bwr.py b/esphome/components/epaper_spi/models/waveshare_bwr.py index e124ea7083b..18c673fbeee 100644 --- a/esphome/components/epaper_spi/models/waveshare_bwr.py +++ b/esphome/components/epaper_spi/models/waveshare_bwr.py @@ -1,10 +1,10 @@ """Waveshare Black/White/Red e-paper displays using UC8179 controller. Supported models: -- waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EDP_7in5b_V2) +- waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EPD_7in5b_V2) -These displays use the UC8179 controller. Panel configuration is sent during -the INITIALISE state. Power-on is handled in the POWER_ON state, after data +These displays use the UC8179 controller. Power and panel configuration are sent +during the INITIALISE state. Power-on is handled in the POWER_ON state, after data transfer, so the state machine's built-in busy wait covers the power-on delay. """ @@ -20,8 +20,8 @@ class WaveshareBWR(EpaperModel): def get_init_sequence(self, config): """Generate initialization sequence for UC8179 BWR displays. - Panel configuration only — power-on is handled separately in power_on() - after data transfer, with the state machine busy-waiting before refresh. + Power and panel configuration only — power-on is handled separately in + power_on() after data transfer, with the state machine busy-waiting before refresh. """ width, height = self.get_dimensions(config) return ( @@ -43,10 +43,12 @@ class WaveshareBWR(EpaperModel): (0x60, 0x22), # RESOLUTION GATE SETTING (0x65, 0x00, 0x00, 0x00, 0x00), + # POWER SETTING + (0x01, 0x07, 0x17, 0x3F, 0x3F), ) -# Model: Waveshare 7.5" V2 BWR (EDP_7in5b_V2) — 800x480, UC8179 controller +# Model: Waveshare 7.5" V2 BWR (EPD_7in5b_V2) — 800x480, UC8179 controller WaveshareBWR( "waveshare-7.5in-bv2-bwr", width=800, diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 644a505bf82..5e9cc6d703a 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -143,7 +143,7 @@ def test_all_predefined_models( # Add dimensions if not provided by model if not model.get_default(CONF_WIDTH): - config[CONF_DIMENSIONS] = {CONF_HEIGHT: 240, CONF_WIDTH: 320} + config[CONF_DIMENSIONS] = {CONF_HEIGHT: 120, CONF_WIDTH: 120} # Add init sequence if model doesn't provide one if model.initsequence is None: @@ -199,7 +199,7 @@ def test_individual_models( config[CONF_DC_PIN] = 21 if not model.get_default(CONF_WIDTH): - config[CONF_DIMENSIONS] = {CONF_HEIGHT: 240, CONF_WIDTH: 320} + config[CONF_DIMENSIONS] = {CONF_HEIGHT: 120, CONF_WIDTH: 120} if model.initsequence is None: config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]] diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 144c60dd2ff..d0d5f5a6aab 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -285,6 +285,28 @@ display: it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Waveshare 2.13" V3 B series 3-color e-paper (104x212, BWR, UC8151D) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-bv3 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Waveshare 2.66" B 3-color e-paper (152x296, BWR, SSD1680) - platform: epaper_spi spi_id: spi_bus From 218e696654aba4c7a08b5257bd00cf76d9c05282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Mart=C3=ADn?= Date: Sun, 18 Oct 2026 22:40:55 +0200 Subject: [PATCH 117/179] [globals] Add RTC memory support (#13954) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> 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: J. Nick Koston --- esphome/components/globals/__init__.py | 26 ++++- .../components/globals/globals_component.h | 29 ++++- .../config/globals_esp8266_storage_test.yaml | 13 +++ .../globals/config/globals_esp8266_test.yaml | 12 ++ .../globals/config/globals_storage_test.yaml | 17 +++ .../globals/config/globals_test.yaml | 4 + tests/component_tests/globals/test_storage.py | 103 ++++++++++++++++++ tests/components/globals/storage.yaml | 16 +++ tests/components/globals/test.esp32-idf.yaml | 4 +- .../components/globals/test.esp8266-ard.yaml | 4 +- tests/components/globals/test.rp2040-ard.yaml | 3 +- 11 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/globals/config/globals_esp8266_storage_test.yaml create mode 100644 tests/component_tests/globals/config/globals_esp8266_test.yaml create mode 100644 tests/component_tests/globals/config/globals_storage_test.yaml create mode 100644 tests/component_tests/globals/test_storage.py create mode 100644 tests/components/globals/storage.yaml diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index a35e2280455..c699c933ba1 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -1,10 +1,11 @@ import hashlib -from esphome import automation, codegen as cg, config_validation as cv +from esphome import automation, codegen as cg, config_validation as cv, preferences from esphome.const import ( CONF_ID, CONF_INITIAL_VALUE, CONF_RESTORE_VALUE, + CONF_STORAGE, CONF_TYPE, CONF_VALUE, ) @@ -21,6 +22,7 @@ RestoringGlobalsComponent = globals_ns.class_( RestoringGlobalStringComponent = globals_ns.class_( "RestoringGlobalStringComponent", cg.PollingComponent ) +Storage = globals_ns.enum("Storage", is_class=True) CONF_MAX_RESTORE_DATA_LENGTH = "max_restore_data_length" @@ -37,6 +39,9 @@ _NON_RESTORING_SCHEMA = cv.Schema( { **_BASE_SCHEMA, cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean, + cv.Optional(CONF_STORAGE): cv.invalid( + f"'{CONF_STORAGE}' needs '{CONF_RESTORE_VALUE}: true'" + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -45,6 +50,9 @@ _RESTORING_SCHEMA = cv.Schema( { **_BASE_SCHEMA, cv.Optional(CONF_RESTORE_VALUE, default=True): cv.boolean, + # No default: when omitted the global keeps the platform's own default backend, + # which on ESP8266 follows restore_from_flash + cv.Optional(CONF_STORAGE): preferences.validate_storage, } ).extend(cv.polling_component_schema("1s")) @@ -68,13 +76,17 @@ async def to_code(config: ConfigType) -> None: # Special casing the strings to their own class with a different save/restore mechanism if str(type_) == "std::string" and restore: - template_args = cg.TemplateArguments( - type_, config.get(CONF_MAX_RESTORE_DATA_LENGTH, 63) + 1 - ) + args = [type_, config.get(CONF_MAX_RESTORE_DATA_LENGTH, 63) + 1] type = RestoringGlobalStringComponent else: - template_args = cg.TemplateArguments(type_) + args = [type_] type = RestoringGlobalsComponent if restore else GlobalsComponent + in_flash = None + if (storage := config.get(CONF_STORAGE)) is not None: + # is_in_flash() also requests the RTC backend when rtc is chosen + in_flash = preferences.is_in_flash(storage) + args.append(Storage.STORAGE_FLASH if in_flash else Storage.STORAGE_RTC) + template_args = cg.TemplateArguments(*args) res_type = type.template(template_args) initial_value = None @@ -91,6 +103,10 @@ async def to_code(config: ConfigType) -> None: value = value.encode() hash_ = int(hashlib.md5(value).hexdigest()[:8], 16) cg.add(glob.set_name_hash(hash_)) + if type is RestoringGlobalStringComponent: + # The ESP8266 adds up every saved value to check that they fit its store; only the + # string's size is known here + preferences.reserve_preferences(1, args[1], in_flash) # A lambda returns the global's own type so `return 42;` reduces to a cast; the value diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index b7acb065695..1f9503c2c3c 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -25,7 +25,11 @@ template class GlobalsComponent final : public Component { T value_{}; }; -template class RestoringGlobalsComponent : public PollingComponent { +// Where a restoring global saves its value; STORAGE_PLATFORM keeps the platform default +enum class Storage : uint8_t { STORAGE_PLATFORM, STORAGE_FLASH, STORAGE_RTC }; + +template +class RestoringGlobalsComponent : public PollingComponent { public: using value_type = T; explicit RestoringGlobalsComponent() : PollingComponent(1000) {} @@ -39,7 +43,12 @@ template class RestoringGlobalsComponent : public PollingComponent { T &value() { return this->value_; } void setup() override { - this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + if constexpr (STORAGE == Storage::STORAGE_PLATFORM) { + this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + } else { + this->rtc_ = + global_preferences->make_preference(1944399030U ^ this->name_hash_, STORAGE == Storage::STORAGE_FLASH); + } this->rtc_.load(&this->value_); memcpy(&this->last_checked_value_, &this->value_, sizeof(T)); } @@ -68,7 +77,8 @@ template class RestoringGlobalsComponent : public PollingComponent { }; // Use with string or subclasses of strings -template class RestoringGlobalStringComponent : public PollingComponent { +template +class RestoringGlobalStringComponent : public PollingComponent { public: using value_type = T; explicit RestoringGlobalStringComponent() : PollingComponent(1000) {} @@ -83,7 +93,12 @@ template class RestoringGlobalStringComponent : public P void setup() override { char temp[SZ]; - this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + if constexpr (STORAGE == Storage::STORAGE_PLATFORM) { + this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); + } else { + this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_, + STORAGE == Storage::STORAGE_FLASH); + } bool hasdata = this->rtc_.load(&temp); if (hasdata) { this->value_.assign(temp + 1, static_cast(temp[0])); @@ -130,7 +145,9 @@ template class RestoringGlobalStringComponent : public P }; template T &id(GlobalsComponent *value) { return value->value(); } -template T &id(RestoringGlobalsComponent *value) { return value->value(); } -template T &id(RestoringGlobalStringComponent *value) { return value->value(); } +template T &id(RestoringGlobalsComponent *value) { return value->value(); } +template T &id(RestoringGlobalStringComponent *value) { + return value->value(); +} } // namespace esphome::globals diff --git a/tests/component_tests/globals/config/globals_esp8266_storage_test.yaml b/tests/component_tests/globals/config/globals_esp8266_storage_test.yaml new file mode 100644 index 00000000000..b921899b3c7 --- /dev/null +++ b/tests/component_tests/globals/config/globals_esp8266_storage_test.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +globals: + - id: rtc_string + type: std::string + restore_value: true + storage: rtc + max_restore_data_length: 15 + initial_value: '"boot"' diff --git a/tests/component_tests/globals/config/globals_esp8266_test.yaml b/tests/component_tests/globals/config/globals_esp8266_test.yaml new file mode 100644 index 00000000000..f321ecf84f1 --- /dev/null +++ b/tests/component_tests/globals/config/globals_esp8266_test.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp8266: + board: d1_mini + restore_from_flash: true + +globals: + - id: my_restoring_int + type: int + restore_value: true + initial_value: "8" diff --git a/tests/component_tests/globals/config/globals_storage_test.yaml b/tests/component_tests/globals/config/globals_storage_test.yaml new file mode 100644 index 00000000000..d224408921f --- /dev/null +++ b/tests/component_tests/globals/config/globals_storage_test.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + +globals: + - id: rtc_int + type: int + restore_value: true + storage: rtc + initial_value: "7" + - id: flash_string + type: std::string + restore_value: true + storage: flash + max_restore_data_length: 15 diff --git a/tests/component_tests/globals/config/globals_test.yaml b/tests/component_tests/globals/config/globals_test.yaml index 1d1a9edaa62..a05ea84fd9b 100644 --- a/tests/component_tests/globals/config/globals_test.yaml +++ b/tests/component_tests/globals/config/globals_test.yaml @@ -14,3 +14,7 @@ globals: - id: my_global_bool type: bool initial_value: "true" + - id: my_restoring_int + type: int + restore_value: true + initial_value: "8" diff --git a/tests/component_tests/globals/test_storage.py b/tests/component_tests/globals/test_storage.py new file mode 100644 index 00000000000..6f1ea3aff48 --- /dev/null +++ b/tests/component_tests/globals/test_storage.py @@ -0,0 +1,103 @@ +"""Tests for the globals storage option.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components import globals as globals_component +from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32C2 +from esphome.components.esp8266.const import ( + KEY_ESP8266, + KEY_PREFERENCES, + preference_words, +) +from esphome.const import ( + CONF_ID, + CONF_RESTORE_VALUE, + CONF_STORAGE, + CONF_TYPE, + PlatformFramework, +) +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(**extra: object) -> ConfigType: + return globals_component.CONFIG_SCHEMA( + {CONF_ID: "my_global", CONF_TYPE: "int", **extra} + ) + + +def _define_names() -> set[str]: + return {define.name for define in CORE.defines} + + +@pytest.mark.parametrize( + ("platform_framework", "platform_data"), + [ + (PlatformFramework.ESP32_IDF, {KEY_VARIANT: VARIANT_ESP32C2}), + (PlatformFramework.RP2040_ARDUINO, None), + ], +) +def test_rtc_rejected_without_rtc_memory( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + platform_data: ConfigType | None, +) -> None: + set_core_config(platform_framework, platform_data=platform_data) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate(**{CONF_RESTORE_VALUE: True, CONF_STORAGE: "rtc"}) + + +@pytest.mark.parametrize("extra", [{}, {CONF_RESTORE_VALUE: False}]) +def test_storage_rejected_without_restore_value( + set_core_config: SetCoreConfigCallable, extra: ConfigType +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32} + ) + with pytest.raises(cv.Invalid, match="'storage' needs 'restore_value: true'"): + _validate(**extra, **{CONF_STORAGE: "rtc"}) + + +def test_storage_generates_template_argument( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("globals_storage_test.yaml")) + assert ( + "globals::RestoringGlobalsComponent" + in main_cpp + ) + assert ( + "globals::RestoringGlobalStringComponent" in main_cpp + ) + assert "USE_ESP32_RTC_PREFERENCES" in _define_names() + + +# On the ESP8266 the omitted form must stay the two-argument call so restore_from_flash keeps deciding +@pytest.mark.parametrize("config", ["globals_test.yaml", "globals_esp8266_test.yaml"]) +def test_omitted_storage_keeps_platform_default( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config: str, +) -> None: + main_cpp = generate_main(component_config_path(config)) + assert ( + "new(my_restoring_int) globals::RestoringGlobalsComponent(8)" in main_cpp + ) + assert "globals::Storage" not in main_cpp + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + + +def test_restored_string_reserves_esp8266_store( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("globals_esp8266_storage_test.yaml")) + # 15 characters plus the length byte, in RTC memory + assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(preference_words(16), False)] diff --git a/tests/components/globals/storage.yaml b/tests/components/globals/storage.yaml new file mode 100644 index 00000000000..40ca7f3e089 --- /dev/null +++ b/tests/components/globals/storage.yaml @@ -0,0 +1,16 @@ +globals: + - id: glob_rtc_int + type: int + restore_value: true + storage: rtc + initial_value: "0" + - id: glob_rtc_string + type: std::string + restore_value: true + storage: rtc + max_restore_data_length: 15 + - id: glob_flash_float + type: float + restore_value: true + storage: flash + initial_value: "0.0f" diff --git a/tests/components/globals/test.esp32-idf.yaml b/tests/components/globals/test.esp32-idf.yaml index dade44d145b..ae95448a794 100644 --- a/tests/components/globals/test.esp32-idf.yaml +++ b/tests/components/globals/test.esp32-idf.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + globals: !include common.yaml + storage: !include storage.yaml diff --git a/tests/components/globals/test.esp8266-ard.yaml b/tests/components/globals/test.esp8266-ard.yaml index dade44d145b..ae95448a794 100644 --- a/tests/components/globals/test.esp8266-ard.yaml +++ b/tests/components/globals/test.esp8266-ard.yaml @@ -1 +1,3 @@ -<<: !include common.yaml +packages: + globals: !include common.yaml + storage: !include storage.yaml diff --git a/tests/components/globals/test.rp2040-ard.yaml b/tests/components/globals/test.rp2040-ard.yaml index dade44d145b..d27f5d02d97 100644 --- a/tests/components/globals/test.rp2040-ard.yaml +++ b/tests/components/globals/test.rp2040-ard.yaml @@ -1 +1,2 @@ -<<: !include common.yaml +packages: + globals: !include common.yaml From 603f16623f8aafb750ab0d9500dd22ffbf1158f5 Mon Sep 17 00:00:00 2001 From: ab-tools Date: Sun, 18 Oct 2026 22:44:05 +0200 Subject: [PATCH 118/179] [usb_host] Size the FIFO split on ESP32-S2/S3 from periodic packet requests (#21081) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/usb_host/__init__.py | 92 +++++++++- .../usb_host/usb_host_component.cpp | 6 + esphome/core/defines.h | 4 + tests/component_tests/usb_host/__init__.py | 0 tests/component_tests/usb_host/test_fifo.py | 172 ++++++++++++++++++ 5 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/usb_host/__init__.py create mode 100644 tests/component_tests/usb_host/test_fifo.py diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 317cec8236b..1f12112eea7 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -11,11 +11,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32S31, add_idf_component, add_idf_sdkconfig_option, + get_esp32_variant, idf_version, only_on_variant, ) import esphome.config_validation as cv -from esphome.const import CONF_DEVICES, CONF_ID +from esphome.const import CONF_DEVICES, CONF_FRAMEWORK, CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.cpp_types import Component @@ -117,12 +118,101 @@ def _final_validate(config: ConfigType) -> ConfigType: if redundant else "narrow its filter so it no longer matches that device", ) + _reconcile_fifo() return config FINAL_VALIDATE_SCHEMA = _final_validate +# S2/S3 full-speed controller: 200 usable 4-byte FIFO lines (EPINFO takes the rest of 256) shared by RX, +# non-periodic TX and periodic TX +FIFO_VARIANTS = (VARIANT_ESP32S2, VARIANT_ESP32S3) +FIFO_LINES = 200 +FIFO_LINE_BYTES = 4 +FIFO_RX_STATUS_LINES = 2 +# (RX, NPTX, PTX) lines ESP-IDF picks for each CONFIG_USB_HOST_HW_BUFFER_BIAS_* option +FIFO_BIAS_SPLITS = { + "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN": (152, 16, 32), + "CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT": (34, 16, 150), +} +FIFO_BALANCED_SPLIT = (104, 64, 32) +# Custom split: 64-byte control/bulk, at least 128 bytes each way +FIFO_NPTX_LINES = 16 +FIFO_PTX_MIN_LINES = 32 +FIFO_RX_MIN_LINES = 32 + FIFO_RX_STATUS_LINES +# usb_host_config_t::fifo_settings_custom; USB host is not reliable on older releases +FIFO_CUSTOM_MIN_IDF = cv.Version(5, 5, 0) +KEY_PERIODIC_FIFO = "periodic_fifo" + + +def request_periodic_fifo(in_bytes: int = 0, out_bytes: int = 0) -> None: + """Request FIFO space for the largest periodic IN/OUT packet. Call from CONFIG_SCHEMA validation.""" + requested = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_PERIODIC_FIFO, [0, 0]) + requested[0] = max(requested[0], in_bytes) + requested[1] = max(requested[1], out_bytes) + + +def _fifo_lines(size: int) -> int: + return -(-size // FIFO_LINE_BYTES) + + +def _bias_option() -> str | None: + framework = fv.full_config.get().get("esp32", {}).get(CONF_FRAMEWORK, {}) + options = framework.get("sdkconfig_options", {}) + return next((o for o in FIFO_BIAS_SPLITS if options.get(o) in ("y", True)), None) + + +def fifo_split() -> tuple[int, int, int] | None: + """(RX, NPTX, PTX) lines for the requests, or None when the default split fits them.""" + requested = CORE.data.get(DOMAIN, {}).get(KEY_PERIODIC_FIFO) + if requested is None or get_esp32_variant() not in FIFO_VARIANTS: + return None + in_bytes, out_bytes = requested + rx = _fifo_lines(in_bytes) + FIFO_RX_STATUS_LINES + ptx = _fifo_lines(out_bytes) + bias = _bias_option() + default_rx, _, default_ptx = FIFO_BIAS_SPLITS[bias] if bias else FIFO_BALANCED_SPLIT + if rx <= default_rx and ptx <= default_ptx: + return None + if bias: + raise cv.Invalid( + f"The requested USB packets don't fit the FIFO split {bias} selects; remove it from " + "sdkconfig_options so usb_host can size the FIFO" + ) + rx = max(FIFO_RX_MIN_LINES, rx) + ptx = max(FIFO_PTX_MIN_LINES, ptx) + spare = FIFO_LINES - FIFO_NPTX_LINES - rx - ptx + if spare < 0: + raise cv.Invalid( + f"The requested USB packets need {(rx + ptx + FIFO_NPTX_LINES) * FIFO_LINE_BYTES} bytes of USB FIFO, " + f"but the {get_esp32_variant()} has {FIFO_LINES * FIFO_LINE_BYTES} usable" + ) + # Devices may declare a larger max packet size, so the requested directions share the rest + if in_bytes and out_bytes: + rx += spare // 2 + ptx += spare - spare // 2 + elif out_bytes: + ptx += spare + else: + rx += spare + return rx, FIFO_NPTX_LINES, ptx + + +def _reconcile_fifo() -> None: + if (split := fifo_split()) is None: + return + if idf_version() < FIFO_CUSTOM_MIN_IDF: + raise cv.Invalid( + f"Sizing the USB FIFO on the {get_esp32_variant()} needs ESP-IDF {FIFO_CUSTOM_MIN_IDF} or later" + ) + rx, nptx, ptx = split + cg.add_define("USE_USB_HOST_FIFO_CONFIG") + cg.add_define("USB_HOST_FIFO_RX_LINES", rx) + cg.add_define("USB_HOST_FIFO_NPTX_LINES", nptx) + cg.add_define("USB_HOST_FIFO_PTX_LINES", ptx) + + def _set_max_packet_size(config: dict) -> dict: CORE.data.setdefault(DOMAIN, {})[CONF_MAX_PACKET_SIZE] = config[ CONF_MAX_PACKET_SIZE diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 102311348aa..c2a87eeab70 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -9,6 +9,12 @@ namespace esphome::usb_host { void USBHost::setup() { usb_host_config_t config{}; +#ifdef USE_USB_HOST_FIFO_CONFIG + // Sized in Python from component requests + config.fifo_settings_custom.rx_fifo_lines = USB_HOST_FIFO_RX_LINES; + config.fifo_settings_custom.nptx_fifo_lines = USB_HOST_FIFO_NPTX_LINES; + config.fifo_settings_custom.ptx_fifo_lines = USB_HOST_FIFO_PTX_LINES; +#endif if (usb_host_install(&config) != ESP_OK) { this->status_set_error(LOG_STR("usb_host_install failed")); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ed8f7b984a0..931ccf3c789 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -542,6 +542,10 @@ #define USE_WIFI_RUNTIME_ROAMING_SUPPRESSION #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 +#define USE_USB_HOST_FIFO_CONFIG +#define USB_HOST_FIFO_RX_LINES 93 +#define USB_HOST_FIFO_NPTX_LINES 16 +#define USB_HOST_FIFO_PTX_LINES 91 #define USB_UART_OUTPUT_CHUNK_COUNT 5 #define USE_USB_UART_CH34X #define USE_USB_UART_CP210X diff --git a/tests/component_tests/usb_host/__init__.py b/tests/component_tests/usb_host/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/usb_host/test_fifo.py b/tests/component_tests/usb_host/test_fifo.py new file mode 100644 index 00000000000..8bc12a3a183 --- /dev/null +++ b/tests/component_tests/usb_host/test_fifo.py @@ -0,0 +1,172 @@ +"""Tests for the usb_host FIFO split sized from periodic packet requests.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import usb_host +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +from esphome.config import Config +from esphome.const import ( + CONF_FRAMEWORK, + KEY_IDF_VERSION, + KEY_VARIANT, + PlatformFramework, +) +from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable + +IDF_5_5 = cv.Version(5, 5, 6) + + +def _set_variant( + set_core_config: SetCoreConfigCallable, variant: str, idf: cv.Version = IDF_5_5 +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant, KEY_IDF_VERSION: idf}, + ) + + +def test_no_request_keeps_the_default_split( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + assert usb_host.fifo_split() is None + + +def test_in_and_out_requests_share_the_spare_lines( + set_core_config: SetCoreConfigCallable, +) -> None: + # 196-byte packets each way, such as 48 kHz 16-bit stereo audio: 49 + 51 lines, plus 84 spare. + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=196, out_bytes=196) + rx, nptx, ptx = usb_host.fifo_split() + assert (rx, nptx, ptx) == (93, 16, 91) + assert rx + nptx + ptx == 200 + assert ptx * 4 >= 196 + assert (rx - 2) * 4 >= 196 + + +def test_out_request_gets_the_rest(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32S2) + usb_host.request_periodic_fifo(out_bytes=196) + assert usb_host.fifo_split() == (34, 16, 150) + + +def test_in_request_gets_the_rest(set_core_config: SetCoreConfigCallable) -> None: + # 512 bytes is more than the default 408 bytes of RX. + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=512) + assert usb_host.fifo_split() == (152, 16, 32) + + +@pytest.mark.parametrize( + ("in_bytes", "out_bytes"), [(0, 0), (196, 0), (408, 128), (0, 128)] +) +def test_requests_that_fit_the_default_keep_it( + set_core_config: SetCoreConfigCallable, in_bytes: int, out_bytes: int +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=in_bytes, out_bytes=out_bytes) + assert usb_host.fifo_split() is None + + +def test_requests_that_fit_the_default_need_no_newer_idf( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3, idf=cv.Version(5, 4, 2)) + usb_host.request_periodic_fifo(in_bytes=34, out_bytes=128) + usb_host.FINAL_VALIDATE_SCHEMA({}) + assert "USE_USB_HOST_FIFO_CONFIG" not in _defines() + + +def test_requests_keep_the_largest_packet( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(out_bytes=400) + usb_host.request_periodic_fifo(in_bytes=96, out_bytes=196) + assert CORE.data[usb_host.DOMAIN][usb_host.KEY_PERIODIC_FIFO] == [96, 400] + + +def test_only_full_speed_variants_are_sized( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32P4) + usb_host.request_periodic_fifo(out_bytes=196) + assert usb_host.fifo_split() is None + + +def test_requests_that_do_not_fit_are_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(in_bytes=776, out_bytes=776) + with pytest.raises(cv.Invalid, match="bytes of USB FIFO"): + usb_host.fifo_split() + + +def _defines() -> dict[str, str]: + return {define.name: str(define.value) for define in CORE.defines} + + +def test_final_validate_emits_the_split( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3) + usb_host.request_periodic_fifo(out_bytes=196) + usb_host.FINAL_VALIDATE_SCHEMA({}) + defines = _defines() + assert "USE_USB_HOST_FIFO_CONFIG" in defines + assert ( + defines["USB_HOST_FIFO_RX_LINES"], + defines["USB_HOST_FIFO_NPTX_LINES"], + defines["USB_HOST_FIFO_PTX_LINES"], + ) == ("34", "16", "150") + + +def _set_bias(set_core_config: SetCoreConfigCallable, option: str) -> None: + full = Config() + full["esp32"] = {CONF_FRAMEWORK: {"sdkconfig_options": {option: "y"}}} + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: VARIANT_ESP32S3, KEY_IDF_VERSION: IDF_5_5}, + full_config=full, + ) + + +def test_request_that_fits_the_selected_bias_keeps_it( + set_core_config: SetCoreConfigCallable, +) -> None: + # The IN bias leaves RX 152 lines, enough for 512-byte IN packets. + _set_bias(set_core_config, "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN") + usb_host.request_periodic_fifo(in_bytes=512) + assert usb_host.fifo_split() is None + + +@pytest.mark.parametrize( + ("option", "in_bytes", "out_bytes"), + [ + # The IN bias reserves 152 RX lines, leaving no room for more than 128-byte OUT packets. + ("CONFIG_USB_HOST_HW_BUFFER_BIAS_IN", 0, 196), + # The periodic OUT bias reserves 150 PTX lines, leaving no room for more than 128-byte IN packets. + ("CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT", 196, 0), + ], +) +def test_request_conflicting_with_the_selected_bias_is_rejected( + set_core_config: SetCoreConfigCallable, option: str, in_bytes: int, out_bytes: int +) -> None: + _set_bias(set_core_config, option) + usb_host.request_periodic_fifo(in_bytes=in_bytes, out_bytes=out_bytes) + with pytest.raises(cv.Invalid, match=f"FIFO split {option} selects"): + usb_host.fifo_split() + + +@pytest.mark.parametrize("idf", [cv.Version(5, 3, 4), cv.Version(5, 4, 3)]) +def test_final_validate_needs_idf_5_5( + set_core_config: SetCoreConfigCallable, idf: cv.Version +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S3, idf=idf) + usb_host.request_periodic_fifo(out_bytes=196) + with pytest.raises(cv.Invalid, match="needs ESP-IDF 5.5.0 or later"): + usb_host.FINAL_VALIDATE_SCHEMA({}) From 512e43793b28b0e8276dcc844cb3c344e6b85e05 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 18 Oct 2026 15:47:02 -0500 Subject: [PATCH 119/179] [api] Poll the outgoing dial every loop (#21085) Co-authored-by: J. Nick Koston --- esphome/components/api/api_outgoing_connection.cpp | 10 +--------- esphome/components/api/api_outgoing_connection.h | 8 +------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index b38976e8cd5..491a9c27a4f 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -152,10 +152,7 @@ void OutgoingConnectionManager::loop(APIServer *server) { void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { if (!network::is_connected()) { - // Flips within seconds of boot; recheck fast so a deep sleep wake - // window is not spent waiting - this->schedule_wait_(now, NETWORK_RETRY_MS); - return; + return; // still WAITING with the wait elapsed, so the next loop rechecks } struct sockaddr_storage addr; socklen_t addr_len = this->target_sockaddr_(&addr); @@ -197,7 +194,6 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { } this->state_ = DialState::DIAL_STATE_CONNECTING; this->state_ts_ = now; - this->last_poll_ = now; } void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { @@ -206,10 +202,6 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { this->schedule_retry_(now); return; } - if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) { - return; - } - this->last_poll_ = now; int err = 0; switch (socket::poll_connect(*this->dial_socket_, err)) { case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING: diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index fff11522d79..fa222e29ccf 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -61,18 +61,13 @@ class OutgoingConnectionManager { static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; // A deep sleep wake window is too short to spend on the delay, so those - // builds dial out as soon as the target is gone, recheck the network on - // every loop and notice the connect completing on the loop it does + // builds dial out as soon as the target is gone #ifdef USE_DEEP_SLEEP static constexpr uint32_t BOOT_WAIT_MS = 0; static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 0; - static constexpr uint32_t NETWORK_RETRY_MS = 0; #else static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; - static constexpr uint32_t NETWORK_RETRY_MS = 500; #endif void try_dial_(APIServer *server, uint32_t now); @@ -107,7 +102,6 @@ class OutgoingConnectionManager { uint32_t backoff_{BACKOFF_MIN_MS}; uint32_t wait_{BOOT_WAIT_MS}; uint32_t state_ts_{0}; - uint32_t last_poll_{0}; // Byte-aligned types last #ifndef API_OUTGOING_CONNECTION_HOST From dff1422e3697cc558c76af1d11fc865d1e18c21c Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 13 Oct 2026 17:55:22 -0500 Subject: [PATCH 120/179] [mdns] Advertise the connected interface in the network TXT record (#20673) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/mdns/mdns_component.cpp | 53 ++++++++++++++++++- esphome/components/mdns/mdns_component.h | 22 ++++++++ esphome/components/mdns/mdns_esp32.cpp | 19 ++++++- esphome/components/mdns/mdns_rp2.cpp | 19 +++++++ .../mdns/common-dual-interface.yaml | 9 ++++ .../mdns/test-dual-interface.esp32-idf.yaml | 17 ++++++ .../mdns/test-dual-interface.rp2040-ard.yaml | 17 ++++++ 7 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/components/mdns/common-dual-interface.yaml create mode 100644 tests/components/mdns/test-dual-interface.esp32-idf.yaml create mode 100644 tests/components/mdns/test-dual-interface.rp2040-ard.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4f236153c2c..a5e1efeb08a 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -20,6 +20,10 @@ #ifdef USE_DASHBOARD_IMPORT #include "esphome/components/dashboard_import/dashboard_import.h" #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +#include "esphome/components/ethernet/ethernet_component.h" +#include "esphome/components/wifi/wifi_component.h" +#endif namespace esphome::mdns { @@ -39,6 +43,45 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); +MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet"); + +// Same order as the default route in NetworkComponent::loop() +const char *MDNSComponent::connected_network_() const { + const bool ethernet = ethernet::global_eth_component->is_connected(); + const bool wifi = wifi::global_wifi_component->is_connected(); +#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI + if (wifi) + return NETWORK_WIFI; +#endif + if (ethernet) + return NETWORK_ETHERNET; + if (wifi) + return NETWORK_WIFI; + return nullptr; +} + +void MDNSComponent::update_network_txt_() { + if (this->network_txt_ == nullptr) + return; + // With no interface connected, keep the last value instead of flapping to wifi + const char *network = this->connected_network_(); + if (network == nullptr || network == this->network_txt_) + return; + if (!this->publish_network_txt_(network)) { + // The _esphomelib service is missing, so a retry would fail too + this->network_txt_ = nullptr; + return; + } + ESP_LOGD(TAG, "Network TXT: %s", network); + this->network_txt_ = network; +#ifdef USE_MDNS_STORE_SERVICES + this->network_record_->value = MDNS_STR(network); +#endif +} +#endif + void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { #ifdef USE_MDNS_STORE_SERVICES auto &services = this->services_; @@ -160,7 +203,15 @@ void MDNSComponent::compile_records_(StaticVectorconnected_network_(); + this->network_txt_ = network != nullptr ? network : NETWORK_WIFI; + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(this->network_txt_)}); +#ifdef USE_MDNS_STORE_SERVICES + // services is services_ here, which never reallocates + this->network_record_ = &txt_records.back(); +#endif +#elif defined(USE_WIFI) MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)}); #elif defined(USE_ETHERNET) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 93d9eb1e385..f2ea7c19149 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -32,6 +32,12 @@ #define USE_MDNS_DEVICE_INFO_TXT #endif +// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface. +// OpenThread hands its services to the SRP client once, so it keeps the fixed value. +#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD) +#define USE_MDNS_DYNAMIC_NETWORK_TXT +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -150,6 +156,22 @@ class MDNSComponent final : public Component void setup_buffers_and_register_(PlatformRegisterFn platform_register); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000; + /// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr. + const char *connected_network_() const; + /// Check the connected interface and publish a changed "network" TXT value. + void update_network_txt_(); + /// Platform-specific: replace the "network" TXT value of the registered _esphomelib service. + bool publish_network_txt_(const char *network); + /// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish. + const char *network_txt_{nullptr}; +#ifdef USE_MDNS_STORE_SERVICES + /// The "network" record in services_, kept in step with network_txt_. + MDNSTXTRecord *network_record_{nullptr}; +#endif +#endif + #ifdef USE_MDNS_DYNAMIC_TXT /// Storage for runtime-generated TXT values from user lambdas /// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 48df61326e2..bd74dd6a948 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -89,7 +89,24 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr } #endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD -void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +bool MDNSComponent::publish_network_txt_(const char *network) { + // ESP-IDF copies the value and announces the changed record + esp_err_t err = mdns_service_txt_item_set("_esphomelib", "_tcp", "network", network); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to update network TXT: %s", esp_err_to_name(err)); + return false; + } + return true; +} +#endif + +void MDNSComponent::setup() { + this->setup_buffers_and_register_(register_esp32); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#endif +} void MDNSComponent::on_shutdown() { #ifndef USE_OPENTHREAD diff --git a/esphome/components/mdns/mdns_rp2.cpp b/esphome/components/mdns/mdns_rp2.cpp index 7eaac594fb5..603f3f0417c 100644 --- a/esphome/components/mdns/mdns_rp2.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -46,7 +46,26 @@ void MDNSComponent::start_polling_window_() { } #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +ESPHOME_LOG_TAG(TAG, "mdns"); + +bool MDNSComponent::publish_network_txt_(const char *network) { + // LEAmDNS replaces the value of an existing key; announce() sends the change + if (!MDNS.addServiceTxt("esphomelib", "tcp", "network", network)) { + ESP_LOGW(TAG, "Failed to update network TXT"); + return false; + } + MDNS.announce(); + this->start_polling_window_(); + return true; +} +#endif + void MDNSComponent::setup() { +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + // No-op until the first IP-up event registers the services + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#endif // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our // own IP state listener. Both WiFi and Ethernet have the same listener signature — diff --git a/tests/components/mdns/common-dual-interface.yaml b/tests/components/mdns/common-dual-interface.yaml new file mode 100644 index 00000000000..97ae545f2bc --- /dev/null +++ b/tests/components/mdns/common-dual-interface.yaml @@ -0,0 +1,9 @@ +# wifi and ethernet together: the _esphomelib "network" TXT value follows +# the connected interface, so the update path is compiled. +wifi: + ssid: MySSID + password: password1 + +api: + +mdns: diff --git a/tests/components/mdns/test-dual-interface.esp32-idf.yaml b/tests/components/mdns/test-dual-interface.esp32-idf.yaml new file mode 100644 index 00000000000..d480824494b --- /dev/null +++ b/tests/components/mdns/test-dual-interface.esp32-idf.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/mdns/test-dual-interface.rp2040-ard.yaml b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml new file mode 100644 index 00000000000..3836afb7eef --- /dev/null +++ b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + +# wifi first, so the USE_NETWORK_PRIMARY_INTERFACE_WIFI order is compiled +network: + priority: + - wifi + - ethernet From 4645d34e1a85538db5de7bc75dd6df66749fb248 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Wed, 14 Oct 2026 08:19:06 +0000 Subject: [PATCH 121/179] [web_server_idf] Keep active SSE sessions out of the LRU purge (#17383) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../web_server_idf/web_server_idf.cpp | 34 +++++++++++++++++-- .../web_server_idf/web_server_idf.h | 12 +++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index a35ad652141..d120e826726 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -955,10 +955,39 @@ void AsyncEventSourceResponse::process_close_() { } } +void AsyncEventSourceResponse::refresh_lru_() { + // httpd only advances a session's LRU counter when a request arrives, and an event stream receives one + // request ever, so the purge that frees a socket for a new client would evict the live streams first. + // The HTTPD task owns the session table, so the refresh runs there. + // A stalled stream is the socket the purge should take, and a zeroed fd has no session left to refresh. + if (this->send_failure_started_ms_ != 0 || this->fd_.load() == 0) + return; + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_lru_refresh_ms_ < LRU_REFRESH_INTERVAL_MS || + this->lru_work_queued_.load(std::memory_order_acquire)) { + return; + } + this->last_lru_refresh_ms_ = now; // a full queue also waits for the next interval + this->lru_work_queued_.store(true, std::memory_order_release); + if (httpd_queue_work(this->hd_, &AsyncEventSourceResponse::refresh_lru_work, this) != ESP_OK) { + this->lru_work_queued_.store(false, std::memory_order_release); + } +} + +void AsyncEventSourceResponse::refresh_lru_work(void *arg) { + auto *response = static_cast(arg); + const int fd = response->fd_.load(); + if (response->owns_session_(fd)) { + httpd_sess_update_lru_counter(response->hd_, fd); + } + // Release self only after the HTTPD-task callback has finished every access. + response->lru_work_queued_.store(false, std::memory_order_release); +} + void AsyncEventSourceResponse::close_session_work(void *arg) { auto *response = static_cast(arg); const int fd = response->fd_.load(); - if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) { + if (response->owns_session_(fd)) { // The HTTPD task remains the session owner. Shutting the socket down makes // its next select/recv path delete the session and invoke destroy(). shutdown(fd, SHUT_RDWR); @@ -972,7 +1001,7 @@ ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) { // httpd frees a session before closing its socket, so the fd may already be a new client's. // Treated as would-block; the stall timer ends a session that never becomes ours again. const int fd = this->fd_.load(); - if (httpd_sess_get_ctx(this->hd_, fd) != this) { + if (!this->owns_session_(fd)) { return 0; } struct msghdr msg {}; @@ -1081,6 +1110,7 @@ void AsyncEventSourceResponse::loop() { this->process_close_(); return; } + this->refresh_lru_(); drain_tail_(); process_deferred_queue_(); if (this->close_requested_) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 581ba8323b5..45128602e3a 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -329,10 +329,15 @@ class AsyncEventSourceResponse { }; void process_close_(); static void close_session_work(void *arg); + void refresh_lru_(); + static void refresh_lru_work(void *arg); + // httpd frees a session before closing its socket, so the fd may already belong to a new client + bool owns_session_(int fd) const { return fd != 0 && httpd_sess_get_ctx(this->hd_, fd) == this; } - // Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object. + // Deletable only after destroy() zeroed fd_ and no queued HTTPD work still references this object. bool safe_to_delete_() const { - return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire); + return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire) && + !this->lru_work_queued_.load(std::memory_order_acquire); } static void destroy(void *p); @@ -347,11 +352,13 @@ class AsyncEventSourceResponse { RAMUniquePtr tail_; uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress. uint32_t next_close_attempt_ms_{0}; + uint32_t last_lru_refresh_ms_{0}; uint16_t tail_cap_{0}; uint16_t tail_len_{0}; // Zero means nothing pending uint16_t tail_sent_{0}; // Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done. std::atomic close_work_queued_{false}; + std::atomic lru_work_queued_{false}; // Main-loop only; the HTTPD task never reads or writes these flags. bool close_requested_{false}; bool close_retry_warning_logged_{false}; @@ -373,6 +380,7 @@ class AsyncEventSourceResponse { static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000; static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250; static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000; + static constexpr uint32_t LRU_REFRESH_INTERVAL_MS = 1000; }; using AsyncEventSourceClient = AsyncEventSourceResponse; From cd6c0dd402bde0022f011fe7ed93ee6497ecbffc 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 122/179] 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 a54897ef36d..486612817c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.4.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 ba575600dc53f21f09f306a1f8c690cb2173c933 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 11:13:32 -1000 Subject: [PATCH 123/179] [mqtt] Match subscription topics in a loop instead of recursing per character (#20767) --- esphome/components/mqtt/mqtt_client.cpp | 58 +----------- esphome/components/mqtt/mqtt_topic_match.h | 28 ++++++ tests/components/mqtt/test_topic_match.cpp | 103 +++++++++++++++++++++ 3 files changed, 132 insertions(+), 57 deletions(-) create mode 100644 esphome/components/mqtt/mqtt_topic_match.h create mode 100644 tests/components/mqtt/test_topic_match.cpp diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 2ecab47904a..4143f12d127 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -16,6 +16,7 @@ #include "lwip/dns.h" #include "lwip/err.h" #include "mqtt_component.h" +#include "mqtt_topic_match.h" #ifdef USE_API #include "esphome/components/api/api_server.h" @@ -581,63 +582,6 @@ void MQTTClientComponent::disable() { this->on_shutdown(); } -/** Check if the message topic matches the given subscription topic - * - * INFO: MQTT spec mandates that topics must not be empty and must be valid NULL-terminated UTF-8 strings. - * - * @param message The message topic that was received from the MQTT server. Note: this must not contain - * wildcard characters as mandated by the MQTT spec. - * @param subscription The subscription topic we are matching against. - * @param is_normal Is this a "normal" topic - Does the message topic not begin with a "$". - * @param past_separator Are we past the first '/' topic separator. - * @return true if the subscription topic matches the message topic, false otherwise. - */ -static bool topic_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { - // Reached end of both strings at the same time, this means we have a successful match - if (*message == '\0' && *subscription == '\0') - return true; - - // Either the message or the subscribe are at the end. This means they don't match. - if (*message == '\0' || *subscription == '\0') - return false; - - bool do_wildcards = is_normal || past_separator; - - if (*subscription == '+' && do_wildcards) { - // single level wildcard - // consume + from subscription - subscription++; - // consume everything from message until '/' found or end of string - while (*message != '\0' && *message != '/') { - message++; - } - // after this, both pointers will point to a '/' or to the end of the string - - return topic_match(message, subscription, is_normal, true); - } - - if (*subscription == '#' && do_wildcards) { - // multilevel wildcard - MQTT mandates that this must be at end of subscribe topic - return true; - } - - // this handles '/' and normal characters at the same time. - if (*message != *subscription) - return false; - - past_separator = past_separator || *subscription == '/'; - - // consume characters - subscription++; - message++; - - return topic_match(message, subscription, is_normal, past_separator); -} - -static bool topic_match(const char *message, const char *subscription) { - return topic_match(message, subscription, *message != '\0' && *message != '$', false); -} - void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) { #ifdef USE_ESP8266 // IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266. diff --git a/esphome/components/mqtt/mqtt_topic_match.h b/esphome/components/mqtt/mqtt_topic_match.h new file mode 100644 index 00000000000..84172bc805e --- /dev/null +++ b/esphome/components/mqtt/mqtt_topic_match.h @@ -0,0 +1,28 @@ +#pragma once + +namespace esphome::mqtt { + +/// True if the message topic matches the subscription, with + and # wildcards. +/// A loop on purpose: recursing per character overflowed the ESP32 loop task's stack on long topics. +inline bool topic_match(const char *message, const char *subscription) { + // First-level wildcards do not match topics that begin with '$' + bool wildcards = *message != '$'; + while (*message != '\0' && *subscription != '\0') { + if (wildcards && *subscription == '#') + return true; + if (wildcards && *subscription == '+') { + subscription++; + while (*message != '\0' && *message != '/') + message++; + continue; + } + if (*message != *subscription) + return false; + wildcards |= *subscription == '/'; + message++; + subscription++; + } + return *message == *subscription; // a match only if both ended +} + +} // namespace esphome::mqtt diff --git a/tests/components/mqtt/test_topic_match.cpp b/tests/components/mqtt/test_topic_match.cpp new file mode 100644 index 00000000000..b51f45ce478 --- /dev/null +++ b/tests/components/mqtt/test_topic_match.cpp @@ -0,0 +1,103 @@ +#include + +#include +#include + +#include "esphome/components/mqtt/mqtt_topic_match.h" + +namespace esphome::mqtt::testing { + +// The previous recursive implementation, kept to check the loop matches it +static bool reference_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { + if (*message == '\0' && *subscription == '\0') + return true; + if (*message == '\0' || *subscription == '\0') + return false; + bool do_wildcards = is_normal || past_separator; + if (*subscription == '+' && do_wildcards) { + subscription++; + while (*message != '\0' && *message != '/') + message++; + return reference_match(message, subscription, is_normal, true); + } + if (*subscription == '#' && do_wildcards) + return true; + if (*message != *subscription) + return false; + past_separator = past_separator || *subscription == '/'; + return reference_match(message + 1, subscription + 1, is_normal, past_separator); +} + +static bool reference_match(const char *message, const char *subscription) { + return reference_match(message, subscription, *message != '\0' && *message != '$', false); +} + +TEST(MQTTTopicMatch, Exact) { + EXPECT_TRUE(topic_match("a/b/c", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b")); + EXPECT_FALSE(topic_match("a/b", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b/d")); +} + +TEST(MQTTTopicMatch, SingleLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/+/c")); + EXPECT_TRUE(topic_match("a/b/c", "+/+/+")); + EXPECT_TRUE(topic_match("a//c", "a/+/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/+")); + EXPECT_FALSE(topic_match("a/b/c/d", "a/+/c")); +} + +TEST(MQTTTopicMatch, MultiLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/#")); + EXPECT_TRUE(topic_match("a/b/c", "#")); + EXPECT_TRUE(topic_match("a/b/c", "a/+/#")); + EXPECT_FALSE(topic_match("b/c", "a/#")); +} + +TEST(MQTTTopicMatch, DollarTopicsSkipFirstLevelWildcards) { + EXPECT_FALSE(topic_match("$SYS/broker", "#")); + EXPECT_FALSE(topic_match("$SYS/broker", "+/broker")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/#")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/+")); +} + +TEST(MQTTTopicMatch, EdgeCases) { + EXPECT_TRUE(topic_match("", "")); + EXPECT_FALSE(topic_match("a", "")); + // Kept from the previous code; the MQTT spec has "#" match the parent level and "+" an empty last level + EXPECT_FALSE(topic_match("a/b", "a/b/#")); + EXPECT_FALSE(topic_match("a/", "a/+")); + EXPECT_TRUE(topic_match("a/$x", "a/+")); +} + +// Recursion overflowed the ESP32 loop stack at about 250 characters +TEST(MQTTTopicMatch, LongTopic) { + std::string topic(4096, 'x'); + topic[100] = '/'; + EXPECT_TRUE(topic_match(topic.c_str(), topic.c_str())); + std::string other = topic; + other.back() = 'y'; + EXPECT_FALSE(topic_match(topic.c_str(), other.c_str())); +} + +TEST(MQTTTopicMatch, MatchesPreviousImplementation) { + // Small alphabet so wildcards, separators and '$' meet often + static constexpr char MESSAGE_CHARS[] = {'a', 'b', '/', '$'}; + static constexpr char SUBSCRIPTION_CHARS[] = {'a', 'b', '/', '$', '+', '#'}; + std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible + auto random_string = [&rng](const char *chars, size_t count) { + std::string s(rng() % 8, ' '); + for (char &c : s) + c = chars[rng() % count]; + return s; + }; + for (int i = 0; i < 20000; i++) { + std::string message = random_string(MESSAGE_CHARS, sizeof(MESSAGE_CHARS)); + std::string subscription = random_string(SUBSCRIPTION_CHARS, sizeof(SUBSCRIPTION_CHARS)); + ASSERT_EQ(topic_match(message.c_str(), subscription.c_str()), + reference_match(message.c_str(), subscription.c_str())) + << "message='" << message << "' subscription='" << subscription << "'"; + } +} + +} // namespace esphome::mqtt::testing From dd2582bdfd71dfad2ace33b072048232ce9ce578 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:41:12 -0400 Subject: [PATCH 124/179] [usb_uart] Expose `interface_class` and `port_number` (#20659) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 7 ++- esphome/components/api/api_pb2.cpp | 4 ++ esphome/components/api/api_pb2.h | 2 + esphome/components/api/api_pb2_dump.cpp | 2 + .../components/serial_proxy/serial_proxy.cpp | 2 + esphome/components/usb_uart/ch34x.cpp | 8 --- esphome/components/usb_uart/cp210x.cpp | 10 ++-- esphome/components/usb_uart/ft23xx.cpp | 1 + esphome/components/usb_uart/pl2303.cpp | 5 +- esphome/components/usb_uart/usb_uart.cpp | 6 ++- esphome/components/usb_uart/usb_uart.h | 21 +++++++- tests/components/serial_proxy/__init__.py | 16 ++++++ .../test_usb_device_descriptor.cpp | 51 +++++++++++++++++++ 13 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 tests/components/serial_proxy/__init__.py create mode 100644 tests/components/serial_proxy/test_usb_device_descriptor.cpp diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 77b5afe818e..6ba7bde9af6 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -3043,12 +3043,15 @@ enum SerialProxyIdentityFlag { // IDs below are empty } -// The descriptor fields of a USB device as seen by the host stack +// The descriptor fields of a USB device as seen by the host stack, and the port's index +// among those a vendor driver exposes on the interface message UsbDeviceDescriptor { uint32 vendor_id = 1; uint32 product_id = 2; uint32 bcd_device = 3; - uint32 interface_number = 4; // bInterfaceNumber the host driver binds to + uint32 interface_number = 4; // bInterfaceNumber the port is bound through + uint32 interface_class = 5; // bInterfaceClass of that interface + uint32 port_number = 6; } // The identity of the device behind a port. Identifies one physical device among others of diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index b59663aeb51..a3a08b78e3d 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4175,6 +4175,8 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number); return pos; } uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { @@ -4184,6 +4186,8 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.product_id); size += ProtoSize::calc_uint32(1, msg.bcd_device); size += ProtoSize::calc_uint32(1, msg.interface_number); + size += ProtoSize::calc_uint32(1, msg.interface_class); + size += ProtoSize::calc_uint32(1, msg.port_number); return size; } uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 421c3fccb38..ef4e713c77d 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -4083,6 +4083,8 @@ class UsbDeviceDescriptor final : public ProtoMessage { uint32_t product_id{0}; uint32_t bcd_device{0}; uint32_t interface_number{0}; + uint32_t interface_class{0}; + uint32_t port_number{0}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3c6f5302b55..ec224c46e29 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2942,6 +2942,8 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("product_id"), this->product_id); dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device); dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); + dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class); + dump_field(out, ESPHOME_PSTR("port_number"), this->port_number); return out.c_str(); } const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 16e0f0b984c..a34b1b5b514 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -412,6 +412,8 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: msg.usb.product_id = scratch.product_id; msg.usb.bcd_device = scratch.bcd_device; msg.usb.interface_number = this->usb_channel_->get_interface_number(); + msg.usb.interface_class = this->usb_channel_->get_interface_class(); + msg.usb.port_number = this->usb_channel_->get_port_number(); msg.manufacturer = StringRef(scratch.manufacturer); msg.product = StringRef(scratch.product); msg.serial_number = StringRef(scratch.serial_number); diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index ed5a034b82f..dce07a64a53 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -152,14 +152,6 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo } } -std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { - auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl); - // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare - for (auto &cdc_dev : result) { - cdc_dev.interrupt_interface_number = 0xFF; - } - return result; -} } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index e920da6c267..34b76f88541 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -88,11 +88,11 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; } - if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) { - cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}}); - } else { - cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}}); - } + CdcEps eps = in_ep->bEndpointAddress & usb_host::USB_DIR_IN + ? CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber} + : CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}; + eps.bulk_interface_class = data_desc->bInterfaceClass; + cdc_devs.push_back(eps); } return cdc_devs; } diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index e10da78aaf0..805a4adfce3 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -217,6 +217,7 @@ static optional get_uart(const usb_config_desc_t *config_desc, uint8_t i } eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; return eps; } diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 36107c093c4..0430b1eeacb 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -189,7 +189,10 @@ std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev } if (in_ep && out_ep) { - cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}); + CdcEps eps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}; + eps.bulk_interface_class = intf->bInterfaceClass; + eps.interrupt_interface_class = intf->bInterfaceClass; + cdc_devs.push_back(eps); break; // PL2303 is single-port } } diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index f61642566cb..744b79f4e43 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -41,14 +41,17 @@ static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t in if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) { eps.notify_ep = ep; eps.interrupt_interface_number = intf_desc->bInterfaceNumber; + eps.interrupt_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.in_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.out_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else { ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes); continue; @@ -431,8 +434,7 @@ void USBUartTypeCdcAcm::on_connected() { if (channel->cdc_dev_.interrupt_interface_number != 0xFF && channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { if (!this->claim_comm_interface_) { - ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", - channel->cdc_dev_.interrupt_interface_number); + ESP_LOGD(TAG, "Comm interface %d left unclaimed", channel->cdc_dev_.interrupt_interface_number); } else { auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number, 0); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 4e52fb60e20..9f527a9becd 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -39,6 +39,9 @@ struct CdcEps { // must remain valid even when the interface itself is not claimed. uint8_t interrupt_interface_number{0xFF}; bool interrupt_interface_claimed{false}; + // bInterfaceClass of each interface above, which decides how a host binds the port + uint8_t bulk_interface_class{0}; + uint8_t interrupt_interface_class{0}; }; enum CH34xChipType : uint8_t { @@ -186,6 +189,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedcdc_dev_.bulk_interface_number; } + /// bInterfaceClass of the interface `get_interface_number()` names + uint8_t get_interface_class() const { + return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_class + : this->cdc_dev_.bulk_interface_class; + } + + /// Index of this port among the ports on its interface. Every driver here exposes one + /// port per interface; a multiplexed driver would index its channels. + uint8_t get_port_number() const { return 0; } + protected: // Not directly instantiable; construct a concrete channel type instead. USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} @@ -317,13 +330,17 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: - USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} + USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) { + // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare. The + // comm interface is left in place unclaimed: on the CDC-style chips it is still the + // interface a host binds, and so the one the port is identified by. + this->claim_comm_interface_ = false; + } void dump_config() override; protected: bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; - std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; private: CH34xChipType chiptype_{CHIP_UNKNOWN}; diff --git a/tests/components/serial_proxy/__init__.py b/tests/components/serial_proxy/__init__.py new file mode 100644 index 00000000000..913e8d8b37e --- /dev/null +++ b/tests/components/serial_proxy/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness gives a multi-instance component an empty list and never calls its to_code, + # so take a single empty config instead and emit what the proxy messages and handlers + # need. No proxy is configured, so the count is 0, which keeps the capability messages as + # the api tests expect them; the USB identity stays out, since the USB channel exists on the ESP32 only. + manifest.multi_conf = False + + async def to_code_testing(config): + cg.add_define("USE_SERIAL_PROXY") + cg.add_define("SERIAL_PROXY_COUNT", 0) + + manifest.to_code = to_code_testing diff --git a/tests/components/serial_proxy/test_usb_device_descriptor.cpp b/tests/components/serial_proxy/test_usb_device_descriptor.cpp new file mode 100644 index 00000000000..c76757bbee6 --- /dev/null +++ b/tests/components/serial_proxy/test_usb_device_descriptor.cpp @@ -0,0 +1,51 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api::testing { + +// Encodes into a buffer of exactly the calculated size, so any overrun trips the debug bounds check. +static std::vector encode(const UsbDeviceDescriptor &msg) { + const uint32_t size = msg.calculate_size(); + APIBuffer buf; + EXPECT_TRUE(buf.resize(size == 0 ? 1 : size)); + uint8_t *pos = buf.data(); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = buf.data() + buf.size(); +#endif + uint8_t *end = UsbDeviceDescriptor::encode_msg(&msg, pos PROTO_ENCODE_DEBUG_ARG); + EXPECT_EQ(static_cast(end - buf.data()), size); + return {buf.data(), end}; +} + +TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) { + UsbDeviceDescriptor msg; + msg.vendor_id = 0x10C4; + msg.product_id = 0xEA60; + msg.bcd_device = 0x0100; + msg.interface_number = 1; + msg.interface_class = 0xFF; + msg.port_number = 2; + + const std::vector expected = {0x08, 0xC4, 0x21, 0x10, 0xE0, 0xD4, 0x03, 0x18, 0x80, + 0x02, 0x20, 0x01, 0x28, 0xFF, 0x01, 0x30, 0x02}; + EXPECT_EQ(encode(msg), expected); +} + +TEST(UsbDeviceDescriptor, ZeroClassAndPortLeaveTheWireAsBefore) { + // The new fields are additive with zero defaults, so an old client sees the message it always did + UsbDeviceDescriptor msg; + msg.vendor_id = 0x303A; + msg.product_id = 0x1001; + msg.interface_number = 1; + + const std::vector expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01}; + EXPECT_EQ(encode(msg), expected); +} + +} // namespace esphome::api::testing From ad8cd0dd3ea1c29c16d8308debcddb23ef9dbcd0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 11:45:54 -1000 Subject: [PATCH 125/179] [noise] Initialize the protocol id's reserved field for ESP-IDF 6.1 (#20779) --- esphome/components/noise/noise_handshake.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index bd1afa198d3..15938f3aca1 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -37,6 +37,7 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog .cipher_id = NOISE_CIPHER_CHACHAPOLY, .hash_id = NOISE_HASH_SHA256, .hybrid_id = NOISE_DH_NONE, + .reserved = {}, }; int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER); From 4552dbad7a9e13e195657675882ff94bddbde86d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:43:07 -1000 Subject: [PATCH 126/179] Bump aioesphomeapi from 46.11.0 to 46.12.0 (#20781) 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 486612817c2..d81770cf647 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.4.0 click==8.5.0 -aioesphomeapi==46.11.0 +aioesphomeapi==46.12.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From f464994f859451ba7cdb77438c53a88bc21d900f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 14 Oct 2026 18:56:51 -0400 Subject: [PATCH 127/179] [api][sendspin] Add the Sendspin client id to the capabilities message (#20775) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 3 +++ esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 1 + esphome/components/api/api_pb2_dump.cpp | 1 + esphome/components/sendspin/sendspin_hub.cpp | 8 ++++++++ esphome/components/sendspin/sendspin_hub.h | 6 ++++++ tests/unit_tests/components/api/test_api_proto.py | 2 +- 8 files changed, 23 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 6ba7bde9af6..8e9ff00f59c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -403,6 +403,9 @@ message WizardCapabilities { message SendspinCapabilities { // Bitmask of the Sendspin features this device supports uint32 feature_flags = 1; + // The Sendspin client_id: base64url of the client's static public key, 43 characters. + // Empty until the Sendspin client has started once since boot. + string client_id = 2 [(max_data_length) = 43]; } message DeviceCapabilitiesResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 73ce4bd8569..efa0beb55e1 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2128,6 +2128,7 @@ bool APIConnection::send_device_capabilities_response_() { #endif #if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); + resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id(); #endif return this->send_message(resp); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index a3a08b78e3d..a3a2e09501f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -310,12 +310,14 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) { uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.client_id); return pos; } uint32_t SendspinCapabilities::calc_size_msg(const void *self) { const auto &msg = *static_cast(self); uint32_t size = 0; size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += !msg.client_id.empty() ? 2 + msg.client_id.size() : 0; return size; } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index ef4e713c77d..cbfdb49ac9a 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -750,6 +750,7 @@ class WizardCapabilities final : public ProtoMessage { class SendspinCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; + StringRef client_id{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ec224c46e29..23ce6319ed1 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1119,6 +1119,7 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const { const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities")); dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("client_id"), this->client_id); return out.c_str(); } #endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index df680202061..30bff186155 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -243,6 +243,14 @@ std::optional SendspinHub::get_pairing_token() const { return this->client_->pairing_token(); } +// THREAD CONTEXT: Main loop +StringRef SendspinHub::get_client_id() const { + if (this->client_ == nullptr) { + return StringRef(); + } + return StringRef(this->client_->client_id()); +} + // THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_unpaired_access_enabled(bool enabled) { this->unpaired_access_ = enabled; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index cd8d0d0afaf..23c60d215b6 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -198,6 +198,12 @@ class SendspinHub final : public Component, /// started once; it stays available after the client stops. Main loop only. std::optional get_pairing_token() const; + /// @brief Returns the Sendspin client_id: base64url of the client's static public key. + /// + /// Empty until the client has started once; it stays set after the client stops. The returned StringRef points into + /// the client's string, which start() may rewrite, so do not keep it past the current call. Main loop only. + StringRef get_client_id() const; + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index ca3c0ce7b58..68e3cab2a21 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -71,7 +71,7 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { "sendspin": 6, }, "WizardCapabilities": {"configured": 1}, - "SendspinCapabilities": {"feature_flags": 1}, + "SendspinCapabilities": {"feature_flags": 1, "client_id": 2}, "BluetoothProxyCapabilities": { "feature_flags": 1, "mac_address": 2, From bd5b3fa64125d7ba4bbfd69c46b7db536156e56b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 15:59:56 -1000 Subject: [PATCH 128/179] [esp32_ble_client] Value-initialize the connection parameters for ESP-IDF 6.1 (#20776) --- esphome/components/esp32_ble_client/ble_client_base.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index cfd77e84f4a..547ab6748bf 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -300,7 +300,7 @@ void BLEClientBase::log_warning_(const char *message) { esp_err_t BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; From e09cb64756d48b61db4ae20b42707211b3ca1468 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 16:00:07 -1000 Subject: [PATCH 129/179] [bluetooth_connection] Value-initialize the connection parameters for ESP-IDF 6.1 (#20774) --- .../bluetooth_connection/bluetooth_connection_bluedroid.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index d3af02780fe..b7017bc105b 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -382,7 +382,7 @@ void BluedroidGattClient::set_disconnecting_() { esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; From 79823c89abb7f3c66794df286177861a48a4e867 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:05:01 -0400 Subject: [PATCH 130/179] [mipi_rgb] Rotate pure RGB panels in software (#20757) --- .../components/mipi_rgb/models/__init__.py | 6 +++- .../mipi_rgb/test_mipi_rgb_config.py | 35 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/components/mipi_rgb/models/__init__.py b/esphome/components/mipi_rgb/models/__init__.py index 9e3fe2a476f..62f8c7789d4 100644 --- a/esphome/components/mipi_rgb/models/__init__.py +++ b/esphome/components/mipi_rgb/models/__init__.py @@ -9,6 +9,10 @@ class RgbDriverChip(DriverChip): def transforms(self) -> set[str]: """ Return the set of transformations supported by this driver chip. - RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY + RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY. + A model with an empty init sequence has no command interface at all, so it + cannot apply any transform in hardware. """ + if self.initsequence is not None and len(self.initsequence) == 0: + return set() return super().transforms - {CONF_SWAP_XY} diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py index e677577ec08..58e7ba9d959 100644 --- a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -93,12 +93,12 @@ def test_transform_matches_model_support( from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS - # ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays + # The full-height ST7701S supports both mirror axes but not swap_xy (RGB displays # never support axis swapping). - model = MODELS["ESP32-8048S070"] + model = MODELS["ST7701S"] assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} - base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + base = {"model": "ST7701S", "data_pins": DATA_PINS, "pclk_pin": 21} CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) # An unsupported axis may be explicitly disabled (a harmless no-op)... @@ -139,6 +139,35 @@ def test_st7701s_only_supports_mirror_x( CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) +def test_pure_rgb_model_has_no_hardware_transform( + set_core_config: SetCoreConfigCallable, +) -> None: + """A model with no init sequence has no command interface, so no transform + can be applied in hardware and every rotation must be done in software. + + Previously such models claimed mirror_x/mirror_y support, so a rotation of + 180 was handed to the (nonexistent) hardware and the display was not rotated. + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + model = MODELS["ESP32-8048S070"] + assert model.initsequence == () + assert model.transforms == set() + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + for rotation in (90, 180, 270): + config = CONFIG_SCHEMA({**base, "id": f"rot_{rotation}", "rotation": rotation}) + assert not model.rotation_as_transform(config) + metadata = get_display_metadata(config["id"]) + assert metadata.rotation == rotation + assert not metadata.has_hardware_rotation + + with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"): + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) + + def test_metadata_records_rotation( set_core_config: SetCoreConfigCallable, ) -> None: From 235c614e68eb2085d97f0afa2b1bd8ee45a4eb37 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:55:19 +1100 Subject: [PATCH 131/179] [epaper_spi] Ensure test card gets drawn (#20804) --- esphome/components/epaper_spi/display.py | 1 + .../config/test_card_update_interval.yaml | 22 +++++++++++++++++++ tests/component_tests/epaper_spi/test_init.py | 12 ++++++++++ 3 files changed, 35 insertions(+) create mode 100644 tests/component_tests/epaper_spi/config/test_card_update_interval.yaml diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 487e8e5ef40..32ac0b9da70 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -208,6 +208,7 @@ def _final_validate(config) -> None: else: # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + config[CONF_UPDATE_INTERVAL] = update_interval("1min") elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") diff --git a/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml b/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml new file mode 100644 index 00000000000..fb6eeec8d77 --- /dev/null +++ b/tests/component_tests/epaper_spi/config/test_card_update_interval.yaml @@ -0,0 +1,22 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO19 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO23 + cs_pin: GPIO5 + update_interval: never + dimensions: + width: 200 + height: 200 diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 4b8f5788925..81e197abf81 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -580,3 +580,15 @@ def test_model_with_no_default_init_sequence_generates( # init_sequence_length, ...); a length of 0 confirms the empty init # sequence array was generated instead of raising during code generation. assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp) + + +def test_test_card_overrides_update_interval( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With nothing else to draw, update_interval is not left at never for the test card.""" + main_cpp = generate_main(component_config_path("test_card_update_interval.yaml")) + + match = re.search(r"epaper_display->set_update_interval\((\d+)\);", main_cpp) + assert match is not None + assert int(match.group(1)) != 4294967295 From 0173127239a449d3952cdee2532795f1904431d3 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:11:26 -1000 Subject: [PATCH 132/179] Bump bundled esphome-device-builder to 1.24.0 (#20851) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c9aa3dba62c..658b8c06302 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1 +RUN uv pip install --no-cache-dir esphome-device-builder==1.24.0 RUN \ platformio settings set enable_telemetry No \ From 7ccaedfb9e14bccd320b2fa808ee4a56f90d5d1b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:49:15 -0400 Subject: [PATCH 133/179] [esp32] Bump recommended ESP-IDF to 5.5.6 (#20987) --- esphome/components/esp32/__init__.py | 9 +++++---- platformio.ini | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 73078dc19fd..9164b1e1590 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1099,7 +1099,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), - cv.Version(3, 3, 12): cv.Version(5, 5, 5), + cv.Version(3, 3, 12): cv.Version(5, 5, 6), cv.Version(3, 3, 11): cv.Version(5, 5, 5), cv.Version(3, 3, 10): cv.Version(5, 5, 5), cv.Version(3, 3, 9): cv.Version(5, 5, 4), @@ -1123,9 +1123,9 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 5), - "latest": cv.Version(5, 5, 5), - "dev": cv.Version(5, 5, 5), + "recommended": cv.Version(5, 5, 6), + "latest": cv.Version(5, 5, 6), + "dev": cv.Version(5, 5, 6), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { @@ -1135,6 +1135,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(5, 5, 6): cv.Version(55, 3, 312), cv.Version(5, 5, 5): cv.Version(55, 3, 312), cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), diff --git a/platformio.ini b/platformio.ini index 56a12d8b31c..18c410c16b2 100644 --- a/platformio.ini +++ b/platformio.ini @@ -147,7 +147,7 @@ extends = common:arduino platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.12/esp32-core-3.3.12.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -183,7 +183,7 @@ extra_scripts = extends = common:idf platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.5/esp-idf-v5.5.5.tar.xz + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.6/esp-idf-v5.5.6.tar.xz framework = espidf lib_deps = From 0fbba035221341fd23d5836fc382f3f72783184a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:50:54 -0400 Subject: [PATCH 134/179] [debug] Log uptime in dump_config (#20989) --- esphome/components/debug/debug_component.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 97f4522c623..afd1337dca2 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -34,6 +34,15 @@ void DebugComponent::dump_config() { this->free_heap_ = get_free_heap_(); ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); + uint32_t uptime = static_cast(millis_64() / 1000); + uint32_t days = uptime / 86400; + uptime -= days * 86400; + uint32_t hours = uptime / 3600; + uptime -= hours * 3600; + uint32_t minutes = uptime / 60; + uptime -= minutes * 60; + ESP_LOGD(TAG, "Uptime: %" PRIu32 "d %02" PRIu32 "h %02" PRIu32 "m %02" PRIu32 "s", days, hours, minutes, uptime); + pos = get_device_info_(std::span(device_info_buffer), pos); #ifdef USE_TEXT_SENSOR From c50ee215f225ced93a3074b5b42cd18ea4855ac5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:07:42 -1000 Subject: [PATCH 135/179] [tcp_uart] Expose the connection entities as platforms (#21009) --- esphome/components/tcp_uart/__init__.py | 30 +++--------------- esphome/components/tcp_uart/binary_sensor.py | 25 +++++++++++++++ esphome/components/tcp_uart/sensor.py | 31 +++++++++++++++++++ esphome/components/tcp_uart/tcp_uart.cpp | 6 ++++ esphome/components/tcp_uart/tcp_uart.h | 7 +++++ tests/components/tcp_uart/benchmark.yaml | 5 +-- tests/components/tcp_uart/common.yaml | 8 +++++ .../tcp_uart/test-client.esp32-idf.yaml | 8 +++++ .../integration/fixtures/tcp_uart_bridge.yaml | 4 +++ 9 files changed, 97 insertions(+), 27 deletions(-) create mode 100644 esphome/components/tcp_uart/binary_sensor.py create mode 100644 esphome/components/tcp_uart/sensor.py diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index 796a9fc48db..c05b9d17a32 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_DATA_BITS, CONF_HOST, CONF_PARITY, @@ -11,22 +10,16 @@ from esphome.components.const import ( CONF_STOP_BITS, ) import esphome.config_validation as cv -from esphome.const import ( - CONF_BAUD_RATE, - CONF_ID, - CONF_PORT, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, -) +from esphome.const import CONF_BAUD_RATE, CONF_ID, CONF_PORT from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DEPENDENCIES = ["network"] -AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["uart", "socket"] +DOMAIN = "tcp_uart" MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_TCP_UART_ID = "tcp_uart_id" tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart") TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) @@ -45,15 +38,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -99,7 +83,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_parity(config[CONF_PARITY])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/binary_sensor.py b/esphome/components/tcp_uart/binary_sensor.py new file mode 100644 index 00000000000..8337973c837 --- /dev/null +++ b/esphome/components/tcp_uart/binary_sensor.py @@ -0,0 +1,25 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/tcp_uart/sensor.py b/esphome/components/tcp_uart/sensor.py new file mode 100644 index 00000000000..099ca8734e1 --- /dev/null +++ b/esphome/components/tcp_uart/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_TCP_UART_ID, TcpUart + +DEPENDENCIES = ["tcp_uart"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_TCP_UART_ID): cv.use_id(TcpUart), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TCP_UART_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp index efc85804e99..139b31a2203 100644 --- a/esphome/components/tcp_uart/tcp_uart.cpp +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -17,9 +17,11 @@ void TcpUart::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -38,7 +40,9 @@ void TcpUart::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -58,9 +62,11 @@ void TcpUart::sync_link_() { // Unread bytes of the last session stay readable while down, never into the next one. this->rx_start_ = this->rx_end_ = 0; } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h index 1e1d48e4c20..6ecfd1a0862 100644 --- a/esphome/components/tcp_uart/tcp_uart.h +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -23,7 +26,9 @@ class TcpUart : public uart::UARTComponent, public Component { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -64,7 +69,9 @@ class TcpUart : public uart::UARTComponent, public Component { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif #ifdef USE_SENSOR sensor::Sensor *disconnects_sensor_{nullptr}; uint32_t disconnects_{0}; diff --git a/tests/components/tcp_uart/benchmark.yaml b/tests/components/tcp_uart/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/tcp_uart/benchmark.yaml +++ b/tests/components/tcp_uart/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/tcp_uart/common.yaml b/tests/components/tcp_uart/common.yaml index 97ac0247da3..97bcd24f6a7 100644 --- a/tests/components/tcp_uart/common.yaml +++ b/tests/components/tcp_uart/common.yaml @@ -10,8 +10,16 @@ tcp_uart: allowed_ips: - 192.0.2.20 - 192.0.2.0/24 + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server connected: name: TCP UART Server Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_server disconnects: name: TCP UART Server Disconnects diff --git a/tests/components/tcp_uart/test-client.esp32-idf.yaml b/tests/components/tcp_uart/test-client.esp32-idf.yaml index 74bd26a98bc..921ce0cbc1a 100644 --- a/tests/components/tcp_uart/test-client.esp32-idf.yaml +++ b/tests/components/tcp_uart/test-client.esp32-idf.yaml @@ -7,7 +7,15 @@ tcp_uart: host: 192.0.2.10 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: TCP UART Connected + +sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 disconnects: name: TCP UART Disconnects diff --git a/tests/integration/fixtures/tcp_uart_bridge.yaml b/tests/integration/fixtures/tcp_uart_bridge.yaml index 12791e7dd46..74a8710b19e 100644 --- a/tests/integration/fixtures/tcp_uart_bridge.yaml +++ b/tests/integration/fixtures/tcp_uart_bridge.yaml @@ -20,6 +20,10 @@ tcp_uart: host: 127.0.0.1 port: 18125 reconnect_interval: 3s + +binary_sensor: + - platform: tcp_uart + tcp_uart_id: tcp_uart_1 connected: name: Bridge Connected From 952623382154d4ce9e6df80318a42f17007bb9bb Mon Sep 17 00:00:00 2001 From: ab-tools Date: Sun, 18 Oct 2026 23:39:37 +0200 Subject: [PATCH 136/179] [audio] Compute frames_to_microseconds() in 64 bits (#21089) --- esphome/components/audio/audio.cpp | 3 ++- .../audio/test_audio_stream_info.cpp | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 tests/components/audio/test_audio_stream_info.cpp diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp index 402e741059b..54406df2aaa 100644 --- a/esphome/components/audio/audio.cpp +++ b/esphome/components/audio/audio.cpp @@ -23,7 +23,8 @@ AudioStreamInfo::AudioStreamInfo(uint8_t bits_per_sample, uint8_t channels, uint } uint32_t AudioStreamInfo::frames_to_microseconds(uint32_t frames) const { - return (frames * 1000000 + (this->sample_rate_ >> 1)) / this->sample_rate_; + // In 64 bits: frames * 1000000 overflows 32 bits above 4294 frames, about 90 ms at 48 kHz. + return (static_cast(frames) * 1000000 + (this->sample_rate_ >> 1)) / this->sample_rate_; } uint32_t AudioStreamInfo::frames_to_milliseconds_with_remainder(uint32_t *total_frames) const { diff --git a/tests/components/audio/test_audio_stream_info.cpp b/tests/components/audio/test_audio_stream_info.cpp new file mode 100644 index 00000000000..e948bc32b2f --- /dev/null +++ b/tests/components/audio/test_audio_stream_info.cpp @@ -0,0 +1,20 @@ +#include + +#include "esphome/components/audio/audio.h" + +namespace esphome::audio::testing { + +TEST(AudioStreamInfo, FramesToMicroseconds) { + AudioStreamInfo info(16, 2, 48000); + EXPECT_EQ(info.frames_to_microseconds(48), 1000U); + EXPECT_EQ(info.frames_to_microseconds(1), 21U); // 20.83 us, rounded +} + +TEST(AudioStreamInfo, FramesToMicrosecondsAboveFourThousandFrames) { + // frames * 1000000 no longer fits 32 bits above 4294 frames, about 90 ms at 48 kHz. + AudioStreamInfo info(24, 2, 48000); + EXPECT_EQ(info.frames_to_microseconds(4800), 100000U); + EXPECT_EQ(info.frames_to_microseconds(48000), 1000000U); +} + +} // namespace esphome::audio::testing From b1c138c6129361caa0869a085457c8c6d6d14c3c Mon Sep 17 00:00:00 2001 From: ab-tools Date: Mon, 19 Oct 2026 00:12:50 +0200 Subject: [PATCH 137/179] [audio] Compute the remaining AudioStreamInfo conversions in 64 bits (#21092) --- esphome/components/audio/audio.cpp | 2 +- esphome/components/audio/audio.h | 10 +++--- .../audio/test_audio_stream_info.cpp | 32 +++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp index 54406df2aaa..61163b820d8 100644 --- a/esphome/components/audio/audio.cpp +++ b/esphome/components/audio/audio.cpp @@ -31,7 +31,7 @@ uint32_t AudioStreamInfo::frames_to_milliseconds_with_remainder(uint32_t *total_ uint32_t unprocessable_frames = *total_frames % (this->sample_rate_ / this->ms_sample_rate_gcd_); uint32_t frames_for_ms_calculation = *total_frames - unprocessable_frames; - uint32_t playback_ms = (frames_for_ms_calculation * 1000) / this->sample_rate_; + uint32_t playback_ms = (static_cast(frames_for_ms_calculation) * 1000) / this->sample_rate_; *total_frames = unprocessable_frames; return playback_ms; } diff --git a/esphome/components/audio/audio.h b/esphome/components/audio/audio.h index 36780a3055b..c03c42a5a3f 100644 --- a/esphome/components/audio/audio.h +++ b/esphome/components/audio/audio.h @@ -34,7 +34,7 @@ class AudioStreamInfo { /// @return Duration in milliseconds that will store `bytes` bytes of audio. May round down for certain sample rates /// or values of `bytes`. uint32_t bytes_to_ms(size_t bytes) const { - return bytes * 1000 / (this->sample_rate_ * this->bytes_per_sample_ * this->channels_); + return static_cast(bytes) * 1000 / (this->sample_rate_ * this->bytes_per_sample_ * this->channels_); } /// @brief Convert bytes to frames. @@ -60,18 +60,20 @@ class AudioStreamInfo { /// @brief Converts duration to frames. /// @param ms Duration in milliseconds /// @return Audio frames that will store `ms` milliseconds of audio. May round down for certain sample rates. - uint32_t ms_to_frames(uint32_t ms) const { return (ms * this->sample_rate_) / 1000; } + uint32_t ms_to_frames(uint32_t ms) const { return (static_cast(ms) * this->sample_rate_) / 1000; } /// @brief Converts duration to samples. /// @param ms Duration in milliseconds /// @return Audio samples that will store `ms` milliseconds of audio. May round down for certain sample rates. - uint32_t ms_to_samples(uint32_t ms) const { return (ms * this->channels_ * this->sample_rate_) / 1000; } + uint32_t ms_to_samples(uint32_t ms) const { + return (static_cast(ms) * this->channels_ * this->sample_rate_) / 1000; + } /// @brief Converts duration to bytes. May round down for certain sample rates. /// @param ms Duration in milliseconds /// @return Bytes that will store `ms` milliseconds of audio. May round down for certain sample rates. size_t ms_to_bytes(uint32_t ms) const { - return (ms * this->bytes_per_sample_ * this->channels_ * this->sample_rate_) / 1000; + return (static_cast(ms) * this->bytes_per_sample_ * this->channels_ * this->sample_rate_) / 1000; } /// @brief Computes the duration, in microseconds, the given amount of frames represents. diff --git a/tests/components/audio/test_audio_stream_info.cpp b/tests/components/audio/test_audio_stream_info.cpp index e948bc32b2f..ce5b24b21e7 100644 --- a/tests/components/audio/test_audio_stream_info.cpp +++ b/tests/components/audio/test_audio_stream_info.cpp @@ -17,4 +17,36 @@ TEST(AudioStreamInfo, FramesToMicrosecondsAboveFourThousandFrames) { EXPECT_EQ(info.frames_to_microseconds(48000), 1000000U); } +TEST(AudioStreamInfo, MsToBytesAboveTheThirtyTwoBitProduct) { + // ms * 8 bytes * 96000 Hz no longer fits 32 bits above 5592 ms. + AudioStreamInfo info(32, 2, 96000); + EXPECT_EQ(info.ms_to_bytes(10000), 7680000U); +} + +TEST(AudioStreamInfo, MsToSamplesAboveTheThirtyTwoBitProduct) { + // ms * 2 channels * 48000 Hz no longer fits 32 bits above 44739 ms; sound_level allows 60 s. + AudioStreamInfo info(16, 2, 48000); + EXPECT_EQ(info.ms_to_samples(60000), 5760000U); +} + +TEST(AudioStreamInfo, MsToFramesAboveTheThirtyTwoBitProduct) { + // ms * 96000 Hz no longer fits 32 bits above 44739 ms. + AudioStreamInfo info(16, 2, 96000); + EXPECT_EQ(info.ms_to_frames(60000), 5760000U); +} + +TEST(AudioStreamInfo, BytesToMsAboveFourMegabytes) { + // bytes * 1000 no longer fits 32 bits above 4294967 bytes. + AudioStreamInfo info(16, 2, 48000); + EXPECT_EQ(info.bytes_to_ms(19200000), 100000U); +} + +TEST(AudioStreamInfo, FramesToMillisecondsAboveFourMillionFrames) { + // frames * 1000 no longer fits 32 bits above 4294967 frames, about 89 s at 48 kHz. + AudioStreamInfo info(16, 2, 48000); + uint32_t frames = 4800000 + 7; + EXPECT_EQ(info.frames_to_milliseconds_with_remainder(&frames), 100000U); + EXPECT_EQ(frames, 7U); +} + } // namespace esphome::audio::testing From 26064f141543d92697a575dbb5e10e7b69d77036 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 17 Oct 2026 15:09:32 -1000 Subject: [PATCH 138/179] [uart_tcp] Expose the connection entities as platforms (#21008) --- esphome/components/uart_tcp/__init__.py | 23 ++------------ esphome/components/uart_tcp/binary_sensor.py | 25 +++++++++++++++ esphome/components/uart_tcp/sensor.py | 31 +++++++++++++++++++ esphome/components/uart_tcp/uart_tcp.cpp | 6 ++++ esphome/components/uart_tcp/uart_tcp.h | 7 +++++ tests/components/uart_tcp/benchmark.yaml | 5 +-- tests/components/uart_tcp/common.yaml | 8 +++++ .../uart_tcp/test-client.esp32-idf.yaml | 8 +++++ tests/components/uart_tcp/test.host.yaml | 8 +++++ .../integration/fixtures/uart_tcp_bridge.yaml | 8 +++-- 10 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 esphome/components/uart_tcp/binary_sensor.py create mode 100644 esphome/components/uart_tcp/sensor.py diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 6e7a6fef325..b5eafa9a448 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -1,8 +1,7 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components import socket, uart from esphome.components.const import ( CONF_ALLOWED_IPS, - CONF_CONNECTED, CONF_HOST, CONF_RECONNECT_INTERVAL, CONF_ROLE, @@ -14,9 +13,6 @@ from esphome.const import ( CONF_ID, CONF_PORT, CONF_UART_ID, - DEVICE_CLASS_CONNECTIVITY, - ENTITY_CATEGORY_DIAGNOSTIC, - STATE_CLASS_TOTAL_INCREASING, ) from esphome.core import CORE import esphome.final_validate as fv @@ -25,10 +21,10 @@ from esphome.types import ConfigType CODEOWNERS = ["@Bascht74"] DOMAIN = "uart_tcp" DEPENDENCIES = ["network", "uart"] -AUTO_LOAD = ["binary_sensor", "sensor", "socket"] +AUTO_LOAD = ["socket"] MULTI_CONF = True -CONF_DISCONNECTS = "disconnects" +CONF_UART_TCP_ID = "uart_tcp_id" uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp") UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice) @@ -42,15 +38,6 @@ BASE_SCHEMA = cv.Schema( cv.Optional( CONF_RECONNECT_INTERVAL, default="5s" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( - device_class=DEVICE_CLASS_CONNECTIVITY, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), - cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_TOTAL_INCREASING, - entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - ), } ).extend(cv.COMPONENT_SCHEMA) @@ -155,7 +142,3 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) if (host := config.get(CONF_HOST)) is not None: cg.add(var.set_host(host)) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) - sensors = sensor.sub_sensors(config) - await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/binary_sensor.py b/esphome/components/uart_tcp/binary_sensor.py new file mode 100644 index 00000000000..c8d19319337 --- /dev/null +++ b/esphome/components/uart_tcp/binary_sensor.py @@ -0,0 +1,25 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import CONF_ID, DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, hub.set_connected_sensor) diff --git a/esphome/components/uart_tcp/sensor.py b/esphome/components/uart_tcp/sensor.py new file mode 100644 index 00000000000..ad44b56ad09 --- /dev/null +++ b/esphome/components/uart_tcp/sensor.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +from . import CONF_UART_TCP_ID, UartTcp + +DEPENDENCIES = ["uart_tcp"] + +CONF_DISCONNECTS = "disconnects" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), + cv.GenerateID(CONF_UART_TCP_ID): cv.use_id(UartTcp), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UART_TCP_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, hub.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp index 4f241f85671..dcdc8ce49d5 100644 --- a/esphome/components/uart_tcp/uart_tcp.cpp +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -19,9 +19,11 @@ void UartTcp::setup() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.begin(TAG); #endif +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(false); } +#endif #ifdef USE_SENSOR if (this->disconnects_sensor_ != nullptr) { this->disconnects_sensor_->publish_state(0); @@ -40,7 +42,9 @@ void UartTcp::dump_config() { #ifdef USE_SOCKET_TCP_LISTENER this->listener_.dump_config(); #endif +#ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#endif #ifdef USE_SENSOR LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); #endif @@ -60,9 +64,11 @@ void UartTcp::sync_link_() { // The driver kept whatever arrived while the link was down. this->discard_uart_(); } +#ifdef USE_BINARY_SENSOR if (this->connected_sensor_ != nullptr) { this->connected_sensor_->publish_state(up); } +#endif #ifdef USE_SENSOR // Only edges get here, so down is the falling edge. if (!up && this->disconnects_sensor_ != nullptr) { diff --git a/esphome/components/uart_tcp/uart_tcp.h b/esphome/components/uart_tcp/uart_tcp.h index 256e059133a..6080cc95514 100644 --- a/esphome/components/uart_tcp/uart_tcp.h +++ b/esphome/components/uart_tcp/uart_tcp.h @@ -1,6 +1,9 @@ #pragma once +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" +#endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif @@ -22,7 +25,9 @@ class UartTcp final : public Component, public uart::UARTDevice { void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } +#ifdef USE_BINARY_SENSOR void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#endif #ifdef USE_SENSOR void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } #endif @@ -53,7 +58,9 @@ class UartTcp final : public Component, public uart::UARTDevice { #ifdef USE_SOCKET_TCP_LISTENER socket::TcpListener listener_; #endif +#ifdef USE_BINARY_SENSOR binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#endif // Loop start time of the last socket-to-UART write; sizes the next paced write. uint32_t last_write_ms_{0}; #ifdef USE_SENSOR diff --git a/tests/components/uart_tcp/benchmark.yaml b/tests/components/uart_tcp/benchmark.yaml index 781dc15b681..ed771f20534 100644 --- a/tests/components/uart_tcp/benchmark.yaml +++ b/tests/components/uart_tcp/benchmark.yaml @@ -1,3 +1,4 @@ -# The disconnects counter is compiled only with USE_SENSOR; declaring the -# sensor domain makes the C++ unit test build define it. +# The connection entities are compiled only with USE_BINARY_SENSOR and +# USE_SENSOR; declaring the domains makes the C++ unit test build define them. +binary_sensor: sensor: diff --git a/tests/components/uart_tcp/common.yaml b/tests/components/uart_tcp/common.yaml index fd34757d0ee..cf86dc9da89 100644 --- a/tests/components/uart_tcp/common.yaml +++ b/tests/components/uart_tcp/common.yaml @@ -10,7 +10,15 @@ uart_tcp: allowed_ips: - 192.168.1.10 - 192.168.1.0/24 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test-client.esp32-idf.yaml b/tests/components/uart_tcp/test-client.esp32-idf.yaml index 5ba900347c4..6bce693f854 100644 --- a/tests/components/uart_tcp/test-client.esp32-idf.yaml +++ b/tests/components/uart_tcp/test-client.esp32-idf.yaml @@ -12,7 +12,15 @@ uart_tcp: host: 192.0.2.20 port: 502 reconnect_interval: 10s + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/components/uart_tcp/test.host.yaml b/tests/components/uart_tcp/test.host.yaml index a0b82c568df..b793ae144c3 100644 --- a/tests/components/uart_tcp/test.host.yaml +++ b/tests/components/uart_tcp/test.host.yaml @@ -8,7 +8,15 @@ uart_tcp: uart_id: uart_bus host: 127.0.0.1 port: 44502 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge connected: name: UART TCP Connected + +sensor: + - platform: uart_tcp + uart_tcp_id: bridge disconnects: name: UART TCP Disconnects diff --git a/tests/integration/fixtures/uart_tcp_bridge.yaml b/tests/integration/fixtures/uart_tcp_bridge.yaml index 7ad28316f75..cf6c8fad64b 100644 --- a/tests/integration/fixtures/uart_tcp_bridge.yaml +++ b/tests/integration/fixtures/uart_tcp_bridge.yaml @@ -24,11 +24,15 @@ uart_tcp: port: 18126 allowed_ips: - 127.0.0.1 - connected: - name: Bridge Connected - id: denied_bridge uart_id: denied_bus role: server port: 18127 allowed_ips: - 192.0.2.1 + +binary_sensor: + - platform: uart_tcp + uart_tcp_id: bridge + connected: + name: Bridge Connected From f979ddcf0de00b868f7c1556bd366820ccbb4608 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 02:37:48 -1000 Subject: [PATCH 139/179] [bl0906] Read stale receive bytes instead of spinning on flush() (#21035) --- esphome/components/bl0906/bl0906.cpp | 4 +- .../fixtures/uart_mock_bl0906_stray_rx.yaml | 64 +++++++++++++++++++ .../test_uart_mock_bl0906_stray_rx.py | 57 +++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml create mode 100644 tests/integration/test_uart_mock_bl0906_stray_rx.py diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 9a27cffd043..4e9b1f9c5fd 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -20,7 +20,7 @@ constexpr uint8_t bl0906_checksum(const uint8_t address, const DataPacket *data) void BL0906::loop() { while (this->available()) - this->flush(); + this->read(); if (this->current_stage_ == STAGE_IDLE) { // Woken up between cycles to drain the action queue. Go back to sleep. @@ -92,7 +92,7 @@ void BL0906::advance_stage_() { void BL0906::setup() { while (this->available()) - this->flush(); + this->read(); this->write_array(USR_WRPROT_WITABLE, sizeof(USR_WRPROT_WITABLE)); // Calibration (1: register address; 2: value before calibration; 3: value after calibration) this->bias_correction_(BL0906_RMSOS_1, 0.01600, 0); // Calibration current_1 diff --git a/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml new file mode 100644 index 00000000000..d5f8a7837ce --- /dev/null +++ b/tests/integration/fixtures/uart_mock_bl0906_stray_rx.yaml @@ -0,0 +1,64 @@ +esphome: + name: uart-mock-bl0906-stray-rx + +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 DEPENDENCIES = ["uart"] +uart: + baud_rate: 9600 + port: /dev/null + +# Answers register reads; the first temperature answer has one stray trailing byte +uart_mock: + id: mock_uart + baud_rate: 19200 + on_tx: + - then: + - lambda: |- + // Command and register address arrive as separate one-byte writes + static bool read_pending = false; + static bool stray_sent = false; + if (data.size() != 1) + return; + if (!read_pending) { + read_pending = data[0] == 0x35; + return; + } + read_pending = false; + const uint8_t address = data[0]; + uint32_t raw; + switch (address) { + case 0x5E: // temperature: (300 - 64) * 12.5 / 59 - 40 = 10 C + raw = 300; + break; + case 0x16: // voltage + raw = 0x2A1B3C; + break; + default: + return; + } + const uint8_t l = raw & 0xFF, m = (raw >> 8) & 0xFF, h = (raw >> 16) & 0xFF; + std::vector reply{l, m, h, static_cast((address + l + m + h) ^ 0xFF)}; + if (address == 0x5E && !stray_sent) { + stray_sent = true; + reply.push_back(0xAA); + } + id(mock_uart).inject_to_rx_buffer(reply); + +sensor: + - platform: bl0906 + uart_id: mock_uart + update_interval: 1s + temperature: + name: Temperature + voltage: + name: Voltage diff --git a/tests/integration/test_uart_mock_bl0906_stray_rx.py b/tests/integration/test_uart_mock_bl0906_stray_rx.py new file mode 100644 index 00000000000..5f8a635c7f5 --- /dev/null +++ b/tests/integration/test_uart_mock_bl0906_stray_rx.py @@ -0,0 +1,57 @@ +"""A BL0906 that leaves a stray byte in the receive buffer keeps reading.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import EntityState, SensorInfo +import pytest + +from .state_utils import require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_uart_mock_bl0906_stray_rx( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A stray received byte does not stop the read cycle.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + lines: list[str] = [] + states: dict[int, EntityState] = {} + + async with ( + run_compiled(yaml_config, line_callback=lines.append), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + temperature = require_entity(entities, "temperature", SensorInfo) + voltage = require_entity(entities, "voltage", SensorInfo) + + client.subscribe_states(lambda state: states.__setitem__(state.key, state)) + + def state_of(entity: SensorInfo) -> float | None: + state = states.get(entity.key) + return None if state is None or state.missing_state else state.state + + try: + async with asyncio.timeout(10.0): + while state_of(voltage) is None: + await asyncio.sleep(0.05) + except TimeoutError: + pytest.fail(f"No voltage reading; log: {lines[-20:]}") + + assert any("RX inject 5 bytes" in x for x in lines), "stray byte not sent" + assert state_of(temperature) == pytest.approx(10.0, abs=0.01) + uref = 1.097 * 100000 / (13162 * 100 * 1000) + assert state_of(voltage) == pytest.approx(0x2A1B3C * uref, rel=1e-5) + assert not any("Junk on wire" in x for x in lines) From ffa20abe96664634bea6cdf192450767120406df Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 18 Oct 2026 14:36:35 -0500 Subject: [PATCH 140/179] [api] Poll the outgoing dial every loop on deep sleep builds (#21077) --- esphome/components/api/api_outgoing_connection.h | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index 16d50c34255..fff11522d79 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -59,17 +59,20 @@ class OutgoingConnectionManager { static constexpr uint32_t BACKOFF_MIN_MS = 5000; static constexpr uint32_t BACKOFF_MAX_MS = 300000; static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; - static constexpr uint32_t NETWORK_RETRY_MS = 500; static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; // A deep sleep wake window is too short to spend on the delay, so those - // builds dial out as soon as the target is gone + // builds dial out as soon as the target is gone, recheck the network on + // every loop and notice the connect completing on the loop it does #ifdef USE_DEEP_SLEEP static constexpr uint32_t BOOT_WAIT_MS = 0; static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS; + static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 0; + static constexpr uint32_t NETWORK_RETRY_MS = 0; #else static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; + static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; + static constexpr uint32_t NETWORK_RETRY_MS = 500; #endif void try_dial_(APIServer *server, uint32_t now); From 649f2c91f566cf75fe2ecc6e4466f669fe24bf32 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 18 Oct 2026 15:47:02 -0500 Subject: [PATCH 141/179] [api] Poll the outgoing dial every loop (#21085) Co-authored-by: J. Nick Koston --- esphome/components/api/api_outgoing_connection.cpp | 10 +--------- esphome/components/api/api_outgoing_connection.h | 8 +------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index c86107cef0b..0b2b1d52dbe 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -152,10 +152,7 @@ void OutgoingConnectionManager::loop(APIServer *server) { void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { if (!network::is_connected()) { - // Flips within seconds of boot; recheck fast so a deep sleep wake - // window is not spent waiting - this->schedule_wait_(now, NETWORK_RETRY_MS); - return; + return; // still WAITING with the wait elapsed, so the next loop rechecks } struct sockaddr_storage addr; socklen_t addr_len = this->target_sockaddr_(&addr); @@ -197,7 +194,6 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { } this->state_ = DialState::DIAL_STATE_CONNECTING; this->state_ts_ = now; - this->last_poll_ = now; } void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { @@ -206,10 +202,6 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { this->schedule_retry_(now); return; } - if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) { - return; - } - this->last_poll_ = now; int err = 0; switch (socket::poll_connect(*this->dial_socket_, err)) { case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING: diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index fff11522d79..fa222e29ccf 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -61,18 +61,13 @@ class OutgoingConnectionManager { static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; // A deep sleep wake window is too short to spend on the delay, so those - // builds dial out as soon as the target is gone, recheck the network on - // every loop and notice the connect completing on the loop it does + // builds dial out as soon as the target is gone #ifdef USE_DEEP_SLEEP static constexpr uint32_t BOOT_WAIT_MS = 0; static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 0; - static constexpr uint32_t NETWORK_RETRY_MS = 0; #else static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; - static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; - static constexpr uint32_t NETWORK_RETRY_MS = 500; #endif void try_dial_(APIServer *server, uint32_t now); @@ -107,7 +102,6 @@ class OutgoingConnectionManager { uint32_t backoff_{BACKOFF_MIN_MS}; uint32_t wait_{BOOT_WAIT_MS}; uint32_t state_ts_{0}; - uint32_t last_poll_{0}; // Byte-aligned types last #ifndef API_OUTGOING_CONNECTION_HOST From a6a4b49e752817d0615b2db4231f8b73efeb00b3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:29:05 +1300 Subject: [PATCH 142/179] Bump version to 2026.10.0b5 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index 614b9a6fc05..5261997847d 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.10.0b4 +PROJECT_NUMBER = 2026.10.0b5 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 3888f8f95d3..785a4de64a3 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.10.0b4" +__version__ = "2026.10.0b5" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From be9741c6de58fffed795115b633424cb000e87e5 Mon Sep 17 00:00:00 2001 From: Jacopo Labardi <2527836+labodj@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:02:22 +0200 Subject: [PATCH 143/179] [const] Share CONF_VALUE_TYPE across components (#21062) --- esphome/components/const/__init__.py | 1 + esphome/components/modbus_controller/const.py | 3 ++- esphome/components/modbus_server/const.py | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 966db92c6fd..99d7bf3f525 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -70,6 +70,7 @@ CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" CONF_USE_PSRAM = "use_psram" +CONF_VALUE_TYPE = "value_type" CONF_VERIFY_SSL = "verify_ssl" CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" diff --git a/esphome/components/modbus_controller/const.py b/esphome/components/modbus_controller/const.py index 364a0a510e8..333196aaa0d 100644 --- a/esphome/components/modbus_controller/const.py +++ b/esphome/components/modbus_controller/const.py @@ -1,3 +1,5 @@ +from esphome.components.const import CONF_VALUE_TYPE # noqa: F401 # pylint: disable=unused-import + CONF_ALLOW_DUPLICATE_COMMANDS = "allow_duplicate_commands" CONF_BITMASK = "bitmask" CONF_BYTE_OFFSET = "byte_offset" @@ -23,5 +25,4 @@ CONF_SERVER_COURTESY_RESPONSE = "server_courtesy_response" CONF_SERVER_REGISTERS = "server_registers" CONF_SKIP_UPDATES = "skip_updates" CONF_USE_WRITE_MULTIPLE = "use_write_multiple" -CONF_VALUE_TYPE = "value_type" CONF_WRITE_LAMBDA = "write_lambda" diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py index 86366c7ce02..91293818fca 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -1,6 +1,7 @@ +from esphome.components.const import CONF_VALUE_TYPE # noqa: F401 # pylint: disable=unused-import + CONF_REGISTER_LAST_ADDRESS = "register_last_address" CONF_REGISTER_VALUE = "register_value" -CONF_VALUE_TYPE = "value_type" CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" From 2dcb97774d4958a10a54fc748e9887850fd1ec48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 13:54:48 -1000 Subject: [PATCH 144/179] [tests] Add an esp32-s31-idf base file and shared i2c and i2s_audio packages (#21086) --- .../build_components_base.esp32-s31-idf.yaml | 18 ++++++++++++++++++ .../common/i2c/esp32-s31-idf.yaml | 11 +++++++++++ .../common/i2s_audio/esp32-s31-idf.yaml | 14 ++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 tests/test_build_components/build_components_base.esp32-s31-idf.yaml create mode 100644 tests/test_build_components/common/i2c/esp32-s31-idf.yaml create mode 100644 tests/test_build_components/common/i2s_audio/esp32-s31-idf.yaml diff --git a/tests/test_build_components/build_components_base.esp32-s31-idf.yaml b/tests/test_build_components/build_components_base.esp32-s31-idf.yaml new file mode 100644 index 00000000000..6dbd51db0ad --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-s31-idf.yaml @@ -0,0 +1,18 @@ +esphome: + name: componenttestesp32s31idf + friendly_name: $component_name + +esp32: + variant: esp32s31 + framework: + type: esp-idf + version: 6.1.0 + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/common/i2c/esp32-s31-idf.yaml b/tests/test_build_components/common/i2c/esp32-s31-idf.yaml new file mode 100644 index 00000000000..77eece9b3b1 --- /dev/null +++ b/tests/test_build_components/common/i2c/esp32-s31-idf.yaml @@ -0,0 +1,11 @@ +# Common I2C configuration for ESP32-S31 IDF tests + +substitutions: + scl_pin: GPIO9 + sda_pin: GPIO8 + +i2c: + - id: i2c_bus + scl: ${scl_pin} + sda: ${sda_pin} + scan: true diff --git a/tests/test_build_components/common/i2s_audio/esp32-s31-idf.yaml b/tests/test_build_components/common/i2s_audio/esp32-s31-idf.yaml new file mode 100644 index 00000000000..28587958f0f --- /dev/null +++ b/tests/test_build_components/common/i2s_audio/esp32-s31-idf.yaml @@ -0,0 +1,14 @@ +# Common I2S audio bus configuration for ESP32-S31 IDF tests +# Provides a shared i2s_audio bus that speaker/microphone components can use +# Each consumer must give its speaker/microphone a unique data pin + +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +i2s_audio: + - id: i2s_audio_bus + i2s_bclk_pin: ${i2s_bclk_pin} + i2s_lrclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} From a70d75e46996065603dd4bd797f7287802943afa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 13:59:09 -1000 Subject: [PATCH 145/179] [components] Read UART bytes with read_available and discard_available (#21084) --- esphome/components/a01nyub/a01nyub.cpp | 8 +-- esphome/components/a02yyuw/a02yyuw.cpp | 8 +-- .../adalight/adalight_light_effect.cpp | 42 +++++------ esphome/components/apc1/apc1.cpp | 14 ++-- .../apc_proteous/apc_proteous_cover.cpp | 8 +-- esphome/components/daly_bms/daly_bms.cpp | 37 +++++----- esphome/components/emontx/emontx.cpp | 13 ++-- esphome/components/gcja5/gcja5.cpp | 69 ++++++++++--------- esphome/components/he60r/he60r.cpp | 8 +-- esphome/components/hlw8032/hlw8032.cpp | 5 +- .../hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp | 7 +- .../components/hydreon_rgxx/hydreon_rgxx.cpp | 8 +-- esphome/components/jsn_sr04t/jsn_sr04t.cpp | 9 +-- esphome/components/ld6002b/ld6002b.cpp | 5 +- esphome/components/nextion/nextion.cpp | 6 +- esphome/components/pipsolar/pipsolar.cpp | 20 ++---- esphome/components/pipsolar/pipsolar.h | 1 - esphome/components/pm1006/pm1006.cpp | 6 +- esphome/components/pmsx003/pmsx003.cpp | 42 +++++------ esphome/components/sds011/sds011.cpp | 37 +++++----- esphome/components/sim800l/sim800l.cpp | 7 +- esphome/components/sml/sml.cpp | 6 +- esphome/components/smt100/smt100.cpp | 19 +++-- esphome/components/sun_gtil2/sun_gtil2.cpp | 6 +- esphome/components/systa_bus/systa_bus.cpp | 4 +- .../uart/binary_sensor/uart_binary_sensor.cpp | 7 +- esphome/components/uart/event/uart_event.cpp | 6 +- .../uponor_smatrix/uponor_smatrix.cpp | 6 +- esphome/components/vbus/vbus.cpp | 30 ++++---- esphome/components/wts01/wts01.cpp | 8 +-- 30 files changed, 189 insertions(+), 263 deletions(-) diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp index 8c97169cbf1..84f853c265a 100644 --- a/esphome/components/a01nyub/a01nyub.cpp +++ b/esphome/components/a01nyub/a01nyub.cpp @@ -9,15 +9,13 @@ namespace esphome::a01nyub { ESPHOME_LOG_TAG(TAG, "a01nyub.sensor"); void A01nyubComponent::loop() { - uint8_t data; - while (this->available() > 0) { - this->read_byte(&data); + this->read_available([this](uint8_t data) { if (this->buffer_.empty() && (data != 0xff)) - continue; + return; buffer_.push_back(data); if (this->buffer_.size() == 4) this->check_buffer_(); - } + }); } void A01nyubComponent::check_buffer_() { diff --git a/esphome/components/a02yyuw/a02yyuw.cpp b/esphome/components/a02yyuw/a02yyuw.cpp index 20e8f6c7b56..6b809ad396c 100644 --- a/esphome/components/a02yyuw/a02yyuw.cpp +++ b/esphome/components/a02yyuw/a02yyuw.cpp @@ -9,15 +9,13 @@ namespace esphome::a02yyuw { ESPHOME_LOG_TAG(TAG, "a02yyuw.sensor"); void A02yyuwComponent::loop() { - uint8_t data; - while (this->available() > 0) { - this->read_byte(&data); + this->read_available([this](uint8_t data) { if (this->buffer_.empty() && (data != 0xff)) - continue; + return; buffer_.push_back(data); if (this->buffer_.size() == 4) this->check_buffer_(); - } + }); } void A02yyuwComponent::check_buffer_() { diff --git a/esphome/components/adalight/adalight_light_effect.cpp b/esphome/components/adalight/adalight_light_effect.cpp index e3510d380eb..7e7dca0f2fd 100644 --- a/esphome/components/adalight/adalight_light_effect.cpp +++ b/esphome/components/adalight/adalight_light_effect.cpp @@ -68,32 +68,32 @@ void AdalightLightEffect::apply(light::AddressableLight &it, const Color ¤ blank_all_leds_(it); } - if (this->available() > 0) { - ESP_LOGV(TAG, "Frame: Available (size=%d).", this->available()); + const size_t avail = this->available(); + if (avail > 0) { + ESP_LOGV(TAG, "Frame: Available (size=%zu).", avail); } - while (this->available() != 0) { - uint8_t data; - if (!this->read_byte(&data)) - break; - this->frame_.push_back(data); - this->last_byte_ = now; + this->read_available( + [this, &it, now](uint8_t data) { + this->frame_.push_back(data); + this->last_byte_ = now; - switch (this->parse_frame_(it)) { - case INVALID: - ESP_LOGD(TAG, "Frame: Invalid (size=%zu, first=%d).", this->frame_.size(), this->frame_[0]); - reset_frame_(it); - break; + switch (this->parse_frame_(it)) { + case INVALID: + ESP_LOGD(TAG, "Frame: Invalid (size=%zu, first=%d).", this->frame_.size(), this->frame_[0]); + reset_frame_(it); + break; - case PARTIAL: - break; + case PARTIAL: + break; - case CONSUMED: - ESP_LOGV(TAG, "Frame: Consumed (size=%zu).", this->frame_.size()); - reset_frame_(it); - break; - } - } + case CONSUMED: + ESP_LOGV(TAG, "Frame: Consumed (size=%zu).", this->frame_.size()); + reset_frame_(it); + break; + } + }, + avail); } AdalightLightEffect::Frame AdalightLightEffect::parse_frame_(light::AddressableLight &it) { diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp index dee4bd8d73b..b94f9c52021 100644 --- a/esphome/components/apc1/apc1.cpp +++ b/esphome/components/apc1/apc1.cpp @@ -104,16 +104,14 @@ void APC1Component::loop() { this->rx_index_ = 0; } - while (this->available()) { + this->read_available([this, now](uint8_t byte) { this->last_transmission_ = now; - uint8_t byte; - this->read_byte(&byte); if (this->rx_index_ == 0) { if (byte == APC1_HEADER_1) { this->rx_buffer_[this->rx_index_++] = byte; } - continue; + return; } if (this->rx_index_ == 1) { @@ -122,12 +120,12 @@ void APC1Component::loop() { } else if (byte != APC1_HEADER_1) { this->rx_index_ = 0; } - continue; + return; } this->rx_buffer_[this->rx_index_++] = byte; if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) { - continue; + return; } // The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a @@ -136,14 +134,14 @@ void APC1Component::loop() { if (payload_len < 2 || payload_len > this->rx_buffer_.size() - APC1_FRAME_HEADER_SIZE) { ESP_LOGW(TAG, "Invalid frame length: %u", payload_len); this->rx_index_ = 0; - continue; + return; } const uint8_t total_len = payload_len + APC1_FRAME_HEADER_SIZE; if (this->rx_index_ == total_len) { this->parse_frame_(total_len); this->rx_index_ = 0; } - } + }); } void APC1Component::parse_frame_(uint16_t total_len) { diff --git a/esphome/components/apc_proteous/apc_proteous_cover.cpp b/esphome/components/apc_proteous/apc_proteous_cover.cpp index c17773da29c..e781cef67d5 100644 --- a/esphome/components/apc_proteous/apc_proteous_cover.cpp +++ b/esphome/components/apc_proteous/apc_proteous_cover.cpp @@ -27,11 +27,7 @@ void APCProteousCover::setup() { } void APCProteousCover::loop() { - uint8_t data; - while (this->available() > 0) { - if (!this->read_byte(&data)) { - continue; - } + this->read_available([this](uint8_t data) { const char *line = this->line_reader_.feed(data); if (this->line_reader_.overflowed()) { // Start over from this byte, so a frame right after unterminated noise is still read @@ -41,7 +37,7 @@ void APCProteousCover::loop() { ESP_LOGVV(TAG, "rx: '%s'", line); this->parse_response_(line); } - } + }); } void APCProteousCover::parse_response_(const char *line) { diff --git a/esphome/components/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index 04ef798b69e..422f6f1350b 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -42,25 +42,26 @@ void DalyBmsComponent::loop() { this->last_transmission_ = now; this->trigger_next_ = true; } - if (available()) + const size_t avail = this->available(); + if (avail > 0) this->last_transmission_ = now; - while (available()) { - uint8_t c; - read_byte(&c); - if (!this->receiving_) { - if (c != 0xa5) - continue; - this->receiving_ = true; - } - this->data_.push_back(c); - if (this->data_.size() == 4) - this->data_count_ = c; - if ((this->data_.size() > 4) and (data_.size() == this->data_count_ + 5)) { - this->decode_data_(this->data_); - this->data_.clear(); - this->receiving_ = false; - } - } + this->read_available( + [this](uint8_t c) { + if (!this->receiving_) { + if (c != 0xa5) + return; + this->receiving_ = true; + } + this->data_.push_back(c); + if (this->data_.size() == 4) + this->data_count_ = c; + if ((this->data_.size() > 4) and (data_.size() == this->data_count_ + 5)) { + this->decode_data_(this->data_); + this->data_.clear(); + this->receiving_ = false; + } + }, + avail); if (this->trigger_next_) { this->trigger_next_ = false; diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 888b12f4bf4..1992f36d328 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -111,8 +111,7 @@ void EmonTx::loop() { // Drop what arrived during the pause and resync at the next line start this->was_paused_ = false; uint8_t dropped = 0; - while (this->available() > 0 && this->read_byte(&dropped)) { - } + this->read_available([&dropped](uint8_t byte) { dropped = byte; }); if (dropped == '\n') { this->line_reader_.reset(); } else { @@ -126,16 +125,12 @@ void EmonTx::loop() { } // Read all available data to prevent UART buffer overflow - while (this->available() > 0) { - uint8_t received; - if (!this->read_byte(&received)) - break; - + this->read_available([this](uint8_t received) { size_t len; char *line = this->line_reader_.feed(received, len); this->line_reader_.log_if_overflowed(TAG); if (line == nullptr || len == 0) { - continue; + return; } ESP_LOGD(TAG, "Received line: %s", line); this->data_callbacks_.call(StringRef(line, len)); @@ -149,7 +144,7 @@ void EmonTx::loop() { this->parse_version_line_(line, len); } #endif - } + }); } void EmonTx::parse_json_(const char *data, size_t len) { diff --git a/esphome/components/gcja5/gcja5.cpp b/esphome/components/gcja5/gcja5.cpp index fcb9e832ea8..b908fcd9e15 100644 --- a/esphome/components/gcja5/gcja5.cpp +++ b/esphome/components/gcja5/gcja5.cpp @@ -20,48 +20,49 @@ void GCJA5Component::loop() { this->rx_message_.clear(); } - if (this->available() == 0) { + const size_t avail = this->available(); + if (avail == 0) { return; } // There must now be data waiting this->last_transmission_ = now; - uint8_t val; - while (this->available() != 0) { - this->read_byte(&val); - this->rx_message_.push_back(val); + this->read_available( + [this](uint8_t val) { + this->rx_message_.push_back(val); - // check if rx_message_ has 32 bytes of data - if (this->rx_message_.size() == 32) { - this->parse_data_(); + // check if rx_message_ has 32 bytes of data + if (this->rx_message_.size() == 32) { + this->parse_data_(); - if (this->have_good_data_) { - if (this->pm_1_0_sensor_ != nullptr) - this->pm_1_0_sensor_->publish_state(get_32_bit_uint_(1)); - if (this->pm_2_5_sensor_ != nullptr) - this->pm_2_5_sensor_->publish_state(get_32_bit_uint_(5)); - if (this->pm_10_0_sensor_ != nullptr) - this->pm_10_0_sensor_->publish_state(get_32_bit_uint_(9)); - if (this->pmc_0_3_sensor_ != nullptr) - this->pmc_0_3_sensor_->publish_state(get_16_bit_uint_(13)); - if (this->pmc_0_5_sensor_ != nullptr) - this->pmc_0_5_sensor_->publish_state(get_16_bit_uint_(15)); - if (this->pmc_1_0_sensor_ != nullptr) - this->pmc_1_0_sensor_->publish_state(get_16_bit_uint_(17)); - if (this->pmc_2_5_sensor_ != nullptr) - this->pmc_2_5_sensor_->publish_state(get_16_bit_uint_(21)); - if (this->pmc_5_0_sensor_ != nullptr) - this->pmc_5_0_sensor_->publish_state(get_16_bit_uint_(23)); - if (this->pmc_10_0_sensor_ != nullptr) - this->pmc_10_0_sensor_->publish_state(get_16_bit_uint_(25)); - } else { - this->status_set_warning(); - ESP_LOGV(TAG, "Have 32 bytes but not good data. Skipping."); - } + if (this->have_good_data_) { + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(get_32_bit_uint_(1)); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(get_32_bit_uint_(5)); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(get_32_bit_uint_(9)); + if (this->pmc_0_3_sensor_ != nullptr) + this->pmc_0_3_sensor_->publish_state(get_16_bit_uint_(13)); + if (this->pmc_0_5_sensor_ != nullptr) + this->pmc_0_5_sensor_->publish_state(get_16_bit_uint_(15)); + if (this->pmc_1_0_sensor_ != nullptr) + this->pmc_1_0_sensor_->publish_state(get_16_bit_uint_(17)); + if (this->pmc_2_5_sensor_ != nullptr) + this->pmc_2_5_sensor_->publish_state(get_16_bit_uint_(21)); + if (this->pmc_5_0_sensor_ != nullptr) + this->pmc_5_0_sensor_->publish_state(get_16_bit_uint_(23)); + if (this->pmc_10_0_sensor_ != nullptr) + this->pmc_10_0_sensor_->publish_state(get_16_bit_uint_(25)); + } else { + this->status_set_warning(); + ESP_LOGV(TAG, "Have 32 bytes but not good data. Skipping."); + } - this->rx_message_.clear(); - } - } + this->rx_message_.clear(); + } + }, + avail); } bool GCJA5Component::calculate_checksum_() { diff --git a/esphome/components/he60r/he60r.cpp b/esphome/components/he60r/he60r.cpp index 7c67c5ffd3f..a15551f4c1a 100644 --- a/esphome/components/he60r/he60r.cpp +++ b/esphome/components/he60r/he60r.cpp @@ -152,13 +152,7 @@ void HE60rCover::update_() { } void HE60rCover::loop() { - uint8_t data; - - while (this->available() > 0) { - if (this->read_byte(&data)) { - this->process_rx_(data); - } - } + this->read_available([this](uint8_t data) { this->process_rx_(data); }); } void HE60rCover::control(const CoverCall &call) { diff --git a/esphome/components/hlw8032/hlw8032.cpp b/esphome/components/hlw8032/hlw8032.cpp index cbb6b6c628d..eb0a15d6f2f 100644 --- a/esphome/components/hlw8032/hlw8032.cpp +++ b/esphome/components/hlw8032/hlw8032.cpp @@ -30,8 +30,7 @@ static constexpr uint8_t STATE_REG_OVERFLOW_MASK = 0xF; static constexpr uint8_t PACKET_LENGTH = 24; void HLW8032Component::loop() { - while (this->available()) { - uint8_t data = this->read(); + this->read_available([this](uint8_t data) { if (!this->header_found_) { if ((data == STATE_REG_CORRECTION_FUNC_NORMAL) || (data == STATE_REG_CORRECTION_FUNC_FAIL) || (data & STATE_REG_CORRECTION_MASK) == STATE_REG_CORRECTION_MASK) { @@ -57,7 +56,7 @@ void HLW8032Component::loop() { memset(this->raw_data_, 0, PACKET_LENGTH); } } - } + }); } uint32_t HLW8032Component::read_uint24_(uint8_t offset) { diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 5415b7af74f..7317caf6c0d 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -20,10 +20,7 @@ ESPHOME_LOG_TAG(TAG, "hrxl.maxsonar.wr.sensor"); * The 123 means 123cm */ void HrxlMaxsonarWrComponent::loop() { - uint8_t data; - while (this->available() > 0) { - if (!this->read_byte(&data)) - continue; + this->read_available([this](uint8_t data) { size_t len; const char *line = this->line_reader_.feed(data, len); if (line != nullptr) { @@ -34,7 +31,7 @@ void HrxlMaxsonarWrComponent::loop() { ESP_LOGW(TAG, "Invalid data read from sensor: %s", this->line_reader_.partial()); this->line_reader_.reset(); } - } + }); } void HrxlMaxsonarWrComponent::handle_line_(const char *line, size_t len) { diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 4a479874f53..c96e2a41ecb 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -106,18 +106,14 @@ void HydreonRGxxComponent::update() { } void HydreonRGxxComponent::loop() { - uint8_t data; - while (this->available() > 0) { - if (!this->read_byte(&data)) { - continue; - } + this->read_available([this](uint8_t data) { size_t len; const char *line = this->line_reader_.feed(data, len); this->line_reader_.log_if_overflowed(TAG); if (line != nullptr) { this->process_line_(StringRef(line, len)); } - } + }); } /** diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.cpp b/esphome/components/jsn_sr04t/jsn_sr04t.cpp index 0c7dd277381..502874a0022 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.cpp +++ b/esphome/components/jsn_sr04t/jsn_sr04t.cpp @@ -14,19 +14,16 @@ void Jsnsr04tComponent::update() { } void Jsnsr04tComponent::loop() { - while (this->available() > 0) { - uint8_t data; - this->read_byte(&data); - + this->read_available([this](uint8_t data) { ESP_LOGV(TAG, "Read byte from sensor: %x", data); if (this->buffer_.empty() && data != 0xFF) - continue; + return; this->buffer_.push_back(data); if (this->buffer_.size() == 4) this->check_buffer_(); - } + }); } void Jsnsr04tComponent::check_buffer_() { diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 42461228cef..3418d007ae4 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -497,10 +497,7 @@ void LD6002BComponent::dump_config() { } void LD6002BComponent::loop() { - while (this->available()) { - uint8_t byte = this->read(); - this->parse_byte_(byte); - } + this->read_available([this](uint8_t byte) { this->parse_byte_(byte); }); this->process_command_queue_(); } diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index b9194b3f31c..a5ac2325369 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -180,11 +180,7 @@ void Nextion::release_queue_entry_(NextionQueue *nb) { } void Nextion::reset_(bool reset_nextion) { - uint8_t d; - - while (this->available()) { // Clear receive buffer - this->read_byte(&d); - } + this->discard_available(); for (auto *entry : this->nextion_queue_) { this->release_queue_entry_(entry); } diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index ea7ebaed0ae..b5d17cf3c15 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -11,20 +11,10 @@ void Pipsolar::setup() { this->command_start_millis_ = 0; } -void Pipsolar::empty_uart_buffer_() { - uint8_t buf[64]; - size_t avail; - while ((avail = this->available()) > 0) { - if (!this->read_array(buf, std::min(avail, sizeof(buf)))) { - break; - } - } -} - void Pipsolar::loop() { // Read message if (this->state_ == STATE_IDLE) { - this->empty_uart_buffer_(); + this->discard_available(); if (this->send_next_command_()) { // command sent @@ -111,7 +101,7 @@ void Pipsolar::loop() { // make sure data and null terminator fit in buffer if (this->read_pos_ >= PIPSOLAR_READ_BUFFER_LENGTH - 1) { this->read_pos_ = 0; - this->empty_uart_buffer_(); + this->discard_available(); ESP_LOGW(TAG, "response data too long, discarding."); done = true; break; @@ -122,7 +112,7 @@ void Pipsolar::loop() { // end of answer if (byte == 0x0D) { this->read_buffer_[this->read_pos_] = 0; - this->empty_uart_buffer_(); + this->discard_available(); if (this->state_ == STATE_POLL) { this->state_ = STATE_POLL_COMPLETE; } @@ -202,7 +192,7 @@ bool Pipsolar::send_next_command_() { } this->state_ = STATE_COMMAND; this->command_start_millis_ = millis(); - this->empty_uart_buffer_(); + this->discard_available(); this->read_pos_ = 0; crc16 = this->pipsolar_crc_(byte_command, length); this->write_str(command); @@ -231,7 +221,7 @@ bool Pipsolar::send_next_poll_() { } this->state_ = STATE_POLL; this->command_start_millis_ = millis(); - this->empty_uart_buffer_(); + this->discard_available(); this->read_pos_ = 0; crc16 = this->pipsolar_crc_(this->enabled_polling_commands_[this->last_polling_command_].command, this->enabled_polling_commands_[this->last_polling_command_].length); diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 4695e39829a..80ca0156a36 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -206,7 +206,6 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { static const size_t COMMAND_TIMEOUT = 5000; static const size_t POLLING_COMMANDS_MAX = 15; void add_polling_command_(const char *command, ENUMPollingCommand polling_command); - void empty_uart_buffer_(); uint8_t check_incoming_crc_(); uint8_t check_incoming_length_(uint8_t length); uint16_t pipsolar_crc_(uint8_t *msg, uint8_t len); diff --git a/esphome/components/pm1006/pm1006.cpp b/esphome/components/pm1006/pm1006.cpp index 2f57c028439..9247cf37bf7 100644 --- a/esphome/components/pm1006/pm1006.cpp +++ b/esphome/components/pm1006/pm1006.cpp @@ -24,8 +24,8 @@ void PM1006Component::update() { } void PM1006Component::loop() { - while (this->available() != 0) { - this->read_byte(&this->data_[this->data_index_]); + this->read_available([this](uint8_t byte) { + this->data_[this->data_index_] = byte; auto check = this->check_byte_(); if (!check.has_value()) { // finished @@ -39,7 +39,7 @@ void PM1006Component::loop() { // next byte this->data_index_++; } - } + }); } uint8_t PM1006Component::pm1006_checksum_(const uint8_t *command_data, uint8_t length) const { diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index efc80e8c650..d7e7e0e22bc 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -83,8 +83,7 @@ void PMSX003Component::loop() { if (now - this->fan_on_time_ < STABILISING_MS) return; // consume any command responses that are in the serial buffer - while (this->available()) - this->read_byte(&this->data_[0]); + this->discard_available(); // Trigger a new read this->send_command_(Command::MANUAL_MEASUREMENT, 0); this->state_ = State::WAITING; @@ -100,27 +99,30 @@ void PMSX003Component::loop() { this->data_index_ = 0; } - if (this->available() == 0) + const size_t avail = this->available(); + if (avail == 0) return; this->last_transmission_ = now; - while (this->available() != 0) { - this->read_byte(&this->data_[this->data_index_]); - auto check = this->check_byte_(); - if (!check.has_value()) { - if (this->update_interval_ > STABILISING_MS || now - this->last_update_ >= this->update_interval_) { - this->parse_data_(); - this->last_update_ = now; - } - this->data_index_ = 0; - } else if (!*check) { - // wrong data - this->data_index_ = 0; - } else { - // next byte - this->data_index_++; - } - } + this->read_available( + [this, now](uint8_t byte) { + this->data_[this->data_index_] = byte; + auto check = this->check_byte_(); + if (!check.has_value()) { + if (this->update_interval_ > STABILISING_MS || now - this->last_update_ >= this->update_interval_) { + this->parse_data_(); + this->last_update_ = now; + } + this->data_index_ = 0; + } else if (!*check) { + // wrong data + this->data_index_ = 0; + } else { + // next byte + this->data_index_++; + } + }, + avail); } optional PMSX003Component::check_byte_() { diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 147365251b5..08486e55589 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -83,27 +83,30 @@ void SDS011Component::loop() { this->data_index_ = 0; } - if (this->available() == 0) { + const size_t avail = this->available(); + if (avail == 0) { return; } this->last_transmission_ = now; - while (this->available() != 0) { - this->read_byte(&this->data_[this->data_index_]); - auto check = this->check_byte_(); - if (!check.has_value()) { - // finished - this->parse_data_(); - this->data_index_ = 0; - } else if (!*check) { - // wrong data - ESP_LOGV(TAG, "Byte %i of received data frame is invalid.", this->data_index_); - this->data_index_ = 0; - } else { - // next byte - this->data_index_++; - } - } + this->read_available( + [this](uint8_t byte) { + this->data_[this->data_index_] = byte; + auto check = this->check_byte_(); + if (!check.has_value()) { + // finished + this->parse_data_(); + this->data_index_ = 0; + } else if (!*check) { + // wrong data + ESP_LOGV(TAG, "Byte %i of received data frame is invalid.", this->data_index_); + this->data_index_ = 0; + } else { + // next byte + this->data_index_++; + } + }, + avail); } void SDS011Component::sds011_write_command_(const uint8_t *command_data) { diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index ba07bc3d6d3..1c23de43abf 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -422,10 +422,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { void Sim800LComponent::loop() { // Read message - while (this->available()) { - uint8_t byte; - this->read_byte(&byte); - + this->read_available([this](uint8_t byte) { ESP_LOGVV(TAG, "Buffer pos: %u %d", this->line_reader_.length(), byte); // NOLINT if (byte >= 0x7F) @@ -441,7 +438,7 @@ void Sim800LComponent::loop() { } else { this->line_reader_.log_if_overflowed(TAG); } - } + }); if (state_ == STATE_INIT && this->registered_ && (this->call_state_ != 6 // A call is in progress || this->send_pending_ || this->dial_pending_ || this->connect_pending_ || this->disconnect_pending_)) { diff --git a/esphome/components/sml/sml.cpp b/esphome/components/sml/sml.cpp index a4bc4373d7c..6924e2fdd9c 100644 --- a/esphome/components/sml/sml.cpp +++ b/esphome/components/sml/sml.cpp @@ -24,9 +24,7 @@ char Sml::check_start_end_bytes_(uint8_t byte) { } void Sml::loop() { - while (available()) { - const char c = read(); - + this->read_available([this](uint8_t c) { if (this->record_) this->sml_data_.emplace_back(c); @@ -57,7 +55,7 @@ void Sml::loop() { break; }; }; - } + }); } void Sml::process_sml_file_(const BytesView &sml_data) { diff --git a/esphome/components/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index 8a8f82b8504..67b3f4faecf 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -11,32 +11,31 @@ void SMT100Component::update() { } void SMT100Component::loop() { - while (this->available() != 0) { - const int readch = this->read(); - if (readch <= 0) - continue; + this->read_available([this](uint8_t readch) { + if (readch == 0) + return; char *line = this->line_reader_.feed(readch); this->line_reader_.log_if_overflowed(TAG); if (line != nullptr && line[0] != '\0') { char *token = strtok(line, ","); if (!token) - continue; + return; int counts = (int) strtol(token, nullptr, 10); token = strtok(nullptr, ","); if (!token) - continue; + return; float permittivity = (float) strtod(token, nullptr); token = strtok(nullptr, ","); if (!token) - continue; + return; float moisture = (float) strtod(token, nullptr); token = strtok(nullptr, ","); if (!token) - continue; + return; float temperature = (float) strtod(token, nullptr); token = strtok(nullptr, ","); if (!token) - continue; + return; float voltage = (float) strtod(token, nullptr); if (this->counts_sensor_ != nullptr) { @@ -59,7 +58,7 @@ void SMT100Component::loop() { voltage_sensor_->publish_state(voltage); } } - } + }); } void SMT100Component::dump_config() { diff --git a/esphome/components/sun_gtil2/sun_gtil2.cpp b/esphome/components/sun_gtil2/sun_gtil2.cpp index f89797cc106..edaac470cb7 100644 --- a/esphome/components/sun_gtil2/sun_gtil2.cpp +++ b/esphome/components/sun_gtil2/sun_gtil2.cpp @@ -39,11 +39,7 @@ static_assert(MESSAGE_SIZE == 350, "Expected the message size to be 350 bytes"); void SunGTIL2::setup() { this->rx_message_.reserve(MESSAGE_SIZE); } void SunGTIL2::loop() { - while (this->available()) { - uint8_t c; - this->read_byte(&c); - this->handle_char_(c); - } + this->read_available([this](uint8_t c) { this->handle_char_(c); }); } const char *SunGTIL2::state_to_string_(uint8_t state, std::span buffer) { diff --git a/esphome/components/systa_bus/systa_bus.cpp b/esphome/components/systa_bus/systa_bus.cpp index 6c780d07a92..884ad986b3a 100644 --- a/esphome/components/systa_bus/systa_bus.cpp +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -13,9 +13,7 @@ void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } static bool checksum(std::span data) { return sum_bytes(data.data(), data.size()) == 0; } void SystaBus::loop() { - uint8_t c; - while (this->available() && this->read_byte(&c)) - this->handle_byte_(c); + this->read_available([this](uint8_t c) { this->handle_byte_(c); }); } void SystaBus::handle_byte_(uint8_t c) { diff --git a/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp index 9bf9e726561..67158c82de1 100644 --- a/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp +++ b/esphome/components/uart/binary_sensor/uart_binary_sensor.cpp @@ -14,10 +14,7 @@ void UARTBinarySensorHub::dump_config() { } void UARTBinarySensorHub::loop() { - while (this->available()) { - uint8_t byte; - if (!this->read_byte(&byte)) - break; + this->read_available([this](uint8_t byte) { this->buffer_.push_overwrite(byte); for (const auto &matcher : this->matchers_) { // The byte just read has to be the pattern's last one before the rest is compared @@ -30,7 +27,7 @@ void UARTBinarySensorHub::loop() { this->buffer_.clear(); break; } - } + }); } // Whether the pattern, apart from its last byte, ends the buffer diff --git a/esphome/components/uart/event/uart_event.cpp b/esphome/components/uart/event/uart_event.cpp index 22bcee9c83f..5cbd7ed7829 100644 --- a/esphome/components/uart/event/uart_event.cpp +++ b/esphome/components/uart/event/uart_event.cpp @@ -20,9 +20,7 @@ void UARTEvent::add_event_matcher(const char *event_name, const uint8_t *match_d } void UARTEvent::read_data_() { - while (this->available()) { - uint8_t data; - this->read_byte(&data); + this->read_available([this](uint8_t data) { this->buffer_.push_back(data); bool match_found = false; @@ -42,7 +40,7 @@ void UARTEvent::read_data_() { if (!match_found && this->max_matcher_len_ > 0 && this->buffer_.size() > this->max_matcher_len_) { this->buffer_.erase(this->buffer_.begin()); } - } + }); } } // namespace esphome::uart diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index fd6eda73a0c..8db3c148958 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -47,17 +47,15 @@ void UponorSmatrixComponent::loop() { } // Read incoming data - while (this->available()) { + this->read_available([this, now](uint8_t byte) { // The controller polls devices every 10 seconds in some units or continuously in others with around 200 ms between // devices. Remember timestamps so we can send our own packets when the bus is expected to be silent. this->last_rx_ = now; - uint8_t byte; - this->read_byte(&byte); if (this->parse_byte_(byte)) { this->rx_buffer_.clear(); } - } + }); // Send packets during bus silence if (this->rx_buffer_.empty() && (now - this->last_rx_ > 50) && (now - this->last_rx_ < 100) && diff --git a/esphome/components/vbus/vbus.cpp b/esphome/components/vbus/vbus.cpp index 6b6a3393ee4..c45ae6bdaa3 100644 --- a/esphome/components/vbus/vbus.cpp +++ b/esphome/components/vbus/vbus.cpp @@ -28,24 +28,18 @@ static bool checksum(const uint8_t *data, int start, int count) { } void VBus::loop() { - if (!available()) - return; - - while (available()) { - uint8_t c; - read_byte(&c); - + this->read_available([this](uint8_t c) { if (c == 0xaa) { this->state_ = 1; this->buffer_.clear(); - continue; + return; } if (c & 0x80) { this->state_ = 0; - continue; + return; } if (this->state_ == 0) - continue; + return; if (this->state_ == 1) { this->buffer_.push_back(c); @@ -59,7 +53,7 @@ void VBus::loop() { this->state_ = 0; if (!checksum(this->buffer_.data(), 0, 15)) { ESP_LOGE(TAG, "P2 checksum failed"); - continue; + return; } septet_spread(this->buffer_.data(), 7, 6, this->buffer_[13]); uint16_t id = (this->buffer_[8] << 8) + this->buffer_[7]; @@ -70,7 +64,7 @@ void VBus::loop() { if (!checksum(this->buffer_.data(), 0, 9)) { ESP_LOGE(TAG, "P1 checksum failed"); this->state_ = 0; - continue; + return; } this->frames_ = this->buffer_[7]; if (this->frames_) { @@ -86,23 +80,23 @@ void VBus::loop() { ESP_LOGW(TAG, "Unknown protocol 0x%02x, discarding", this->protocol_); this->state_ = 0; } - continue; + return; } if (this->state_ == 2) { this->fbytes_[this->fbcount_++] = c; if (this->fbcount_ < 6) - continue; + return; this->fbcount_ = 0; if (!checksum(this->fbytes_, 0, 6)) { ESP_LOGE(TAG, "frame checksum failed"); - continue; + return; } septet_spread(this->fbytes_, 0, 4, this->fbytes_[4]); for (int i = 0; i < 4; i++) this->buffer_.push_back(this->fbytes_[i]); if (++this->cframe_ < this->frames_) - continue; + return; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_size(VBUS_MAX_LOG_BYTES)]; size_t log_bytes = std::min(this->buffer_.size(), static_cast(VBUS_MAX_LOG_BYTES)); @@ -112,9 +106,9 @@ void VBus::loop() { for (auto &listener : this->listeners_) listener->on_message(this->command_, this->source_, this->dest_, this->buffer_); this->state_ = 0; - continue; + return; } - } + }); } void VBusListener::on_message(uint16_t command, uint16_t source, uint16_t dest, std::vector &message) { diff --git a/esphome/components/wts01/wts01.cpp b/esphome/components/wts01/wts01.cpp index b7b9e5cfa99..93eb1f3227e 100644 --- a/esphome/components/wts01/wts01.cpp +++ b/esphome/components/wts01/wts01.cpp @@ -12,13 +12,7 @@ constexpr uint8_t HEADER_4 = 0x04; ESPHOME_LOG_TAG(TAG, "wts01"); void WTS01Sensor::loop() { - // Process all available data at once - while (this->available()) { - uint8_t c; - if (this->read_byte(&c)) { - this->handle_char_(c); - } - } + this->read_available([this](uint8_t c) { this->handle_char_(c); }); } void WTS01Sensor::dump_config() { LOG_SENSOR("", "WTS01 Sensor", this); } From 03bf2de7df3eacf011bc8fce61d3ff10f470a333 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 13:59:19 -1000 Subject: [PATCH 146/179] [components] Decode BLE fields with the shared helpers (#21082) --- .../airthings_ble/airthings_listener.cpp | 6 ++---- .../atc_mithermometer/atc_mithermometer.cpp | 5 +++-- esphome/components/b_parasite/b_parasite.cpp | 13 +++++++------ esphome/components/ble_device_base/ble_device.cpp | 15 ++++++--------- .../bluetooth_connection/bluetooth_connection.h | 7 +++---- .../bthome_mithermometer/bthome_ble.cpp | 4 ++-- .../esp32_ble_client/ble_client_base.cpp | 7 +++---- .../pvvx_mithermometer/pvvx_mithermometer.cpp | 7 ++++--- esphome/components/ruuvi_ble/ruuvi_ble.cpp | 15 ++++++++------- .../components/thermopro_ble/thermopro_ble.cpp | 8 ++++---- esphome/components/xiaomi_ble/xiaomi_ble.cpp | 8 ++++---- .../xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp | 2 +- .../components/xiaomi_miscale/xiaomi_miscale.cpp | 7 ++++--- 13 files changed, 51 insertions(+), 53 deletions(-) diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp index 5f0e2eb3ecf..c0762cb4c83 100644 --- a/esphome/components/airthings_ble/airthings_listener.cpp +++ b/esphome/components/airthings_ble/airthings_listener.cpp @@ -1,4 +1,5 @@ #include "airthings_listener.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -12,10 +13,7 @@ bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) if (it.data.size() < 4) continue; - uint32_t sn = it.data[0]; - sn |= ((uint32_t) it.data[1] << 8); - sn |= ((uint32_t) it.data[2] << 16); - sn |= ((uint32_t) it.data[3] << 24); + uint32_t sn = encode_value_le(it.data.data()); char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD(TAG, "Found AirThings device Serial:%" PRIu32 " (MAC: %s)", sn, device.address_str_to(addr_buf)); diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index 95b482c8404..a495cb9dd2e 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -1,4 +1,5 @@ #include "atc_mithermometer.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::atc_mithermometer { @@ -89,7 +90,7 @@ bool ATCMiThermometer::parse_message_(const std::vector &message, Parse } // temperature, 2 bytes, 16-bit signed integer (LE), 0.1 °C - const int16_t temperature = uint16_t(data[7]) | (uint16_t(data[6]) << 8); + const int16_t temperature = encode_value(&data[6]); result.temperature = temperature / 10.0f; // humidity, 1 byte, 8-bit unsigned integer, 1.0 % @@ -99,7 +100,7 @@ bool ATCMiThermometer::parse_message_(const std::vector &message, Parse result.battery_level = data[9]; // battery, 2 bytes, 16-bit unsigned integer, 0.001 V - const int16_t battery_voltage = uint16_t(data[11]) | (uint16_t(data[10]) << 8); + const int16_t battery_voltage = encode_value(&data[10]); result.battery_voltage = battery_voltage / 1.0e3f; return true; diff --git a/esphome/components/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp index 8bed59b3dbc..972d938c47b 100644 --- a/esphome/components/b_parasite/b_parasite.cpp +++ b/esphome/components/b_parasite/b_parasite.cpp @@ -1,4 +1,5 @@ #include "b_parasite.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::b_parasite { @@ -62,29 +63,29 @@ bool BParasite::parse_device(const ble_device_base::ESPBTDevice &device) { } // Battery voltage in millivolts. - uint16_t battery_millivolt = data[2] << 8 | data[3]; + uint16_t battery_millivolt = encode_value(&data[2]); float battery_voltage = battery_millivolt / 1000.0f; // Temperature in 1000 * Celsius (protocol v1) or 100 * Celsius (protocol v2). float temp_celsius; if (protocol_version == 1) { - uint16_t temp_millicelsius = data[4] << 8 | data[5]; + uint16_t temp_millicelsius = encode_value(&data[4]); temp_celsius = temp_millicelsius / 1000.0f; } else { - int16_t temp_centicelsius = data[4] << 8 | data[5]; + int16_t temp_centicelsius = encode_value(&data[4]); temp_celsius = temp_centicelsius / 100.0f; } // Relative air humidity in the range [0, 2^16). - uint16_t humidity = data[6] << 8 | data[7]; + uint16_t humidity = encode_value(&data[6]); float humidity_percent = (100.0f * humidity) / (1 << 16); // Relative soil moisture in [0 - 2^16). - uint16_t soil_moisture = data[8] << 8 | data[9]; + uint16_t soil_moisture = encode_value(&data[8]); float moisture_percent = (100.0f * soil_moisture) / (1 << 16); // Ambient light in lux. - float illuminance = has_illuminance ? data[16] << 8 | data[17] : 0.0f; + float illuminance = has_illuminance ? encode_value(&data[16]) : 0.0f; if (battery_voltage_ != nullptr) { battery_voltage_->publish_state(battery_voltage); diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index f7fa488aac7..1cb44c8ace7 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -493,13 +493,13 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x19: // Appearance if (ad_data_len >= 2) - this->appearance_ = static_cast(ad_data[0]) | (static_cast(ad_data[1]) << 8); + this->appearance_ = encode_value_le(ad_data); break; case 0x02: // Incomplete List of 16-bit Service UUIDs case 0x03: // Complete List of 16-bit Service UUIDs for (uint8_t i = 0; (i + 1) < ad_data_len; i += 2) { - uint16_t uuid = (static_cast(ad_data[i + 1]) << 8) | ad_data[i]; + uint16_t uuid = encode_value_le(&ad_data[i]); this->service_uuids_.push_back(ESPBTUUID::from_uint16(uuid)); } break; @@ -507,9 +507,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x04: // Incomplete List of 32-bit Service UUIDs case 0x05: // Complete List of 32-bit Service UUIDs for (uint8_t i = 0; (i + 3) < ad_data_len; i += 4) { - uint32_t uuid = (static_cast(ad_data[i + 3]) << 24) | - (static_cast(ad_data[i + 2]) << 16) | (static_cast(ad_data[i + 1]) << 8) | - ad_data[i]; + uint32_t uuid = encode_value_le(&ad_data[i]); this->service_uuids_.push_back(ESPBTUUID::from_uint32(uuid)); } break; @@ -522,7 +520,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0xFF: // Manufacturer Specific Data if (ad_data_len >= 2) { - uint16_t company_id = (static_cast(ad_data[1]) << 8) | ad_data[0]; + uint16_t company_id = encode_value_le(ad_data); ServiceData sd; sd.uuid = ESPBTUUID::from_uint16(company_id); sd.data.assign(ad_data + 2, ad_data + ad_data_len); @@ -532,7 +530,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x16: // Service Data — 16-bit UUID if (ad_data_len >= 2) { - uint16_t uuid = (static_cast(ad_data[1]) << 8) | ad_data[0]; + uint16_t uuid = encode_value_le(ad_data); ServiceData sd; sd.uuid = ESPBTUUID::from_uint16(uuid); sd.data.assign(ad_data + 2, ad_data + ad_data_len); @@ -542,8 +540,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { case 0x20: // Service Data — 32-bit UUID if (ad_data_len >= 4) { - uint32_t uuid = (static_cast(ad_data[3]) << 24) | (static_cast(ad_data[2]) << 16) | - (static_cast(ad_data[1]) << 8) | ad_data[0]; + uint32_t uuid = encode_value_le(ad_data); ServiceData sd; sd.uuid = ESPBTUUID::from_uint32(uuid); sd.data.assign(ad_data + 4, ad_data + ad_data_len); diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index b21d997b4fc..176a73dc33d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -4,6 +4,7 @@ #pragma once #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/components/ble_device_base/ble_client_state.h" #include "esphome/components/ble_device_base/ble_device.h" @@ -124,10 +125,8 @@ inline void fill_128bit_uuid_array(std::array &out, const ble_devic if (uuid.type() == ESPBTUUID::Type::UUID128) { const uint8_t *u = uuid.uuid128(); // out[0] = bytes 8-15 (big-endian), out[1] = bytes 0-7 (big-endian) - out[0] = ((uint64_t) u[15] << 56) | ((uint64_t) u[14] << 48) | ((uint64_t) u[13] << 40) | ((uint64_t) u[12] << 32) | - ((uint64_t) u[11] << 24) | ((uint64_t) u[10] << 16) | ((uint64_t) u[9] << 8) | ((uint64_t) u[8]); - out[1] = ((uint64_t) u[7] << 56) | ((uint64_t) u[6] << 48) | ((uint64_t) u[5] << 40) | ((uint64_t) u[4] << 32) | - ((uint64_t) u[3] << 24) | ((uint64_t) u[2] << 16) | ((uint64_t) u[1] << 8) | ((uint64_t) u[0]); + out[0] = encode_value_le(&u[8]); + out[1] = encode_value_le(u); return; } // 16/32-bit UUID inserted into the Bluetooth base UUID: diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index c055c430413..b37d2782d56 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -408,7 +408,7 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat } case 0x02: { // temperature if (this->temperature_ != nullptr) { - const int16_t raw = encode_uint16(value[1], value[0]); + const int16_t raw = encode_value_le(value); this->temperature_->publish_state(raw * 0.01f); reported = true; } @@ -424,7 +424,7 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat } case 0x45: { // temperature (0.1C), e.g. Shelly BLU H&T if (this->temperature_ != nullptr) { - const int16_t raw = encode_uint16(value[1], value[0]); + const int16_t raw = encode_value_le(value); this->temperature_->publish_state(raw * 0.1f); reported = true; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 6905ef82228..e627d58fc81 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -680,18 +680,17 @@ float BLEClientBase::parse_char_value(uint8_t *value, uint16_t length) { case 0xD: // int12. case 0xE: // int16. if (length > 2) { - return (float) ((int16_t) (value[1] << 8) + (int16_t) value[2]); + return (float) encode_value(&value[1]); } [[fallthrough]]; case 0xF: // int24. if (length > 3) { - return (float) ((int32_t) (value[1] << 16) + (int32_t) (value[2] << 8) + (int32_t) (value[3])); + return (float) encode_uint24(value[1], value[2], value[3]); } [[fallthrough]]; case 0x10: // int32. if (length > 4) { - return (float) ((int32_t) (value[1] << 24) + (int32_t) (value[2] << 16) + (int32_t) (value[3] << 8) + - (int32_t) (value[4])); + return (float) encode_value(&value[1]); } } ESP_LOGW(TAG, "[%d] [%s] Cannot parse characteristic value of type 0x%x length %d", this->connection_index_, diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp index 0d7f440f15e..462d64f937d 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp @@ -1,4 +1,5 @@ #include "pvvx_mithermometer.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::pvvx_mithermometer { @@ -96,15 +97,15 @@ bool PVVXMiThermometer::parse_message_(const std::vector &message, Pars } // int16_t temperature; // x 0.01 degree [6,7] - const int16_t temperature = int16_t(data[6]) | (int16_t(data[7]) << 8); + const int16_t temperature = encode_value_le(&data[6]); result.temperature = temperature / 1.0e2f; // uint16_t humidity; // x 0.01 % [8,9] - const int16_t humidity = uint16_t(data[8]) | (uint16_t(data[9]) << 8); + const int16_t humidity = encode_value_le(&data[8]); result.humidity = humidity / 1.0e2f; // uint16_t battery_mv; // mV [10,11] - const int16_t battery_voltage = uint16_t(data[10]) | (uint16_t(data[11]) << 8); + const int16_t battery_voltage = encode_value_le(&data[10]); result.battery_voltage = battery_voltage / 1.0e3f; // uint8_t battery_level; // 0..100 % [12] diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.cpp b/esphome/components/ruuvi_ble/ruuvi_ble.cpp index c781e282fe7..415885cfe2b 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.cpp +++ b/esphome/components/ruuvi_ble/ruuvi_ble.cpp @@ -1,4 +1,5 @@ #include "ruuvi_ble.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::ruuvi_ble { @@ -19,9 +20,9 @@ bool parse_ruuvi_data_byte(const ble_device_base::adv_data_t &adv_data, RuuviPar const float humidity = data[0] * 0.5f; const float pressure = (encode_uint16(data[3], data[4]) + 50000.0f) / 100.0f; - const float acceleration_x = static_cast(encode_uint16(data[5], data[6])) / 1000.0f; - const float acceleration_y = static_cast(encode_uint16(data[7], data[8])) / 1000.0f; - const float acceleration_z = static_cast(encode_uint16(data[9], data[10])) / 1000.0f; + const float acceleration_x = encode_value(&data[5]) / 1000.0f; + const float acceleration_y = encode_value(&data[7]) / 1000.0f; + const float acceleration_z = encode_value(&data[9]) / 1000.0f; const float battery_voltage = encode_uint16(data[11], data[12]) / 1000.0f; result.humidity = humidity; @@ -40,12 +41,12 @@ bool parse_ruuvi_data_byte(const ble_device_base::adv_data_t &adv_data, RuuviPar if (adv_data.size() != 24) return false; - const float temperature = static_cast(encode_uint16(data[0], data[1])) * 0.005f; + const float temperature = encode_value(data) * 0.005f; const float humidity = encode_uint16(data[2], data[3]) / 400.0f; const float pressure = (encode_uint16(data[4], data[5]) + 50000.0f) / 100.0f; - const float acceleration_x = static_cast(encode_uint16(data[6], data[7])) / 1000.0f; - const float acceleration_y = static_cast(encode_uint16(data[8], data[9])) / 1000.0f; - const float acceleration_z = static_cast(encode_uint16(data[10], data[11])) / 1000.0f; + const float acceleration_x = encode_value(&data[6]) / 1000.0f; + const float acceleration_y = encode_value(&data[8]) / 1000.0f; + const float acceleration_z = encode_value(&data[10]) / 1000.0f; const uint16_t power_info = encode_uint16(data[12], data[13]); const float battery_voltage = ((power_info >> 5) + 1600.0f) / 1000.0f; diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 38a58137aec..159e36e5554 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -1,5 +1,6 @@ #include "thermopro_ble.h" #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::thermopro_ble { @@ -113,16 +114,15 @@ void ThermoProBLE::update_device_type_(StringRef device_name) { } static inline uint16_t read_uint16(const uint8_t *data, std::size_t offset) { - return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8); + return encode_value_le(&data[offset]); } static inline int16_t read_int16(const uint8_t *data, std::size_t offset) { - return static_cast(read_uint16(data, offset)); + return encode_value_le(&data[offset]); } static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) { - return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8) | - (static_cast(data[offset + 2]) << 16) | (static_cast(data[offset + 3]) << 24); + return encode_value_le(&data[offset]); } // Battery calculation used with permission from: diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 90ea5c32094..1f752f55b10 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -38,12 +38,12 @@ bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_ } // temperature, 2 bytes, 16-bit signed integer (LE), 0.1 °C else if ((value_type == 0x1004) && (value_length == 2)) { - const int16_t temperature = encode_uint16(data[1], data[0]); + const int16_t temperature = encode_value_le(data); result.temperature = temperature / 10.0f; } // humidity, 2 bytes, 16-bit signed integer (LE), 0.1 % else if ((value_type == 0x1006) && (value_length == 2)) { - const int16_t humidity = encode_uint16(data[1], data[0]); + const int16_t humidity = encode_value_le(data); result.humidity = humidity / 10.0f; } // illuminance (+ motion), 3 bytes, 24-bit unsigned integer (LE), 1 lx @@ -69,8 +69,8 @@ bool parse_xiaomi_value(uint16_t value_type, const uint8_t *data, uint8_t value_ } // temperature + humidity, 4 bytes, 16-bit signed integer (LE) each, 0.1 °C, 0.1 % else if ((value_type == 0x100D) && (value_length == 4)) { - const int16_t temperature = encode_uint16(data[1], data[0]); - const int16_t humidity = encode_uint16(data[3], data[2]); + const int16_t temperature = encode_value_le(data); + const int16_t humidity = encode_value_le(&data[2]); result.temperature = temperature / 10.0f; result.humidity = humidity / 10.0f; } diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp index ac1541c386d..83ba611e6fb 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp @@ -35,7 +35,7 @@ bool XiaomiHHCCJCY10::parse_device(const ble_device_base::ESPBTDevice &device) { const uint8_t *data = service_data.data.data(); if (this->temperature_ != nullptr) { - const int16_t temperature = encode_uint16(data[1], data[2]); + const int16_t temperature = encode_value(&data[1]); this->temperature_->publish_state((float) temperature / 10.0f); } diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp index 438b9b55298..7c44eccd411 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp @@ -1,5 +1,6 @@ #include "xiaomi_miscale.h" #include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::xiaomi_miscale { @@ -92,7 +93,7 @@ bool XiaomiMiscale::parse_message_v1_(const std::vector &message, Parse const uint8_t *data = message.data(); // weight, 2 bytes, 16-bit unsigned integer, 1 kg - const int16_t weight = uint16_t(data[1]) | (uint16_t(data[2]) << 8); + const int16_t weight = encode_value_le(&data[1]); if (data[0] == 0x22 || data[0] == 0xa2) { result.weight = weight * 0.01f / 2.0f; // unit 'kg' } else if (data[0] == 0x12 || data[0] == 0xb2) { @@ -126,7 +127,7 @@ bool XiaomiMiscale::parse_message_v2_(const std::vector &message, Parse } // weight, 2 bytes, 16-bit unsigned integer, 1 kg - const int16_t weight = uint16_t(data[11]) | (uint16_t(data[12]) << 8); + const int16_t weight = encode_value_le(&data[11]); if (data[0] == 0x02) { result.weight = weight * 0.01f / 2.0f; // unit 'kg' } else if (data[0] == 0x03) { @@ -135,7 +136,7 @@ bool XiaomiMiscale::parse_message_v2_(const std::vector &message, Parse if (has_impedance) { // impedance, 2 bytes, 16-bit - const int16_t impedance = uint16_t(data[9]) | (uint16_t(data[10]) << 8); + const int16_t impedance = encode_value_le(&data[9]); result.impedance = impedance; if (impedance == 0 || impedance >= 3000) { From d9242520e3e421144f4dec716959f6501c8ce0ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:00:48 -1000 Subject: [PATCH 147/179] [components] Decode bytes with the shared helpers (#21080) --- esphome/components/am2320/am2320.cpp | 7 +--- esphome/components/apds9960/apds9960.cpp | 12 +++--- esphome/components/bh1900nux/bh1900nux.cpp | 3 +- esphome/components/bm8563/bm8563.cpp | 32 +++++---------- esphome/components/bm8563/bm8563.h | 3 -- .../components/bme280_base/bme280_base.cpp | 4 +- esphome/components/bme680/bme680.cpp | 19 +++++---- esphome/components/bmi270/bmi270.cpp | 14 +++---- .../components/bmp280_base/bmp280_base.cpp | 5 ++- .../components/dallas_temp/dallas_temp.cpp | 3 +- esphome/components/dps310/dps310.cpp | 41 ++++++------------- esphome/components/dps310/dps310.h | 1 - esphome/components/ds3231/ds3231.cpp | 3 +- .../ecocomfort2/ecocomfort2_hub.cpp | 2 +- esphome/components/epaper_spi/epaper_spi.cpp | 2 +- esphome/components/gcja5/gcja5.cpp | 5 +-- esphome/components/hlk_fm22x/hlk_fm22x.cpp | 6 +-- esphome/components/ina226/ina226.cpp | 14 ++----- esphome/components/ina226/ina226.h | 2 - .../components/ina2xx_base/ina2xx_base.cpp | 16 ++++---- esphome/components/it8951/it8951.cpp | 3 +- esphome/components/ld2450/ld2450.cpp | 19 +++------ esphome/components/ld2460/ld2460.cpp | 5 ++- esphome/components/ld6002b/ld6002b.cpp | 40 ++++++------------ esphome/components/ld6002b/ld6002b.h | 3 -- esphome/components/lm75b/lm75b.cpp | 3 +- esphome/components/max31888/max31888.cpp | 2 +- esphome/components/mpl3115a2/mpl3115a2.cpp | 2 +- esphome/components/nau7802/nau7802.cpp | 7 +--- esphome/components/pcf8574/pcf8574.cpp | 3 +- esphome/components/pmwcs3/pmwcs3.cpp | 20 ++++----- .../remote_base/abbwelcome_protocol.h | 8 ++-- esphome/components/rf_bridge/rf_bridge.cpp | 8 ++-- .../seeed_mr60bha2/seeed_mr60bha2.cpp | 21 +--------- esphome/components/senseair/senseair.cpp | 2 +- .../systa_bus/sensor/systa_bus_sensor.cpp | 2 +- .../components/vbus/sensor/vbus_sensor.cpp | 4 +- .../waveshare_io_ch32v003.cpp | 5 +-- esphome/components/whirlpool/whirlpool.cpp | 15 +++---- 39 files changed, 136 insertions(+), 230 deletions(-) diff --git a/esphome/components/am2320/am2320.cpp b/esphome/components/am2320/am2320.cpp index 1b3761fdac5..eccab7acfb5 100644 --- a/esphome/components/am2320/am2320.cpp +++ b/esphome/components/am2320/am2320.cpp @@ -69,12 +69,7 @@ bool AM2320Component::read_data_(uint8_t *data) { return false; } - uint16_t checksum; - - checksum = data[7] << 8; - checksum += data[6]; - - if (crc16(data, 6) != checksum) { + if (crc16(data, 6) != encode_value_le(&data[6])) { ESP_LOGW(TAG, "AM2320 Checksum invalid!"); return false; } diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp index c003e7a8bc9..dc7f8c8bffd 100644 --- a/esphome/components/apds9960/apds9960.cpp +++ b/esphome/components/apds9960/apds9960.cpp @@ -162,13 +162,13 @@ void APDS9960::read_color_data_(uint8_t status) { return; } - uint8_t raw[8]; - APDS9960_WARNING_CHECK(this->read_bytes(0x94, raw, 8), "Reading color values failed."); + uint16_t raw[4]; + APDS9960_WARNING_CHECK(this->read_bytes_16_le(0x94, raw, 4), "Reading color values failed."); - uint16_t uint_clear = (uint16_t(raw[1]) << 8) | raw[0]; - uint16_t uint_red = (uint16_t(raw[3]) << 8) | raw[2]; - uint16_t uint_green = (uint16_t(raw[5]) << 8) | raw[4]; - uint16_t uint_blue = (uint16_t(raw[7]) << 8) | raw[6]; + uint16_t uint_clear = raw[0]; + uint16_t uint_red = raw[1]; + uint16_t uint_green = raw[2]; + uint16_t uint_blue = raw[3]; float clear_perc = (uint_clear / float(UINT16_MAX)) * 100.0f; float red_perc = (uint_red / float(UINT16_MAX)) * 100.0f; diff --git a/esphome/components/bh1900nux/bh1900nux.cpp b/esphome/components/bh1900nux/bh1900nux.cpp index 6b7f1a35863..dc1b930675c 100644 --- a/esphome/components/bh1900nux/bh1900nux.cpp +++ b/esphome/components/bh1900nux/bh1900nux.cpp @@ -1,3 +1,4 @@ +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "bh1900nux.h" @@ -36,7 +37,7 @@ void BH1900NUXSensor::update() { // Combined raw value, unsigned and unaligned 16 bit // Temperature is represented in just 12 bits, shift needed - int16_t raw_temperature_register_value = encode_uint16(temperature_raw[0], temperature_raw[1]); + int16_t raw_temperature_register_value = encode_value(temperature_raw); raw_temperature_register_value >>= 4; float temperature_value = raw_temperature_register_value * SENSOR_RESOLUTION; // Apply sensor resolution diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index f83b98af115..9e8db2dd6ec 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -2,6 +2,7 @@ #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bm8563 { @@ -67,28 +68,17 @@ void BM8563::read_time() { time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); } -uint8_t BM8563::bcd2_to_byte_(uint8_t value) { - const uint8_t tmp = ((value & 0xF0) >> 0x4) * 10; - return tmp + (value & 0x0F); -} - -uint8_t BM8563::byte_to_bcd2_(uint8_t value) { - const uint8_t bcdhigh = value / 10; - value -= bcdhigh * 10; - return (bcdhigh << 4) | value; -} - void BM8563::get_time_(ESPTime &time) { uint8_t buf[3] = {0}; this->read_register(TIME_FIRST_REG, buf, 3); - time.second = this->bcd2_to_byte_(buf[0] & 0x7f); - time.minute = this->bcd2_to_byte_(buf[1] & 0x7f); - time.hour = this->bcd2_to_byte_(buf[2] & 0x3f); + time.second = bcd_to_uint8(buf[0] & 0x7f); + time.minute = bcd_to_uint8(buf[1] & 0x7f); + time.hour = bcd_to_uint8(buf[2] & 0x3f); } void BM8563::set_time_(const ESPTime &time) { - uint8_t buf[3] = {this->byte_to_bcd2_(time.second), this->byte_to_bcd2_(time.minute), this->byte_to_bcd2_(time.hour)}; + uint8_t buf[3] = {uint8_to_bcd(time.second), uint8_to_bcd(time.minute), uint8_to_bcd(time.hour)}; this->write_register_(TIME_FIRST_REG, buf, 3); } @@ -96,12 +86,12 @@ void BM8563::get_date_(ESPTime &time) { uint8_t buf[4] = {0}; this->read_register(DATE_FIRST_REG, buf, sizeof(buf)); - time.day_of_month = this->bcd2_to_byte_(buf[0] & 0x3f); + time.day_of_month = bcd_to_uint8(buf[0] & 0x3f); // 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); + time.month = bcd_to_uint8(buf[2] & 0x1f); - uint8_t year_byte = this->bcd2_to_byte_(buf[3] & 0xff); + uint8_t year_byte = bcd_to_uint8(buf[3] & 0xff); if (buf[2] & 0x80) { time.year = 1900 + year_byte; @@ -112,10 +102,10 @@ 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), + uint8_to_bcd(time.day_of_month), static_cast(time.day_of_week - 1), - this->byte_to_bcd2_(time.month), - this->byte_to_bcd2_(time.year % 100), + uint8_to_bcd(time.month), + uint8_to_bcd(time.year % 100), }; if (time.year < 2000) { diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h index b9b7a2c3276..db6d6c5082a 100644 --- a/esphome/components/bm8563/bm8563.h +++ b/esphome/components/bm8563/bm8563.h @@ -29,9 +29,6 @@ class BM8563 final : public time::RealTimeClock, public i2c::I2CDevice { void write_byte_(uint8_t reg, uint8_t value); void write_register_(uint8_t reg, const uint8_t *data, size_t len); optional read_register_(uint8_t reg); - - uint8_t bcd2_to_byte_(uint8_t value); - uint8_t byte_to_bcd2_(uint8_t value); }; } // namespace esphome::bm8563 diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index f922b1942b4..00e6f9edd25 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -236,7 +236,7 @@ void BME280Component::update() { }); } float BME280Component::read_temperature_(const uint8_t *data, int32_t *t_fine) { - int32_t adc = ((data[3] & 0xFF) << 16) | ((data[4] & 0xFF) << 8) | (data[5] & 0xFF); + int32_t adc = encode_uint24(data[3], data[4], data[5]); adc >>= 4; if (adc == 0x80000) { // temperature was disabled @@ -256,7 +256,7 @@ float BME280Component::read_temperature_(const uint8_t *data, int32_t *t_fine) { } float BME280Component::read_pressure_(const uint8_t *data, int32_t t_fine) { - int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); + int32_t adc = encode_uint24(data[0], data[1], data[2]); adc >>= 4; if (adc == 0x80000) { // pressure was disabled diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 3bc1f3ec1e2..e3297d38f55 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -1,5 +1,6 @@ #include "bme680.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bme680 { @@ -92,8 +93,8 @@ bool BME680Component::setup_registers_() { return false; } - this->calibration_.t1 = coeff2[9] << 8 | coeff2[8]; - this->calibration_.t2 = coeff1[2] << 8 | coeff1[1]; + this->calibration_.t1 = encode_value_le(&coeff2[8]); + this->calibration_.t2 = encode_value_le(&coeff1[1]); this->calibration_.t3 = coeff1[3]; this->calibration_.h1 = coeff2[2] << 4 | (coeff2[1] & 0x0F); @@ -104,19 +105,19 @@ bool BME680Component::setup_registers_() { this->calibration_.h6 = coeff2[6]; this->calibration_.h7 = coeff2[7]; - this->calibration_.p1 = coeff1[6] << 8 | coeff1[5]; - this->calibration_.p2 = coeff1[8] << 8 | coeff1[7]; + this->calibration_.p1 = encode_value_le(&coeff1[5]); + this->calibration_.p2 = encode_value_le(&coeff1[7]); this->calibration_.p3 = coeff1[9]; - this->calibration_.p4 = coeff1[12] << 8 | coeff1[11]; - this->calibration_.p5 = coeff1[14] << 8 | coeff1[13]; + this->calibration_.p4 = encode_value_le(&coeff1[11]); + this->calibration_.p5 = encode_value_le(&coeff1[13]); this->calibration_.p6 = coeff1[16]; this->calibration_.p7 = coeff1[15]; - this->calibration_.p8 = coeff1[20] << 8 | coeff1[19]; - this->calibration_.p9 = coeff1[22] << 8 | coeff1[21]; + this->calibration_.p8 = encode_value_le(&coeff1[19]); + this->calibration_.p9 = encode_value_le(&coeff1[21]); this->calibration_.p10 = coeff1[23]; this->calibration_.gh1 = coeff2[14]; - this->calibration_.gh2 = coeff2[12] << 8 | coeff2[13]; + this->calibration_.gh2 = encode_value(&coeff2[12]); this->calibration_.gh3 = coeff2[15]; uint8_t temp_var = 0; diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index 0e010b03118..cc233f4978b 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -314,9 +314,9 @@ bool BMI270Component::update_data(motion::MotionData &data) { }; float scale = ACCEL_SCALE[this->accel_range_]; - data.acceleration[motion::X_AXIS] = (int16_t) ((raw_data[1] << 8) | raw_data[0]) * scale; - data.acceleration[motion::Y_AXIS] = (int16_t) ((raw_data[3] << 8) | raw_data[2]) * scale; - data.acceleration[motion::Z_AXIS] = (int16_t) ((raw_data[5] << 8) | raw_data[4]) * scale; + data.acceleration[motion::X_AXIS] = encode_value_le(raw_data) * scale; + data.acceleration[motion::Y_AXIS] = encode_value_le(&raw_data[2]) * scale; + data.acceleration[motion::Z_AXIS] = encode_value_le(&raw_data[4]) * scale; // Gyroscope: registers 0x12–0x17 (6 bytes) // Scale: full-scale range / 2^15 @@ -326,15 +326,15 @@ bool BMI270Component::update_data(motion::MotionData &data) { static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8; scale = GYRO_SCALE[this->gyro_range_]; - data.angular_rate[motion::X_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 1] << 8) | raw_data[GYR_OFFS + 0]) * scale; - data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale; - data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale; + data.angular_rate[motion::X_AXIS] = encode_value_le(&raw_data[GYR_OFFS]) * scale; + data.angular_rate[motion::Y_AXIS] = encode_value_le(&raw_data[GYR_OFFS + 2]) * scale; + data.angular_rate[motion::Z_AXIS] = encode_value_le(&raw_data[GYR_OFFS + 4]) * scale; if (!this->temperature_callback_.empty()) { // Temperature: registers 0x22–0x23 // Formula from datasheet: T[°C] = raw / 512 + 23 static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; - int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + int16_t raw_t = encode_value_le(&raw_data[TEMP_OFFS]); float temperature = (raw_t / 512.0f) + 23.0f; this->temperature_callback_.call(temperature); } diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 10be0585210..69c4ca5e931 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -1,5 +1,6 @@ #include "bmp280_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::bmp280_base { @@ -176,7 +177,7 @@ float BMP280Component::read_temperature_(int32_t *t_fine) { if (!this->bmp_read_bytes(BMP280_REGISTER_TEMPDATA, data, 3)) return NAN; ESP_LOGV(TAG, "Read temperature data, raw: %02X %02X %02X", data[0], data[1], data[2]); - int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); + int32_t adc = encode_uint24(data[0], data[1], data[2]); adc >>= 4; if (adc == 0x80000) { // temperature was disabled @@ -199,7 +200,7 @@ float BMP280Component::read_pressure_(int32_t t_fine) { uint8_t data[3]; if (!this->bmp_read_bytes(BMP280_REGISTER_PRESSUREDATA, data, 3)) return NAN; - int32_t adc = ((data[0] & 0xFF) << 16) | ((data[1] & 0xFF) << 8) | (data[2] & 0xFF); + int32_t adc = encode_uint24(data[0], data[1], data[2]); adc >>= 4; if (adc == 0x80000) { // pressure was disabled diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 45101e4981f..4514624c75c 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -1,4 +1,5 @@ #include "dallas_temp.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::dallas_temp { @@ -136,7 +137,7 @@ bool DallasTemperatureSensor::check_scratch_pad_() { } float DallasTemperatureSensor::get_temp_c_() { - int16_t temp = (this->scratch_pad_[1] << 8) | this->scratch_pad_[0]; + int16_t temp = encode_value_le(this->scratch_pad_); if ((this->address_ & 0xff) == DALLAS_MODEL_DS18S20) { if (this->scratch_pad_[7] == 0) { return NAN; diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index 5b3272b1d5e..9241a825549 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -58,26 +58,16 @@ bool DPS310Component::setup_registers_() { return false; } - this->c0_ = // we only ever use c0/2, so just divide by 2 here to save time later - DPS310Component::twos_complement( - int16_t(((uint16_t) coef_data_raw[0] << 4) | (((uint16_t) coef_data_raw[1] >> 4) & 0x0F)), 12) / - 2; - - this->c1_ = - DPS310Component::twos_complement(int16_t((((uint16_t) coef_data_raw[1] & 0x0F) << 8) | coef_data_raw[2]), 12); - - this->c00_ = ((uint32_t) coef_data_raw[3] << 12) | ((uint32_t) coef_data_raw[4] << 4) | - (((uint32_t) coef_data_raw[5] >> 4) & 0x0F); - this->c00_ = DPS310Component::twos_complement(c00_, 20); - - this->c10_ = encode_uint24(coef_data_raw[5] & 0x0F, coef_data_raw[6], coef_data_raw[7]); - this->c10_ = DPS310Component::twos_complement(c10_, 20); - - this->c01_ = int16_t(((uint16_t) coef_data_raw[8] << 8) | (uint16_t) coef_data_raw[9]); - this->c11_ = int16_t(((uint16_t) coef_data_raw[10] << 8) | (uint16_t) coef_data_raw[11]); - this->c20_ = int16_t(((uint16_t) coef_data_raw[12] << 8) | (uint16_t) coef_data_raw[13]); - this->c21_ = int16_t(((uint16_t) coef_data_raw[14] << 8) | (uint16_t) coef_data_raw[15]); - this->c30_ = int16_t(((uint16_t) coef_data_raw[16] << 8) | (uint16_t) coef_data_raw[17]); + // we only ever use c0/2, so just divide by 2 here to save time later + this->c0_ = sign_extend<12>(encode_uint16(coef_data_raw[0], coef_data_raw[1]) >> 4) / 2; + this->c1_ = sign_extend<12>(encode_uint16(coef_data_raw[1], coef_data_raw[2])); + this->c00_ = sign_extend<20>(encode_uint24(coef_data_raw[3], coef_data_raw[4], coef_data_raw[5]) >> 4); + this->c10_ = sign_extend<20>(encode_uint24(coef_data_raw[5], coef_data_raw[6], coef_data_raw[7])); + this->c01_ = encode_value(&coef_data_raw[8]); + this->c11_ = encode_value(&coef_data_raw[10]); + this->c20_ = encode_value(&coef_data_raw[12]); + this->c21_ = encode_value(&coef_data_raw[14]); + this->c30_ = encode_value(&coef_data_raw[16]); return true; } @@ -133,7 +123,7 @@ void DPS310Component::read_pressure_() { return; } this->got_pres_ = true; - this->raw_pressure_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); + this->raw_pressure_ = sign_extend<24>(encode_uint24(bytes[0], bytes[1], bytes[2])); } void DPS310Component::read_temperature_() { @@ -143,7 +133,7 @@ void DPS310Component::read_temperature_() { return; } this->got_temp_ = true; - this->raw_temperature_ = DPS310Component::twos_complement(encode_uint24(bytes[0], bytes[1], bytes[2]), 24); + this->raw_temperature_ = sign_extend<24>(encode_uint24(bytes[0], bytes[1], bytes[2])); } // Calculations are taken from the datasheet which can be found here: @@ -168,11 +158,4 @@ void DPS310Component::calculate_values_(int32_t raw_temperature, int32_t raw_pre } } -int32_t DPS310Component::twos_complement(int32_t val, uint8_t bits) { - if (val & ((uint32_t) 1 << (bits - 1))) { - val -= (uint32_t) 1 << bits; - } - return val; -} - } // namespace esphome::dps310 diff --git a/esphome/components/dps310/dps310.h b/esphome/components/dps310/dps310.h index d761940ca99..a05a5ec056c 100644 --- a/esphome/components/dps310/dps310.h +++ b/esphome/components/dps310/dps310.h @@ -51,7 +51,6 @@ class DPS310Component final : public PollingComponent, public i2c::I2CDevice { void read_pressure_(); void read_temperature_(); void calculate_values_(int32_t raw_temperature, int32_t raw_pressure); - static int32_t twos_complement(int32_t val, uint8_t bits); sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; diff --git a/esphome/components/ds3231/ds3231.cpp b/esphome/components/ds3231/ds3231.cpp index 72cae0e3832..1033ddfb454 100644 --- a/esphome/components/ds3231/ds3231.cpp +++ b/esphome/components/ds3231/ds3231.cpp @@ -1,4 +1,5 @@ #include "ds3231.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" // Datasheet: @@ -37,7 +38,7 @@ bool DS3231Component::read_temperature(float &out) { return false; } // 10-bit two's complement, 0.25 degC per LSB. - int16_t value = static_cast((raw[0] << 8) | raw[1]) >> 6; + int16_t value = encode_value(raw) >> 6; out = value * 0.25f; return true; } diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp index 223648e3276..dcabf6c75c5 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -235,7 +235,7 @@ void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { ESP_LOGW(TAG, "[%s] C_STATE too short: %u bytes", this->parent_->address_str(), len); return; } - const float temperature = static_cast(encode_uint16(data[3], data[4])) / 100.0f; + const float temperature = encode_value(&data[3]) / 100.0f; const float humidity = encode_uint16(data[5], data[6]) / 100.0f; const uint16_t voc = encode_uint16(data[7], data[8]); ESP_LOGD(TAG, "[%s] C_STATE: mode=0x%02x, speed=0x%02x, %.2f °C, %.2f %%, %u ppb, direction=%u", diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index fccb5325a7f..488a068b0ff 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -397,7 +397,7 @@ void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_widt const Rgb565ToMono &to_mono) { const ptrdiff_t byte_step = SwapAxes ? step * static_cast(row_width) : 0; for (; count != 0; count--, source += 2) { - const uint16_t value = BigEndianSource ? (source[0] << 8) | source[1] : source[0] | (source[1] << 8); + const uint16_t value = BigEndianSource ? encode_uint16(source[0], source[1]) : encode_value_le(source); if (to_mono(value)) { *byte |= mask; } else { diff --git a/esphome/components/gcja5/gcja5.cpp b/esphome/components/gcja5/gcja5.cpp index b908fcd9e15..3a00af069ea 100644 --- a/esphome/components/gcja5/gcja5.cpp +++ b/esphome/components/gcja5/gcja5.cpp @@ -66,10 +66,7 @@ void GCJA5Component::loop() { } bool GCJA5Component::calculate_checksum_() { - uint8_t crc = 0; - - for (uint8_t i = 1; i < 30; i++) - crc = crc ^ this->rx_message_[i]; + uint8_t crc = xor_bytes(&this->rx_message_[1], 29); ESP_LOGVV(TAG, "Checksum packet was (0x%02X), calculated checksum was (0x%02X)", this->rx_message_[30], crc); diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 43311f3d968..caba0be9792 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -192,7 +192,7 @@ void HlkFm22xComponent::handle_note_(const uint8_t *data, size_t length) { int16_t info[8]; uint8_t offset = 1; for (int16_t &i : info) { - i = ((int16_t) data[offset + 1] << 8) | data[offset]; + i = encode_value_le(&data[offset]); offset += 2; } ESP_LOGV(TAG, "Face state: status: %d, left: %d, top: %d, right: %d, bottom: %d, yaw: %d, pitch: %d, roll: %d", @@ -259,7 +259,7 @@ void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) { ESP_LOGE(TAG, "VERIFY response too short: %zu bytes", length); break; } - int16_t face_id = ((int16_t) data[2] << 8) | data[3]; + int16_t face_id = encode_value(&data[2]); const char *name_ptr = reinterpret_cast(data + 4); ESP_LOGD(TAG, "Face verified. ID: %d, name: %.*s", face_id, (int) HLK_FM22X_NAME_SIZE, name_ptr); if (this->last_face_id_sensor_ != nullptr) { @@ -272,7 +272,7 @@ void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) { break; } case HlkFm22xCommand::ENROLL: { - int16_t face_id = ((int16_t) data[2] << 8) | data[3]; + int16_t face_id = encode_value(&data[2]); HlkFm22xFaceDirection direction = (HlkFm22xFaceDirection) data[4]; ESP_LOGI(TAG, "Face enrolled. ID: %d, Direction: 0x%.2X", face_id, direction); this->enrollment_done_callback_.call(face_id, (uint8_t) direction); diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index 987ce25d60b..2548f6312e7 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -1,4 +1,5 @@ #include "ina226.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" #include @@ -109,7 +110,7 @@ void INA226Component::update() { return; } // Convert for 2's compliment and signed value (though always positive) - float bus_voltage_v = this->twos_complement_(raw_bus_voltage, 16); + float bus_voltage_v = sign_extend<16>(raw_bus_voltage); bus_voltage_v *= 0.00125f; this->bus_voltage_sensor_->publish_state(bus_voltage_v); } @@ -121,7 +122,7 @@ void INA226Component::update() { return; } // Convert for 2's compliment and signed value - float shunt_voltage_v = this->twos_complement_(raw_shunt_voltage, 16); + float shunt_voltage_v = sign_extend<16>(raw_shunt_voltage); shunt_voltage_v *= 0.0000025f; this->shunt_voltage_sensor_->publish_state(shunt_voltage_v); } @@ -133,7 +134,7 @@ void INA226Component::update() { return; } // Convert for 2's compliment and signed value - float current_ma = this->twos_complement_(raw_current, 16); + float current_ma = sign_extend<16>(raw_current); current_ma *= (this->calibration_lsb_ / 1000.0f); this->current_sensor_->publish_state(current_ma / 1000.0f); } @@ -151,11 +152,4 @@ void INA226Component::update() { this->status_clear_warning(); } -int32_t INA226Component::twos_complement_(int32_t val, uint8_t bits) { - if (val & ((uint32_t) 1 << (bits - 1))) { - val -= (uint32_t) 1 << bits; - } - return val; -} - } // namespace esphome::ina226 diff --git a/esphome/components/ina226/ina226.h b/esphome/components/ina226/ina226.h index 00d62fad760..6fc7c20dd73 100644 --- a/esphome/components/ina226/ina226.h +++ b/esphome/components/ina226/ina226.h @@ -68,8 +68,6 @@ class INA226Component final : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *shunt_voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; sensor::Sensor *power_sensor_{nullptr}; - - int32_t twos_complement_(int32_t val, uint8_t bits); }; } // namespace esphome::ina226 diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 4c36870b680..16b37568d91 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -385,10 +385,10 @@ bool INA2XX::read_shunt_voltage_mv_(float &volt_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_VSHUNT, 3, raw); raw >>= 4; - volt_reading = this->two_complement_(raw, 20); + volt_reading = sign_extend<20>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_VSHUNT, 2, raw); - volt_reading = this->two_complement_(raw, 16); + volt_reading = sign_extend<16>(raw); } if (ret) { @@ -412,10 +412,10 @@ bool INA2XX::read_bus_voltage_(float &volt_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_VBUS, 3, raw); raw >>= 4; - volt_reading = this->two_complement_(raw, 20); + volt_reading = sign_extend<20>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_VBUS, 2, raw); - volt_reading = this->two_complement_(raw, 16); + volt_reading = sign_extend<16>(raw); } if (ret) { volt_out = this->cfg_.vbus_lsb * (float) volt_reading; @@ -436,11 +436,11 @@ bool INA2XX::read_die_temp_c_(float &temp_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_DIETEMP, 2, raw); - temp_reading = this->two_complement_(raw, 16); + temp_reading = sign_extend<16>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_DIETEMP, 2, raw); raw >>= 4; - temp_reading = this->two_complement_(raw, 12); + temp_reading = sign_extend<12>(raw); } if (ret) { temp_out = this->cfg_.die_temp_lsb * (float) temp_reading; @@ -461,10 +461,10 @@ bool INA2XX::read_current_a_(float &s_out) { if (this->ina_model_ == INAModel::INA_228 || this->ina_model_ == INAModel::INA_229) { ret = this->read_unsigned_(RegisterMap::REG_CURRENT, 3, raw); raw >>= 4; - amps_reading = this->two_complement_(raw, 20); + amps_reading = sign_extend<20>(raw); } else { ret = this->read_unsigned_(RegisterMap::REG_CURRENT, 2, raw); - amps_reading = this->two_complement_(raw, 16); + amps_reading = sign_extend<16>(raw); } ESP_LOGV(TAG, "read_current_a_ ret=%s. current_lsb=%f. reading_lsb=%f", OKFAILED(ret), this->current_lsb_, diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index e0d6f683f0d..276f91eed59 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -982,8 +982,7 @@ void HOT IT8951Display::draw_pixels_internal(int x_start, int y_start, int w, in switch (bitness) { case COLOR_BITNESS_565: { const size_t i = source_idx * 2; - color_value = big_endian ? (static_cast(ptr[i]) << 8) | ptr[i + 1] - : ptr[i] | (static_cast(ptr[i + 1]) << 8); + color_value = big_endian ? encode_uint16(ptr[i], ptr[i + 1]) : encode_value_le(&ptr[i]); break; } case COLOR_BITNESS_888: { diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 52327d309e3..e14be5ac968 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -164,15 +164,6 @@ static inline int16_t decode_speed(uint8_t low_byte, uint8_t high_byte) { return speed * 10; // mm/s } -static inline int16_t hex_to_signed_int(const uint8_t *buffer, uint8_t offset) { - uint16_t hex_val = (buffer[offset + 1] << 8) | buffer[offset]; - int16_t dec_val = static_cast(hex_val); - if (dec_val & 0x8000) { - dec_val -= 65536; - } - return dec_val; -} - static inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) { return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0; } @@ -389,10 +380,10 @@ void LD2450Component::process_zone_() { uint8_t index, start; for (index = 0; index < MAX_ZONES; index++) { start = 12 + index * 8; - this->zone_config_[index].x1 = ld2450::hex_to_signed_int(this->buffer_data_, start); - this->zone_config_[index].y1 = ld2450::hex_to_signed_int(this->buffer_data_, start + 2); - this->zone_config_[index].x2 = ld2450::hex_to_signed_int(this->buffer_data_, start + 4); - this->zone_config_[index].y2 = ld2450::hex_to_signed_int(this->buffer_data_, start + 6); + this->zone_config_[index].x1 = encode_value_le(&this->buffer_data_[start]); + this->zone_config_[index].y1 = encode_value_le(&this->buffer_data_[start + 2]); + this->zone_config_[index].x2 = encode_value_le(&this->buffer_data_[start + 4]); + this->zone_config_[index].y2 = encode_value_le(&this->buffer_data_[start + 6]); #ifdef USE_NUMBER // only one null check as all coordinates are required for a single zone if (this->zone_numbers_[index].x1 != nullptr) { @@ -505,7 +496,7 @@ void LD2450Component::handle_periodic_data_() { #ifdef USE_SENSOR // RESOLUTION start = TARGET_RESOLUTION + index * 8; - res = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start]; + res = encode_value_le(&this->buffer_data_[start]); #endif // SPEED start = TARGET_SPEED + index * 8; diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp index abeb6779cf4..c2d469575e7 100644 --- a/esphome/components/ld2460/ld2460.cpp +++ b/esphome/components/ld2460/ld2460.cpp @@ -1,6 +1,7 @@ #include "ld2460.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include #include @@ -117,8 +118,8 @@ void LD2460Component::handle_periodic_data_() { continue; } const uint8_t *p = &data[7 + i * 4]; - auto raw_x = static_cast(encode_uint16(p[1], p[0])); - auto raw_y = static_cast(encode_uint16(p[3], p[2])); + auto raw_x = encode_value_le(p); + auto raw_y = encode_value_le(&p[2]); if (raw_x == t.last_x && raw_y == t.last_y) { continue; } diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 3418d007ae4..51682358e7c 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -203,22 +203,8 @@ static bool is_expected_control_report(uint32_t command, uint16_t type) { } #endif -uint16_t LD6002BComponent::read_u16_be(const uint8_t *data) { return (static_cast(data[0]) << 8) | data[1]; } - -uint32_t LD6002BComponent::read_u32_le(const uint8_t *data) { - return static_cast(data[0]) | (static_cast(data[1]) << 8) | - (static_cast(data[2]) << 16) | (static_cast(data[3]) << 24); -} - -int32_t LD6002BComponent::read_int32_le(const uint8_t *data) { - uint32_t raw = read_u32_le(data); - int32_t value; - std::memcpy(&value, &raw, sizeof(value)); - return value; -} - float LD6002BComponent::read_f32_le(const uint8_t *data) { - uint32_t raw = read_u32_le(data); + uint32_t raw = encode_value_le(data); float value; std::memcpy(&value, &raw, sizeof(value)); return value; @@ -537,9 +523,9 @@ void LD6002BComponent::parse_byte_(uint8_t byte) { this->header_xor_ ^= byte; this->header_pos_++; if (this->header_pos_ == 6) { - this->frame_id_ = read_u16_be(this->data_buf_); - this->data_len_ = read_u16_be(this->data_buf_ + 2); - this->frame_type_ = read_u16_be(this->data_buf_ + 4); + this->frame_id_ = encode_value(this->data_buf_); + this->data_len_ = encode_value(this->data_buf_ + 2); + this->frame_type_ = encode_value(this->data_buf_ + 4); // The length is only trustworthy once the header checksum has been verified, so just // remember that the frame is oversized and let the HCK state act on it. this->frame_oversize_ = this->data_len_ > this->max_data_len_; @@ -625,7 +611,7 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ #ifdef ESPHOME_LOG_HAS_VERBOSE const uint32_t active_control_command = (this->command_active_ && this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) - ? read_u32_le(this->active_command_.data.data()) + ? encode_value_le(this->active_command_.data.data()) : 0; if (active_control_command != 0 && is_expected_control_report(active_control_command, type)) { ESP_LOGV(TAG, "Received %s (0x%04X) while waiting for %s (0x%02" PRIX32 ") ACK", frame_type_name(type), type, @@ -691,7 +677,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) if (len < 4) return; - uint32_t target_num = read_u32_le(data); + uint32_t target_num = encode_value_le(data); uint16_t available = (len - 4) / TARGET_DATA_LEN; // Un-narrowed: a report of e.g. 256 targets must not truncate to 0 and read as "absent". const uint32_t reported = std::min(target_num, available); @@ -704,7 +690,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) std::array slot_wire{}; for (uint8_t i = 0; i < count; i++) { uint16_t cluster_offset = 4 + (i * TARGET_DATA_LEN) + 16; - wire_cluster[i] = static_cast(read_u32_le(data + cluster_offset)); + wire_cluster[i] = encode_value_le(data + cluster_offset); } for (uint8_t i = 0; i < count; i++) { for (uint8_t s = 0; s < MAX_TARGETS; s++) { @@ -761,7 +747,7 @@ void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) float x = read_f32_le(data + offset + 0); float y = read_f32_le(data + offset + 4); float z = read_f32_le(data + offset + 8); - int32_t dop_idx = read_int32_le(data + offset + 12); + int32_t dop_idx = encode_value_le(data + offset + 12); int32_t cluster_id = this->slot_cluster_[i]; TargetSensors &target = this->targets_[i]; if (target.x != nullptr) { @@ -811,7 +797,7 @@ void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { return; #ifdef USE_SENSOR - uint32_t point_num = read_u32_le(data); + uint32_t point_num = encode_value_le(data); if (this->point_count_sensor_ != nullptr) { if (point_num != this->last_point_count_) { this->point_count_sensor_->publish_state(point_num); @@ -832,7 +818,7 @@ void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) this->area_presence_any_ = false; for (uint8_t i = 0; i < AREA_COUNT; i++) { - uint32_t state = read_u32_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); + uint32_t state = encode_value_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); bool present = state != 0; this->area_presence_any_ = this->area_presence_any_ || present; #ifdef USE_BINARY_SENSOR @@ -903,7 +889,7 @@ void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { if (len < 4) return; #ifdef USE_NUMBER - uint32_t delay = read_u32_le(data); + uint32_t delay = encode_value_le(data); this->publish_number_clamped_(this->hold_delay_number_, delay); #endif } @@ -978,7 +964,7 @@ void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint1 if (len < 4) return; #ifdef USE_NUMBER - uint32_t sleep_ms = read_u32_le(data); + uint32_t sleep_ms = encode_value_le(data); this->publish_number_clamped_(this->low_power_sleep_number_, sleep_ms); #endif } @@ -1115,7 +1101,7 @@ void LD6002BComponent::process_command_queue_() { if (this->command_sent_ && now - this->last_send_ms_ >= ack_timeout) { const uint32_t active_control_command = (this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) - ? read_u32_le(this->active_command_.data.data()) + ? encode_value_le(this->active_command_.data.data()) : 0; if (this->retries_left_ > 0) { #ifdef ESPHOME_LOG_HAS_VERBOSE diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index c52f770d6e9..663c73363ef 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -329,9 +329,6 @@ class LD6002BComponent : public Component, public uart::UARTDevice { void apply_area_config_(); void wake_(); - static uint16_t read_u16_be(const uint8_t *data); - static uint32_t read_u32_le(const uint8_t *data); - static int32_t read_int32_le(const uint8_t *data); static float read_f32_le(const uint8_t *data); static void write_u32_le(uint8_t *data, uint32_t value); static void write_int32_le(uint8_t *data, int32_t value); diff --git a/esphome/components/lm75b/lm75b.cpp b/esphome/components/lm75b/lm75b.cpp index 388f5dfd93a..17135652088 100644 --- a/esphome/components/lm75b/lm75b.cpp +++ b/esphome/components/lm75b/lm75b.cpp @@ -1,4 +1,5 @@ #include "lm75b.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -21,7 +22,7 @@ void LM75BComponent::update() { return; } // Obtain combined 16-bit value - int16_t raw_temperature = (buff[0] << 8) | buff[1]; + int16_t raw_temperature = encode_value(buff); // Read the 11-bit raw temperature value raw_temperature >>= 5; // Publish the temperature in °C diff --git a/esphome/components/max31888/max31888.cpp b/esphome/components/max31888/max31888.cpp index 5ff8e6f1ef0..f8787e71d50 100644 --- a/esphome/components/max31888/max31888.cpp +++ b/esphome/components/max31888/max31888.cpp @@ -88,7 +88,7 @@ bool MAX31888Sensor::read_temperature_(int16_t &raw) { this->status_set_warning(LOG_STR("checksum invalid")); return false; } - raw = static_cast(encode_uint16(frame[3], frame[4])); + raw = encode_value(&frame[3]); return true; } diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index 58f346ab77a..2e0dd7bd2bc 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -61,7 +61,7 @@ void MPL3115A2Component::update() { if (this->pressure_ != nullptr) this->pressure_->publish_state(pressure); } - int16_t t = encode_uint16(buffer[3], buffer[4]); + int16_t t = encode_value(&buffer[3]); float temperature = float(t) / 256.0f; if (this->temperature_ != nullptr) this->temperature_->publish_state(temperature); diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index cc24353782e..8c0acbbe434 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -1,5 +1,6 @@ #include "nau7802.h" #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -226,11 +227,7 @@ int32_t NAU7802Sensor::read_value_(uint8_t start_reg, size_t size) { for (size_t i = 0; i < size; i++) { result |= data[i] << (size - 1 - i) * 8; } - // extend sign bit - if (result & 0x800000 && size == 3) { - result |= 0xFF000000; - } - return result; + return size == 3 ? sign_extend<24>(result) : result; } bool NAU7802Sensor::calibrate_(enum NAU7802CalibrationModes mode) { diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 15ed2f8650e..70068ea84da 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -1,4 +1,5 @@ #include "pcf8574.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::pcf8574 { @@ -90,7 +91,7 @@ bool PCF8574Component::read_gpio_() { this->status_set_warning(); return false; } - this->input_mask_ = (uint16_t(data[1]) << 8) | data[0]; + this->input_mask_ = encode_value_le(data); this->status_clear_warning(); return true; } diff --git a/esphome/components/pmwcs3/pmwcs3.cpp b/esphome/components/pmwcs3/pmwcs3.cpp index 60cc4da0e4c..1eac0244469 100644 --- a/esphome/components/pmwcs3/pmwcs3.cpp +++ b/esphome/components/pmwcs3/pmwcs3.cpp @@ -72,32 +72,32 @@ void PMWCS3Component::read_data_() { // Wait for the sensor to be ready. // 80ms empirically determined (conservative). this->set_timeout(80, [this] { - uint8_t data[8]; + uint16_t data[4]; float e25, ec, temperature, vwc; - if (!this->read_bytes(PMWCS3_REG_GET_DATA, (uint8_t *) &data, 8)) { + if (!this->read_bytes_16_le(PMWCS3_REG_GET_DATA, data, 4)) { ESP_LOGVV(TAG, "Reading PMWCS3_REG_GET_DATA failed"); this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (this->e25_sensor_ != nullptr) { - e25 = ((data[1] << 8) | data[0]) / 100.0; + e25 = data[0] / 100.0; this->e25_sensor_->publish_state(e25); - ESP_LOGVV(TAG, "e25: data[0]=%d, data[1]=%d, result=%f", data[0], data[1], e25); + ESP_LOGVV(TAG, "e25: raw=%u, result=%f", data[0], e25); } if (this->ec_sensor_ != nullptr) { - ec = ((data[3] << 8) | data[2]) / 10.0; + ec = data[1] / 10.0; this->ec_sensor_->publish_state(ec); - ESP_LOGVV(TAG, "ec: data[2]=%d, data[3]=%d, result=%f", data[2], data[3], ec); + ESP_LOGVV(TAG, "ec: raw=%u, result=%f", data[1], ec); } if (this->temperature_sensor_ != nullptr) { - temperature = ((data[5] << 8) | data[4]) / 100.0; + temperature = data[2] / 100.0; this->temperature_sensor_->publish_state(temperature); - ESP_LOGVV(TAG, "temp: data[4]=%d, data[5]=%d, result=%f", data[4], data[5], temperature); + ESP_LOGVV(TAG, "temp: raw=%u, result=%f", data[2], temperature); } if (this->vwc_sensor_ != nullptr) { - vwc = ((data[7] << 8) | data[6]) / 10.0; + vwc = data[3] / 10.0; this->vwc_sensor_->publish_state(vwc); - ESP_LOGVV(TAG, "vwc: data[6]=%d, data[7]=%d, result=%f", data[6], data[7], vwc); + ESP_LOGVV(TAG, "vwc: raw=%u, result=%f", data[3], vwc); } }); } diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index ecd27a95a56..2228f41dc10 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -91,9 +91,9 @@ class ABBWelcomeData { } uint32_t get_destination_address() const { if (this->get_address_length() == 2) { - return (this->data_[4] << 8) + this->data_[5]; + return encode_uint16(this->data_[4], this->data_[5]); } - return (this->data_[4] << 16) + (this->data_[5] << 8) + this->data_[6]; + return encode_uint24(this->data_[4], this->data_[5], this->data_[6]); } void set_source_address(uint32_t address) { if (this->get_address_length() == 2) { @@ -107,9 +107,9 @@ class ABBWelcomeData { } uint32_t get_source_address() const { if (this->get_address_length() == 2) { - return (this->data_[6] << 8) + this->data_[7]; + return encode_uint16(this->data_[6], this->data_[7]); } - return (this->data_[7] << 16) + (this->data_[8] << 8) + this->data_[9]; + return encode_uint24(this->data_[7], this->data_[8], this->data_[9]); } void set_message_id(uint8_t message_id) { this->data_[4 + 2 * this->get_address_length()] = message_id; } /// A configured id, which finalize() keeps instead of picking one diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index 86daaf4b08b..1eb2680e9c8 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -56,10 +56,10 @@ bool RFBridgeComponent::parse_bridge_byte_(uint8_t byte) { return true; RFBridgeData data; - data.sync = (raw[2] << 8) | raw[3]; - data.low = (raw[4] << 8) | raw[5]; - data.high = (raw[6] << 8) | raw[7]; - data.code = (raw[8] << 16) | (raw[9] << 8) | raw[10]; + data.sync = encode_uint16(raw[2], raw[3]); + data.low = encode_uint16(raw[4], raw[5]); + data.high = encode_uint16(raw[6], raw[7]); + data.code = encode_uint24(raw[8], raw[9], raw[10]); if (action == RF_CODE_LEARN_OK) { ESP_LOGD(TAG, "Learning success"); diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index eec4e7afa6b..199c25d9390 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -48,25 +48,6 @@ void MR60BHA2Component::loop() { } } -/** - * @brief Calculate the checksum for a byte array. - * - * This function calculates the checksum for the provided byte array using an - * XOR-based checksum algorithm. - * - * @param data The byte array to calculate the checksum for. - * @param len The length of the byte array. - * @return The calculated checksum. - */ -static uint8_t calculate_checksum(const uint8_t *data, size_t len) { - uint8_t checksum = 0; - for (size_t i = 0; i < len; i++) { - checksum ^= data[i]; - } - checksum = ~checksum; - return checksum; -} - /** * @brief Validate the checksum of a byte array. * @@ -79,7 +60,7 @@ static uint8_t calculate_checksum(const uint8_t *data, size_t len) { * @return True if the checksum is valid, false otherwise. */ static bool validate_checksum(const uint8_t *data, size_t len, uint8_t expected_checksum) { - return calculate_checksum(data, len) == expected_checksum; + return static_cast(~xor_bytes(data, len)) == expected_checksum; } bool MR60BHA2Component::validate_message_() { diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index 858510f4277..d5849ddfc0a 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -43,7 +43,7 @@ void SenseAirComponent::update() { } uint16_t calc_checksum = crc16(response, 11); - uint16_t resp_checksum = (uint16_t(response[12]) << 8) | response[11]; + uint16_t resp_checksum = encode_value_le(&response[11]); if (resp_checksum != calc_checksum) { ESP_LOGW(TAG, "SenseAir checksum doesn't match: 0x%02X!=0x%02X", resp_checksum, calc_checksum); this->status_set_warning(); diff --git a/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp index 5d9890eca51..19f05e95b77 100644 --- a/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.cpp @@ -7,7 +7,7 @@ namespace esphome::systa_bus { ESPHOME_LOG_TAG(TAG, "systa_bus.sensor"); static int16_t get_i16be(std::span message, size_t start) { - return static_cast(encode_uint16(message[start], message[start + 1])); + return encode_value(&message[start]); } void SystaSolarAquaSensor::dump_config() { diff --git a/esphome/components/vbus/sensor/vbus_sensor.cpp b/esphome/components/vbus/sensor/vbus_sensor.cpp index 5c111c22942..000b69667dc 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.cpp +++ b/esphome/components/vbus/sensor/vbus_sensor.cpp @@ -7,11 +7,11 @@ namespace esphome::vbus { ESPHOME_LOG_TAG(TAG, "vbus.sensor"); static inline uint16_t get_u16(std::vector &message, int start) { - return (message[start + 1] << 8) + message[start]; + return encode_value_le(&message[start]); } static inline int16_t get_i16(std::vector &message, int start) { - return (int16_t) ((message[start + 1] << 8) + message[start]); + return encode_value_le(&message[start]); } void DeltaSolBSPlusSensor::dump_config() { diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 0347cfa083c..5e16004dc2c 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -51,12 +51,11 @@ uint16_t WaveshareIOCH32V003Component::get_adc_value() { if (this->is_failed()) return 0; - uint8_t data[2]; - if (!this->read_bytes(IO_EXTENSION_ADC_ADDR, data, 2)) { + uint16_t adc_value; + if (!this->read_byte_16_le(IO_EXTENSION_ADC_ADDR, &adc_value)) { this->status_set_warning(LOG_STR("Failed to read ADC register")); return 0; } - uint16_t adc_value = (data[1] << 8) | data[0]; this->status_clear_warning(); return adc_value; } diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index e341ce04bbc..59b48014579 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -1,4 +1,5 @@ #include "whirlpool.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::whirlpool { @@ -136,10 +137,8 @@ void WhirlpoolClimate::transmit_state() { } // Checksum - for (uint8_t i = 2; i < 13; i++) - remote_state[13] ^= remote_state[i]; - for (uint8_t i = 14; i < 20; i++) - remote_state[20] ^= remote_state[i]; + remote_state[13] = xor_bytes(&remote_state[2], 11, remote_state[13]); + remote_state[20] = xor_bytes(&remote_state[14], 6, remote_state[20]); ESP_LOGV(TAG, "Sending: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X " @@ -269,13 +268,9 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum13 = 0; - uint8_t checksum20 = 0; // Calculate checksum and compare with signal value. - for (uint8_t i = 2; i < 13; i++) - checksum13 ^= remote_state[i]; - for (uint8_t i = 14; i < 20; i++) - checksum20 ^= remote_state[i]; + uint8_t checksum13 = xor_bytes(&remote_state[2], 11); + uint8_t checksum20 = xor_bytes(&remote_state[14], 6); if (checksum13 != remote_state[13] || (!skip_footer && checksum20 != remote_state[20])) { ESP_LOGVV(TAG, "Checksum fail"); From 84ce39e493220ea19c9ffe65717b914786faff9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:05:37 -1000 Subject: [PATCH 148/179] [components] Use the codegen helpers in the remaining components (#21078) --- esphome/components/airton/climate.py | 19 ++++++++++--------- esphome/components/am43/sensor/__init__.py | 5 +---- esphome/components/bedjet/__init__.py | 6 ++---- .../components/bluetooth_proxy/__init__.py | 6 ++---- esphome/components/ch422g/__init__.py | 3 +-- esphome/components/ch423/__init__.py | 3 +-- esphome/components/dlms_meter/__init__.py | 15 +++------------ esphome/components/ds1307/time.py | 6 +----- esphome/components/ds248x/one_wire.py | 3 +-- esphome/components/epaper_spi/display.py | 6 +----- esphome/components/ethernet/__init__.py | 3 +-- esphome/components/hbridge/fan/__init__.py | 11 +++-------- esphome/components/ina260/sensor.py | 6 +----- esphome/components/ld6002b/__init__.py | 4 +--- esphome/components/light/__init__.py | 4 +--- esphome/components/mcp23016/__init__.py | 3 +-- esphome/components/mcp23xxx_base/__init__.py | 3 +-- esphome/components/mcp9600/sensor.py | 6 +----- esphome/components/midea/climate.py | 3 +-- esphome/components/mipi_spi/display.py | 13 ++----------- esphome/components/mipi_spi/light/__init__.py | 4 ++-- esphome/components/modbus/__init__.py | 7 ++----- esphome/components/modbus_client/__init__.py | 3 +-- esphome/components/mt6701_i2c/__init__.py | 4 +--- esphome/components/mt6701_spi/__init__.py | 5 +---- esphome/components/nextion/display.py | 7 +------ esphome/components/pca6416a/__init__.py | 3 +-- esphome/components/pca9505/__init__.py | 3 +-- esphome/components/pca9554/__init__.py | 3 +-- esphome/components/pcf85063/time.py | 6 +----- esphome/components/pcf8563/time.py | 6 +----- esphome/components/pcf8574/__init__.py | 3 +-- esphome/components/pcm5122/audio_dac.py | 4 +--- esphome/components/pi4ioe5v6408/__init__.py | 3 +-- esphome/components/rx8025t/time.py | 6 +----- esphome/components/seeed_mr24hpc1/__init__.py | 10 +--------- esphome/components/speed/fan/__init__.py | 13 +++---------- esphome/components/tca9555/__init__.py | 3 +-- .../components/template/climate/__init__.py | 7 ++----- esphome/components/uart/__init__.py | 15 ++++----------- esphome/components/zigbee/__init__.py | 4 +--- esphome/components/zigbee/zigbee_zephyr.py | 3 +-- 42 files changed, 66 insertions(+), 184 deletions(-) diff --git a/esphome/components/airton/climate.py b/esphome/components/airton/climate.py index fdfef45aaa4..bde8eca0c04 100644 --- a/esphome/components/airton/climate.py +++ b/esphome/components/airton/climate.py @@ -16,15 +16,16 @@ CONF_VERTICAL_DIRECTION = "vertical_direction" VerticalDirection = airton_ns.enum("VerticalDirection", is_class=True) # In the order of the enum, so the option index is the direction value -VERTICAL_DIRECTIONS = { - "off": VerticalDirection.VERTICAL_DIRECTION_OFF, - "swing": VerticalDirection.VERTICAL_DIRECTION_SWING, - "up": VerticalDirection.VERTICAL_DIRECTION_UP, - "middle-up": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_UP, - "middle": VerticalDirection.VERTICAL_DIRECTION_MIDDLE, - "middle-down": VerticalDirection.VERTICAL_DIRECTION_MIDDLE_DOWN, - "down": VerticalDirection.VERTICAL_DIRECTION_DOWN, -} +VERTICAL_DIRECTIONS = VerticalDirection.options( + "off", + "swing", + "up", + "middle-up", + "middle", + "middle-down", + "down", + prefix="VERTICAL_DIRECTION_", +) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(AirtonClimate).extend( { diff --git a/esphome/components/am43/sensor/__init__.py b/esphome/components/am43/sensor/__init__.py index ef746f3e52f..d988c6dbec6 100644 --- a/esphome/components/am43/sensor/__init__.py +++ b/esphome/components/am43/sensor/__init__.py @@ -3,7 +3,6 @@ from esphome.components import ble_client, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, - CONF_ID, CONF_ILLUMINANCE, DEVICE_CLASS_BATTERY, ENTITY_CATEGORY_DIAGNOSTIC, @@ -44,9 +43,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_client.register_ble_node(var, config) + var = await ble_client.new_ble_node_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BATTERY_LEVEL, var.set_battery) diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index 406955084ea..2bcbf557649 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import ble_client, time import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_TIME_ID +from esphome.const import CONF_RECEIVE_TIMEOUT, CONF_TIME_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -41,9 +41,7 @@ async def register_bedjet_child(var: MockObj, config: ConfigType) -> None: async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await ble_client.register_ble_node(var, config) + var = await ble_client.new_ble_node_component(config) await cg.add_variable(var.set_time_id, config.get(CONF_TIME_ID)) if (receive_timeout := config.get(CONF_RECEIVE_TIMEOUT)) is not None: cg.add(var.set_status_timeout(receive_timeout)) diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 6332ea6dc12..5c7dff0cd17 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -372,8 +372,7 @@ async def _to_code_esp32(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) - cg.add(var.set_ble_hub(tracker)) + await cg.add_variable(var.set_ble_hub, config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) # Compiles the scanner-state push slot into the tracker and the matching # registration into the proxy; the other hubs are polled instead. @@ -390,8 +389,7 @@ async def _to_code_ble_hub(config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID]) - cg.add(var.set_ble_hub(hub)) + await cg.add_variable(var.set_ble_hub, config[ble_device_base.CONF_BLE_HUB_ID]) await _connections_to_code(var, config) diff --git a/esphome/components/ch422g/__init__.py b/esphome/components/ch422g/__init__.py index 5110503bf84..60801e0c8dd 100644 --- a/esphome/components/ch422g/__init__.py +++ b/esphome/components/ch422g/__init__.py @@ -42,8 +42,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) # Can't use register_i2c_device because there is no CONF_ADDRESS - parent = await cg.get_variable(config[CONF_I2C_ID]) - cg.add(var.set_i2c_bus(parent)) + await cg.add_variable(var.set_i2c_bus, config[CONF_I2C_ID]) # This is used as a final validation step so that modes have been fully transformed. diff --git a/esphome/components/ch423/__init__.py b/esphome/components/ch423/__init__.py index 3997f9581f0..42dc8bb2841 100644 --- a/esphome/components/ch423/__init__.py +++ b/esphome/components/ch423/__init__.py @@ -43,8 +43,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) # Can't use register_i2c_device because there is no CONF_ADDRESS - parent = await cg.get_variable(config[CONF_I2C_ID]) - cg.add(var.set_i2c_bus(parent)) + await cg.add_variable(var.set_i2c_bus, config[CONF_I2C_ID]) # This is used as a final validation step so that modes have been fully transformed. diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index eed702b6f1a..46ccc2e44e6 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -4,13 +4,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_NAME, - CONF_PATTERN, - CONF_PRIORITY, - CONF_RECEIVE_TIMEOUT, -) +from esphome.const import CONF_NAME, CONF_PATTERN, CONF_PRIORITY, CONF_RECEIVE_TIMEOUT from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -146,8 +140,8 @@ async def to_code(config: ConfigType) -> None: for pattern in config.get(CONF_CUSTOM_PATTERNS, []) ] - var = cg.new_Pvariable( - config[CONF_ID], + await uart.new_uart_component( + config, config[CONF_RECEIVE_TIMEOUT], config[CONF_SKIP_CRC], config.get(CONF_DECRYPTION_KEY, cg.nullptr), @@ -155,9 +149,6 @@ async def to_code(config: ConfigType) -> None: cg.ArrayInitializer(*custom_patterns, multiline=True), ) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) - if CORE.is_esp32: esp32.add_idf_component(name="esphome/dlms_parser", ref=DLMS_PARSER_VERSION) else: diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index 7a15283f3b7..962bd3605fb 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@badbadc0ffee"] @@ -40,8 +39,5 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/ds248x/one_wire.py b/esphome/components/ds248x/one_wire.py index b0289581329..1b142b41690 100644 --- a/esphome/components/ds248x/one_wire.py +++ b/esphome/components/ds248x/one_wire.py @@ -52,6 +52,5 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - parent = await cg.get_variable(config[CONF_DS248X_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[CONF_DS248X_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 75a3426f162..35bb99ed872 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -245,11 +245,7 @@ async def to_code(config): await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) await cg.add_pin(var.set_busy_pin, config.get(CONF_BUSY_PIN)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 3fa71632f02..1a50dbebfc3 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -715,8 +715,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: if config[CONF_TYPE] in SPI_ETHERNET_TYPES: if (spi_id := config.get(CONF_SPI_ID)) is not None: # Pins and host come from the shared spi bus. - spi_parent = await cg.get_variable(spi_id) - cg.add(var.set_spi_parent(spi_parent)) + await cg.add_variable(var.set_spi_parent, spi_id) else: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index f6d935c300d..01713ba34f4 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -56,14 +56,9 @@ async def to_code(config: ConfigType) -> None: config[CONF_DECAY_MODE], ) await cg.register_component(var, config) - pin_a_ = await cg.get_variable(config[CONF_PIN_A]) - cg.add(var.set_pin_a(pin_a_)) - pin_b_ = await cg.get_variable(config[CONF_PIN_B]) - cg.add(var.set_pin_b(pin_b_)) - - if CONF_ENABLE_PIN in config: - enable_pin = await cg.get_variable(config[CONF_ENABLE_PIN]) - cg.add(var.set_enable_pin(enable_pin)) + await cg.add_variable(var.set_pin_a, config[CONF_PIN_A]) + await cg.add_variable(var.set_pin_b, config[CONF_PIN_B]) + await cg.add_variable(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) if CONF_PRESET_MODES in config: cg.add(var.set_preset_modes(config[CONF_PRESET_MODES])) diff --git a/esphome/components/ina260/sensor.py b/esphome/components/ina260/sensor.py index 20cf1fee45f..985defc06a8 100644 --- a/esphome/components/ina260/sensor.py +++ b/esphome/components/ina260/sensor.py @@ -4,7 +4,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, CONF_CURRENT, - CONF_ID, CONF_POWER, DEVICE_CLASS_CURRENT, DEVICE_CLASS_POWER, @@ -54,10 +53,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) sensors = sensor.sub_sensors(config) await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index 48017a8f00e..5dccf0d725f 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -64,9 +64,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: var = await uart.new_uart_component(config) - if wakeup_pin_config := config.get(CONF_WAKEUP_PIN): - pin = await cg.gpio_pin_expression(wakeup_pin_config) - cg.add(var.set_wakeup_pin(pin)) + await cg.add_pin(var.set_wakeup_pin, config.get(CONF_WAKEUP_PIN)) cg.add(var.set_wakeup_pulse_ms(config[CONF_WAKEUP_PULSE].total_milliseconds)) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 056ec7c44f9..dcd88a80ea4 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -633,9 +633,7 @@ async def setup_light_core_(light_var, config, output_var): if (color_correct := config.get(CONF_COLOR_CORRECT)) is not None: cg.add(output_var.set_correction(*color_correct)) - if (power_supply_id := config.get(CONF_POWER_SUPPLY)) is not None: - var_ = await cg.get_variable(power_supply_id) - cg.add(output_var.set_power_supply(var_)) + await cg.add_variable(output_var.set_power_supply, config.get(CONF_POWER_SUPPLY)) if (power_estimation := config.get(CONF_POWER_ESTIMATION)) is not None: cg.add_define("USE_LIGHT_POWER_ESTIMATION") diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index ab77bc5a5b3..67b302d1c51 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -38,8 +38,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 942a975813a..c7aae9d10ff 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -47,8 +47,7 @@ async def register_mcp23xxx(config: ConfigType, num_pins: int) -> MockObj: await cg.register_component(var, config) CORE.data.setdefault(CONF_MCP23XXX, {})[id.id] = num_pins cg.add(var.set_open_drain_ints(config[CONF_OPEN_DRAIN_INTERRUPT])) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) return var diff --git a/esphome/components/mcp9600/sensor.py b/esphome/components/mcp9600/sensor.py index 9a5afe64c28..9953f450145 100644 --- a/esphome/components/mcp9600/sensor.py +++ b/esphome/components/mcp9600/sensor.py @@ -2,7 +2,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_THERMOCOUPLE_TYPE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, @@ -57,10 +56,7 @@ FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) cg.add(var.set_thermocouple_type(config[CONF_THERMOCOUPLE_TYPE])) diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index cecbf9836ef..f5c1ed71b84 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -221,8 +221,7 @@ async def to_code(config): if CONF_TRANSMITTER_ID in config: remote_base.request_protocol("midea") # ir_transmitter.h uses it from C++ cg.add_define("USE_REMOTE_TRANSMITTER") - transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter_)) + await cg.add_variable(var.set_transmitter, config[CONF_TRANSMITTER_ID]) cg.add(var.set_beeper_feedback(config[CONF_BEEPER])) cg.add(var.set_autoconf(config[CONF_AUTOCONF])) cg.add(var.set_display_control(config[CONF_DISPLAY_CONTROL])) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 380c3a4dfd6..fffd1aacf44 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -45,7 +45,6 @@ from esphome.const import ( CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, - CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, @@ -227,11 +226,7 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema: ) if model.get_default(CONF_BRIGHTNESS, None) is not None or model.name == "CUSTOM": schema = schema.extend( - { - model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.int_range( - 0, 0xFF, min_included=True, max_included=True - ), - } + {model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.uint8_t} ) if bus_mode != TYPE_SINGLE: return cv.All(schema, cv.only_on_esp32) @@ -413,10 +408,6 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_INVERT_COLORS): cg.add(var.set_invert_colors(True)) - if lamb := config.get(CONF_LAMBDA): - lambda_ = await cg.process_lambda( - lamb, [(display.DisplayRef, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config) await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/mipi_spi/light/__init__.py b/esphome/components/mipi_spi/light/__init__.py index aec5d9b4fdf..2fe4c872dfe 100644 --- a/esphome/components/mipi_spi/light/__init__.py +++ b/esphome/components/mipi_spi/light/__init__.py @@ -32,8 +32,8 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MipiSpiLight), cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(MipiSpi), cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float, - cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.int_range(0, 255), - cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.int_range(0, 255), + cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.uint8_t, + cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.uint8_t, } ), _validate_brightness_range, diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index a1f15aab452..258ecd51bac 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -9,7 +9,7 @@ import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_ROLE import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID +from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType, TemplateArgsType @@ -301,10 +301,7 @@ CONFIG_SCHEMA = cv.typed_schema( async def to_code(config: ConfigType) -> None: cg.add_global(modbus_ns.using) - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - - await uart.register_uart_device(var, config) + var = await uart.new_uart_component(config) await cg.add_pin(var.set_flow_control_pin, config.get(CONF_FLOW_CONTROL_PIN)) diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 67c3cd75bdc..25f4b230dbb 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -186,8 +186,7 @@ async def register_client_action( response_args are the on_response handler's arguments, which differ per action. """ - parent = await cg.get_variable(config[modbus.CONF_MODBUS_ID]) - cg.add(var.set_parent(parent)) + await cg.register_parented(var, config[modbus.CONF_MODBUS_ID]) cg.add( var.set_target_address( await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) diff --git a/esphome/components/mt6701_i2c/__init__.py b/esphome/components/mt6701_i2c/__init__.py index 17734e00459..79c73eebe46 100644 --- a/esphome/components/mt6701_i2c/__init__.py +++ b/esphome/components/mt6701_i2c/__init__.py @@ -155,9 +155,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) if (direction := config.get(CONF_DIRECTION)) is not None: cg.add(var.set_direction(direction)) diff --git a/esphome/components/mt6701_spi/__init__.py b/esphome/components/mt6701_spi/__init__.py index 6f45e347b7d..3498dde0e8a 100644 --- a/esphome/components/mt6701_spi/__init__.py +++ b/esphome/components/mt6701_spi/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType from ..mt6701 import MT6701Component @@ -42,6 +41,4 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await spi.register_spi_device(var, config) + await spi.new_spi_component(config) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 969a6c8e8c9..92255a6eda2 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BRIGHTNESS, CONF_ID, - CONF_LAMBDA, CONF_ON_TOUCH, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -232,11 +231,7 @@ async def to_code(config): if CONF_BRIGHTNESS in config: cg.add(var.set_brightness(config[CONF_BRIGHTNESS])) - if CONF_LAMBDA in config: - lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(nextion_ref, "it")], return_type=cg.void - ) - cg.add(var.set_writer(lambda_)) + await display.add_writer(var, config, nextion_ref) if CONF_TFT_URL in config: cg.add_define("USE_NEXTION_TFT_UPLOAD") diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 33f8af9d528..144088d48f1 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -42,8 +42,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py index 4e3acb94e6e..91521820b30 100644 --- a/esphome/components/pca9505/__init__.py +++ b/esphome/components/pca9505/__init__.py @@ -42,8 +42,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index cba5656e88d..6e9478b1fd4 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -48,8 +48,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pin_count(config[CONF_PIN_COUNT])) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index 3f993c17e19..e3e7daeb49a 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@brogon"] @@ -42,8 +41,5 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index 3f677280c5b..7f78549ba8e 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@KoenBreeman"] @@ -38,8 +37,5 @@ automation.register_apply_action( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index f1db7c29748..b491dae5047 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -48,8 +48,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pcf8575(config[CONF_PCF8575])) if (reset := config[CONF_RESET]) != DEFAULT_RESET: cg.add(var.set_reset(reset)) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index cb50b101c13..fbfc998bb9f 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -140,6 +140,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_channel_mix(config[CONF_CHANNEL_MIX])) cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) - if enable_pin_config := config.get(CONF_ENABLE_PIN): - enable_pin = await cg.gpio_pin_expression(enable_pin_config) - cg.add(var.set_enable_pin(enable_pin)) + await cg.add_pin(var.set_enable_pin, config.get(CONF_ENABLE_PIN)) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index 4128114b3cc..458ea1f2728 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -49,8 +49,7 @@ async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) cg.add(var.set_reset(config[CONF_RESET])) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/rx8025t/time.py b/esphome/components/rx8025t/time.py index e8ffe772ab7..fcc6c0b72fa 100644 --- a/esphome/components/rx8025t/time.py +++ b/esphome/components/rx8025t/time.py @@ -2,7 +2,6 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@remcom"] @@ -35,8 +34,5 @@ for _name, _call in ( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) - - await cg.register_component(var, config) - await i2c.register_i2c_device(var, config) + var = await i2c.new_i2c_component(config) await time.register_time(var, config) diff --git a/esphome/components/seeed_mr24hpc1/__init__.py b/esphome/components/seeed_mr24hpc1/__init__.py index 4ec01e46c67..63b5373cffc 100644 --- a/esphome/components/seeed_mr24hpc1/__init__.py +++ b/esphome/components/seeed_mr24hpc1/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -43,12 +42,5 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -# The async def keyword is used to define a concurrent function. -# Concurrent functions are special functions designed to work with Python's asyncio library to support asynchronous I/O operations. async def to_code(config: ConfigType) -> None: - # This line of code creates a new Pvariable (a Python object representing a C++ variable) with the variable's ID taken from the configuration. - var = cg.new_Pvariable(config[CONF_ID]) - # This line of code registers the newly created Pvariable as a component so that ESPHome can manage it at runtime. - await cg.register_component(var, config) - # This line of code registers the newly created Pvariable as a device. - await uart.register_uart_device(var, config) + await uart.new_uart_component(config) diff --git a/esphome/components/speed/fan/__init__.py b/esphome/components/speed/fan/__init__.py index 9a9290e78ee..cd94796f081 100644 --- a/esphome/components/speed/fan/__init__.py +++ b/esphome/components/speed/fan/__init__.py @@ -38,16 +38,9 @@ async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config, config[CONF_SPEED_COUNT]) await cg.register_component(var, config) - output_ = await cg.get_variable(config[CONF_OUTPUT]) - cg.add(var.set_output(output_)) - - if CONF_OSCILLATION_OUTPUT in config: - oscillation_output = await cg.get_variable(config[CONF_OSCILLATION_OUTPUT]) - cg.add(var.set_oscillating(oscillation_output)) - - if CONF_DIRECTION_OUTPUT in config: - direction_output = await cg.get_variable(config[CONF_DIRECTION_OUTPUT]) - cg.add(var.set_direction(direction_output)) + await cg.add_variable(var.set_output, config[CONF_OUTPUT]) + await cg.add_variable(var.set_oscillating, config.get(CONF_OSCILLATION_OUTPUT)) + await cg.add_variable(var.set_direction, config.get(CONF_DIRECTION_OUTPUT)) if CONF_PRESET_MODES in config: cg.add(var.set_preset_modes(config[CONF_PRESET_MODES])) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 0554f9a569d..a3992b7c88e 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -41,8 +41,7 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await i2c.new_i2c_component(config) - if interrupt_pin := config.get(CONF_INTERRUPT_PIN): - cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) def validate_mode(value: ConfigType) -> ConfigType: diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index 30155de28aa..54cd6df45e6 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -295,11 +295,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await climate.register_climate(var, config) - if (sens := config.get(CONF_SENSOR)) is not None: - cg.add(var.set_sensor(await cg.get_variable(sens))) - - if (sens := config.get(CONF_HUMIDITY_SENSOR)) is not None: - cg.add(var.set_humidity_sensor(await cg.get_variable(sens))) + await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) + await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) for key, flag in ( (CONF_SUPPORTS_ACTION, climate_ns.CLIMATE_SUPPORTS_ACTION), diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index dd6ae47bbfa..c70f0b6c693 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -370,15 +370,9 @@ async def to_code(config): cg.add(var.set_baud_rate(config[CONF_BAUD_RATE])) - if CONF_TX_PIN in config: - tx_pin = await cg.gpio_pin_expression(config[CONF_TX_PIN]) - cg.add(var.set_tx_pin(tx_pin)) - if CONF_RX_PIN in config: - rx_pin = await cg.gpio_pin_expression(config[CONF_RX_PIN]) - cg.add(var.set_rx_pin(rx_pin)) - if CONF_FLOW_CONTROL_PIN in config: - flow_control_pin = await cg.gpio_pin_expression(config[CONF_FLOW_CONTROL_PIN]) - cg.add(var.set_flow_control_pin(flow_control_pin)) + await cg.add_pin(var.set_tx_pin, config.get(CONF_TX_PIN)) + await cg.add_pin(var.set_rx_pin, config.get(CONF_RX_PIN)) + await cg.add_pin(var.set_flow_control_pin, config.get(CONF_FLOW_CONTROL_PIN)) if CONF_PORT in config: cg.add(var.set_name(config[CONF_PORT])) cg.add(var.set_rx_buffer_size(config[CONF_RX_BUFFER_SIZE])) @@ -589,8 +583,7 @@ async def register_uart_device(var, config): This is a coroutine, you need to await it with an 'await' expression! """ - parent = await cg.get_variable(config[CONF_UART_ID]) - cg.add(var.set_uart_parent(parent)) + await cg.add_variable(var.set_uart_parent, config[CONF_UART_ID]) def new_uart_component( diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 44f83696f25..c0b0ce453b8 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -176,9 +176,7 @@ CONFIG_SCHEMA = cv.All( cv.Length(max=31), _validate_ascii, ), - cv.Optional(CONF_VERSION, default=0): cv.All( - cv.int_range(min=0x00, max=0xFF), - ), + cv.Optional(CONF_VERSION, default=0): cv.uint8_t, cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): automation.validate_automation({}), cv.Optional(CONF_ON_START): automation.validate_automation({}), diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index f7da147b88f..ad2bb4f869e 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -392,8 +392,7 @@ async def _add_zigbee_ep( cg.add(var.set_endpoint(slot_index + 1)) cg.add(var.set_cluster_attributes(attrs)) - hub = await cg.get_variable(config[CONF_ZIGBEE_ID]) - cg.add(var.set_parent(hub)) + await cg.register_parented(var, config[CONF_ZIGBEE_ID]) async def add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: From e6ad995c8e4932c15eaed31526da38d8e61c5f84 Mon Sep 17 00:00:00 2001 From: cosmicDustOfLightLength <71566296+cosmicDustOfLightLength@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:07:52 +0200 Subject: [PATCH 149/179] [i2s_audio] Add ESP32-S31 support (#20115) Co-authored-by: cosmicDustOfLightLength Co-authored-by: J. Nick Koston --- esphome/components/i2s_audio/__init__.py | 2 + .../i2s_audio/microphone/__init__.py | 7 +- .../i2s_audio/test_variants.py | 68 +++++++++++++++++++ .../i2s_audio/test.esp32-s31-idf.yaml | 7 ++ .../microphone/test.esp32-s31-idf.yaml | 7 ++ .../speaker/test.esp32-s31-idf.yaml | 7 ++ 6 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/i2s_audio/test_variants.py create mode 100644 tests/components/i2s_audio/test.esp32-s31-idf.yaml create mode 100644 tests/components/microphone/test.esp32-s31-idf.yaml create mode 100644 tests/components/speaker/test.esp32-s31-idf.yaml diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 3d5ec97a633..4abc807ee3d 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -17,6 +17,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ) from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN from esphome.config_helpers import filter_source_files_from_defines @@ -98,6 +99,7 @@ I2S_PORTS = { VARIANT_ESP32P4: 3, VARIANT_ESP32S2: 1, VARIANT_ESP32S3: 2, + VARIANT_ESP32S31: 2, } i2s_channel_fmt_t = cg.global_ns.enum("i2s_channel_fmt_t") diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index c2173172372..2ff709dbd1c 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -38,7 +38,12 @@ I2SAudioMicrophone = i2s_audio_ns.class_( ) INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] -PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4] +PDM_VARIANTS = [ + esp32.VARIANT_ESP32, + esp32.VARIANT_ESP32S3, + esp32.VARIANT_ESP32P4, + esp32.VARIANT_ESP32S31, +] i2s_pdm_dsr_t = cg.global_ns.enum("i2s_pdm_dsr_t") I2S_PDM_DSR = { diff --git a/tests/component_tests/i2s_audio/test_variants.py b/tests/component_tests/i2s_audio/test_variants.py new file mode 100644 index 00000000000..3c5245565fa --- /dev/null +++ b/tests/component_tests/i2s_audio/test_variants.py @@ -0,0 +1,68 @@ +"""Tests for the per-variant limits of i2s_audio: port count and PDM support.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import i2s_audio +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import ( + VARIANT_ESP32C3, + VARIANT_ESP32S2, + VARIANT_ESP32S31, +) +from esphome.components.i2s_audio import microphone +from esphome.const import CONF_ID, PlatformFramework +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +CONF_FULL_DUPLEX = i2s_audio.CONF_FULL_DUPLEX + + +def _set_variant( + set_core_config: SetCoreConfigCallable, variant: str, buses: int +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_VARIANT: variant}, + full_config={ + i2s_audio.DOMAIN: [ + {CONF_ID: ID(f"bus_{n}"), CONF_FULL_DUPLEX: False} for n in range(buses) + ] + }, + ) + + +def test_esp32s31_has_two_i2s_ports(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32S31, buses=2) + i2s_audio._final_validate({}) + + +def test_esp32s31_rejects_a_third_bus(set_core_config: SetCoreConfigCallable) -> None: + _set_variant(set_core_config, VARIANT_ESP32S31, buses=3) + with pytest.raises(cv.Invalid, match="Only 2 I2S audio ports"): + i2s_audio._final_validate({}) + + +def _pdm_microphone(pdm: bool) -> ConfigType: + return { + microphone.CONF_ADC_TYPE: "external", + microphone.CONF_PDM: pdm, + } + + +def test_esp32s31_supports_pdm_microphones( + set_core_config: SetCoreConfigCallable, +) -> None: + _set_variant(set_core_config, VARIANT_ESP32S31, buses=1) + microphone._validate_esp32_variant(_pdm_microphone(True)) + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32S2, VARIANT_ESP32C3]) +def test_pdm_rejected_where_the_soc_has_none( + set_core_config: SetCoreConfigCallable, variant: str +) -> None: + _set_variant(set_core_config, variant, buses=1) + with pytest.raises(cv.Invalid, match="does not support PDM"): + microphone._validate_esp32_variant(_pdm_microphone(True)) + microphone._validate_esp32_variant(_pdm_microphone(False)) diff --git a/tests/components/i2s_audio/test.esp32-s31-idf.yaml b/tests/components/i2s_audio/test.esp32-s31-idf.yaml new file mode 100644 index 00000000000..6457440bb4e --- /dev/null +++ b/tests/components/i2s_audio/test.esp32-s31-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + i2s_bclk_pin: GPIO5 + i2s_lrclk_pin: GPIO4 + i2s_mclk_pin: GPIO15 + +packages: + i2s_audio: !include common.yaml diff --git a/tests/components/microphone/test.esp32-s31-idf.yaml b/tests/components/microphone/test.esp32-s31-idf.yaml new file mode 100644 index 00000000000..00f535b1692 --- /dev/null +++ b/tests/components/microphone/test.esp32-s31-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + i2s_din_pin1: GPIO6 + i2s_din_pin2: GPIO7 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s31-idf.yaml + microphone: !include common-pdm.yaml diff --git a/tests/components/speaker/test.esp32-s31-idf.yaml b/tests/components/speaker/test.esp32-s31-idf.yaml new file mode 100644 index 00000000000..00a41ac969b --- /dev/null +++ b/tests/components/speaker/test.esp32-s31-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + i2s_dout_pin: GPIO10 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-s31-idf.yaml + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s31-idf.yaml + speaker: !include common.yaml From 704dd4b445052a5029a264a8f751ddb3f9040edd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:17:57 -1000 Subject: [PATCH 150/179] [components] Report communication failures with status_comm_result in the remaining components (#21079) --- esphome/components/atm90e32/atm90e32.cpp | 5 +---- esphome/components/bme680/bme680.cpp | 4 +--- esphome/components/bme680_bsec/bme680_bsec.cpp | 6 +----- esphome/components/bme68x_bsec2/bme68x_bsec2.cpp | 6 +----- esphome/components/cm1106/cm1106.cpp | 6 ++---- .../cst226/touchscreen/cst226_touchscreen.cpp | 4 +--- .../cst9220/touchscreen/cst9220_touchscreen.cpp | 4 +--- .../dfrobot_sen0465_base/dfrobot_sen0465_base.cpp | 6 +----- .../components/ds3231/sensor/ds3231_temperature.cpp | 5 +---- .../components/esp32_rmt_led_strip/led_strip.cpp | 5 +---- esphome/components/hc8/hc8.cpp | 5 +---- esphome/components/hdc302x/hdc302x.cpp | 7 ++----- .../hoermann_hcp/cover/hoermann_hcp_cover.cpp | 4 +--- .../hoermann_hcp/light/hoermann_hcp_light.cpp | 7 ++----- esphome/components/husb238/husb238.cpp | 5 +---- esphome/components/hx711/hx711.cpp | 5 +---- esphome/components/ina2xx_base/ina2xx_base.cpp | 6 +----- esphome/components/lis2dw12/lis2dw12.cpp | 13 +++---------- esphome/components/lp5562/lp5562_output.cpp | 6 +----- .../binary_sensor/m5stack_8angle_binary_sensor.cpp | 5 +---- .../m5stack_8angle/sensor/m5stack_8angle_sensor.cpp | 5 +---- esphome/components/max6675/max6675.cpp | 6 ++---- esphome/components/mcp3221/mcp3221_sensor.cpp | 4 +--- esphome/components/mcp9808/mcp9808.cpp | 5 +---- esphome/components/mhz19/mhz19.cpp | 4 +--- esphome/components/mics_4514/mics_4514.cpp | 5 +---- esphome/components/mpr121/mpr121.cpp | 8 +------- esphome/components/nau7802/nau7802.cpp | 5 +---- esphome/components/opt3001/opt3001.cpp | 4 +--- esphome/components/pca6416a/pca6416a.cpp | 10 ++-------- esphome/components/pca9554/pca9554.cpp | 9 ++------- esphome/components/pcf8574/pcf8574.cpp | 13 ++----------- esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp | 12 ++++-------- esphome/components/pn532/pn532.cpp | 13 ++++++------- esphome/components/qmp6988/qmp6988.cpp | 4 +--- .../remote_transmitter/remote_transmitter_rmt.cpp | 4 +--- esphome/components/rx8025t/rx8025t.cpp | 4 +--- esphome/components/rx8111/rx8111.cpp | 6 ++---- esphome/components/selec_meter/selec_meter.cpp | 6 +----- esphome/components/sen21231/sen21231.cpp | 4 +--- esphome/components/sen6x/sen6x.cpp | 4 +--- esphome/components/senseair/senseair.cpp | 4 +--- esphome/components/sht4x/sht4x.cpp | 5 +---- esphome/components/spd2010/spd2010_touchscreen.cpp | 8 +++----- .../st7123/touchscreen/st7123_touchscreen.cpp | 4 +--- esphome/components/sts3x/sts3x.cpp | 5 +---- esphome/components/sy6970/sy6970.cpp | 5 +---- esphome/components/t6615/t6615.cpp | 5 +---- esphome/components/tca8418/tca8418.cpp | 5 +---- esphome/components/tmc22xx/tmc22xx.cpp | 9 +++------ esphome/components/tmp1075/tmp1075.cpp | 4 +--- esphome/components/tsl2561/tsl2561.cpp | 5 +---- esphome/components/ttp229_lsf/ttp229_lsf.cpp | 5 +---- esphome/components/veml3235/veml3235.cpp | 5 +---- 54 files changed, 76 insertions(+), 242 deletions(-) diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index 0cdf9918db9..9fb32e2f021 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -117,12 +117,9 @@ void ATM90E32Component::loop() { } void ATM90E32Component::update() { - if (this->read16_(ATM90E32_REGISTER_METEREN) != 1) { - this->status_set_warning(); + if (!this->status_comm_result(this->read16_(ATM90E32_REGISTER_METEREN) == 1, nullptr)) return; - } this->set_publish_interval_flag_(true); - this->status_clear_warning(); #ifdef USE_TEXT_SENSOR this->check_phase_status(); diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index e3297d38f55..ac5ce3287d8 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -284,7 +284,7 @@ uint8_t BME680Component::calc_heater_duration_(uint16_t duration) { } void BME680Component::read_data_() { uint8_t data[15]; - if (!this->read_bytes(BME680_REGISTER_FIELD0, data, 15)) { + if (!this->status_comm_result(this->read_bytes(BME680_REGISTER_FIELD0, data, 15))) { if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(NAN); if (this->pressure_sensor_ != nullptr) @@ -294,10 +294,8 @@ void BME680Component::read_data_() { if (this->gas_resistance_sensor_ != nullptr) this->gas_resistance_sensor_->publish_state(NAN); ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); return; } - this->status_clear_warning(); uint32_t raw_temperature = (uint32_t(data[5]) << 12) | (uint32_t(data[6]) << 4) | (uint32_t(data[7]) >> 4); uint32_t raw_pressure = (uint32_t(data[2]) << 12) | (uint32_t(data[3]) << 4) | (uint32_t(data[4]) >> 4); diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index 3f0082e1af7..fe24ba88754 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -191,11 +191,7 @@ void BME680BSECComponent::loop() { } else { this->status_clear_error(); } - if (this->bsec_status_ > BSEC_OK || this->bme680_status_ > BME680_OK) { - this->status_set_warning(); - } else { - this->status_clear_warning(); - } + this->status_comm_result(this->bsec_status_ <= BSEC_OK && this->bme680_status_ <= BME680_OK, nullptr); // Process a single action from the queue. These are primarily sensor state publishes // that in totality take too long to send in a single call. diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index c563e120842..1be5c3835e4 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -126,11 +126,7 @@ void BME68xBSEC2Component::check_status_() { this->status_clear_error(); } const bool has_bme68x_warning = this->bme68x_status_ > BME68X_OK && !is_no_new_data_warning(this->bme68x_status_); - if (this->bsec_status_ > BSEC_OK || has_bme68x_warning) { - this->status_set_warning(); - } else { - this->status_clear_warning(); - } + this->status_comm_result(this->bsec_status_ <= BSEC_OK && !has_bme68x_warning, nullptr); } void BME68xBSEC2Component::loop() { diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index 8739e6c0805..1b51030b162 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -101,15 +101,13 @@ bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t co uint8_t response[4] = {0}; if (!this->cm1106_write_command_(command, command_len, response, sizeof(response))) { ESP_LOGW(TAG, "Command %02X failed", command[2]); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } - if (memcmp(response, ack, sizeof(response)) != 0) { + if (!this->status_comm_result(memcmp(response, ack, sizeof(response)) == 0)) { ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); - this->status_set_warning(); return false; } - this->status_clear_warning(); return true; } diff --git a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp index 849cff4a49e..7f98051d2bc 100644 --- a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp +++ b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp @@ -20,12 +20,10 @@ void CST226Touchscreen::setup() { void CST226Touchscreen::update_touches() { uint8_t data[28]; - if (!this->read_bytes(CST226_REG_STATUS, data, sizeof data)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_bytes(CST226_REG_STATUS, data, sizeof data))) { this->skip_update_ = true; return; } - this->status_clear_warning(); if (data[0] == 0x83 && data[1] == 0x17 && data[5] == 0x80) { this->update_button_state_(true); return; diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp index 72803804335..6d869ec5ea7 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -87,12 +87,10 @@ void CST9220Touchscreen::update_touches() { // Only an actual I2C failure should skip the update; a successful read with no // touches is a real "all fingers lifted" state that must flow through so the // base class can generate the release event. - if (this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) == i2c::ERROR_OK)) { this->skip_update_ = true; return; } - this->status_clear_warning(); // Acknowledge the report so the controller can prepare the next one. uint8_t ack = TOUCH_ACK; diff --git a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp index 78267d5ff9e..5fffd71fe91 100644 --- a/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp +++ b/esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.cpp @@ -255,11 +255,7 @@ void DFRobotSEN0465Component::handle_data_(const uint8_t *frame) { if (this->concentration_sensor_ != nullptr) this->concentration_sensor_->publish_state(concentration); - if (std::isnan(concentration) || std::isnan(temperature) || this->setup_incomplete_) { - this->status_set_warning(); - } else { - this->status_clear_warning(); - } + this->status_comm_result(!std::isnan(concentration) && !std::isnan(temperature) && !this->setup_incomplete_, nullptr); } #ifdef USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION diff --git a/esphome/components/ds3231/sensor/ds3231_temperature.cpp b/esphome/components/ds3231/sensor/ds3231_temperature.cpp index 8d7e1156be4..569f24cab5e 100644 --- a/esphome/components/ds3231/sensor/ds3231_temperature.cpp +++ b/esphome/components/ds3231/sensor/ds3231_temperature.cpp @@ -7,11 +7,8 @@ ESPHOME_LOG_TAG(TAG, "ds3231.sensor"); void DS3231TemperatureSensor::update() { float temperature; - if (!this->parent_->read_temperature(temperature)) { - this->status_set_warning(); + if (!this->status_comm_result(this->parent_->read_temperature(temperature))) return; - } - this->status_clear_warning(); this->publish_state(temperature); } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 420ff64ddc6..24f3a77a7ce 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -212,12 +212,9 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { #else error = rmt_transmit(this->channel_, this->encoder_, this->rmt_buf_, len * sizeof(rmt_symbol_word_t), &config); #endif - if (error != ESP_OK) { + if (!this->status_comm_result(error == ESP_OK, nullptr)) { ESP_LOGE(TAG, "RMT TX error"); - this->status_set_warning(); - return; } - this->status_clear_warning(); } light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const { diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp index 2197a086c36..734e133336f 100644 --- a/esphome/components/hc8/hc8.cpp +++ b/esphome/components/hc8/hc8.cpp @@ -55,14 +55,11 @@ void HC8Component::update() { return; } - if (crc16(response.data(), 12) != encode_uint16(response[13], response[12])) { + if (!this->status_comm_result(crc16(response.data(), 12) == encode_uint16(response[13], response[12]))) { ESP_LOGW(TAG, "HC8 Checksum mismatch"); - this->status_set_warning(); return; } - this->status_clear_warning(); - const uint16_t ppm = encode_uint16(response[5], response[4]); ESP_LOGD(TAG, "HC8 Received COâ‚‚=%uppm", ppm); if (this->co2_sensor_ != nullptr) diff --git a/esphome/components/hdc302x/hdc302x.cpp b/esphome/components/hdc302x/hdc302x.cpp index d5428486579..ffe03190b3e 100644 --- a/esphome/components/hdc302x/hdc302x.cpp +++ b/esphome/components/hdc302x/hdc302x.cpp @@ -133,12 +133,9 @@ void HDC302XComponent::read_data_() { } // Check checksums - if (crc8(buf, 2, 0xff, 0x31, true) != buf[2] || crc8(buf + 3, 2, 0xff, 0x31, true) != buf[5]) { - this->status_set_warning(LOG_STR("Read data: invalid CRC")); + const bool crc_ok = crc8(buf, 2, 0xff, 0x31, true) == buf[2] && crc8(buf + 3, 2, 0xff, 0x31, true) == buf[5]; + if (!this->status_comm_result(crc_ok, LOG_STR("Read data: invalid CRC"))) return; - } - - this->status_clear_warning(); if (this->temp_sensor_ != nullptr) { uint16_t raw_t = encode_uint16(buf[0], buf[1]); diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp index ab2afc74d93..d4fc2e9b689 100644 --- a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -39,8 +39,7 @@ void HoermannHcpCover::control(const cover::CoverCall &call) { } void HoermannHcpCover::update_from_state_() { - if (!this->parent_->is_valid()) { - this->status_set_warning(); + if (!this->status_comm_result(this->parent_->is_valid(), LOG_STR("bus controller not responding"))) { // The door can now move unheard, so drop the baseline a direction would be inferred from and stop // reporting motion instead of leaving the cover travelling until the controller returns. this->previous_position_ = NAN; @@ -50,7 +49,6 @@ void HoermannHcpCover::update_from_state_() { } return; } - this->status_clear_warning(); const auto previous_operation = this->current_operation; const float current_position = this->parent_->get_current_position(); diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp index 6195c5d63d8..6a3028ad2fe 100644 --- a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -53,12 +53,9 @@ void HoermannHcpLight::update_from_state_() { this->status_set_warning(LOG_STR("bus controller not responding")); return; } - if (!this->parent_->is_light_known()) { - // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. - this->status_set_warning(LOG_STR("door has not reported the lamp")); + // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. + if (!this->status_comm_result(this->parent_->is_light_known(), LOG_STR("door has not reported the lamp"))) return; - } - this->status_clear_warning(); const bool heading_on = this->parent_->is_light_heading_on(); if (this->light_state_->remote_values.is_on() != heading_on) this->publish_lamp_state_(heading_on); diff --git a/esphome/components/husb238/husb238.cpp b/esphome/components/husb238/husb238.cpp index d0f9973b2a4..3d39b948b38 100644 --- a/esphome/components/husb238/husb238.cpp +++ b/esphome/components/husb238/husb238.cpp @@ -14,11 +14,8 @@ void Husb238Component::setup() { void Husb238Component::update() { uint8_t status; - if (!this->read_byte(REG_PD_STATUS1, &status)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_byte(REG_PD_STATUS1, &status))) return; - } - this->status_clear_warning(); #ifdef USE_BINARY_SENSOR if (this->attached_binary_sensor_ != nullptr) { diff --git a/esphome/components/hx711/hx711.cpp b/esphome/components/hx711/hx711.cpp index 9a81befcc45..c6bf03b72e1 100644 --- a/esphome/components/hx711/hx711.cpp +++ b/esphome/components/hx711/hx711.cpp @@ -59,14 +59,11 @@ bool HX711Sensor::read_sensor_(uint32_t *result) { final_dout = this->dout_pin_->digital_read(); } - if (!final_dout) { + if (!this->status_comm_result(final_dout)) { ESP_LOGW(TAG, "HX711 DOUT pin not high after reading (data 0x%" PRIx32 ")!", data); - this->status_set_warning(); return false; } - this->status_clear_warning(); - data = static_cast(sign_extend<24>(data)); if (result != nullptr) diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 16b37568d91..97f7f211370 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -172,11 +172,7 @@ void INA2XX::loop() { break; case State::DATA_COLLECTION_8: - if (this->full_loop_is_okay_) { - this->status_clear_warning(); - } else { - this->status_set_warning(); - } + this->status_comm_result(this->full_loop_is_okay_); this->state_ = State::IDLE; break; diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp index 7af60be87a4..949ebe14509 100644 --- a/esphome/components/lis2dw12/lis2dw12.cpp +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -291,11 +291,8 @@ void LIS2DW12Component::loop() { // SIXD_SRC and ALL_INT_SRC are adjacent, so one read captures both before any latch clears uint8_t regs[2]; - if (!this->read_bytes(static_cast(RegisterMap::SIXD_SRC), regs, sizeof(regs))) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_bytes(static_cast(RegisterMap::SIXD_SRC), regs, sizeof(regs)))) return; - } - this->status_clear_warning(); RegSixdSrc sixd; sixd.raw = regs[0]; RegAllIntSrc src; @@ -315,11 +312,9 @@ void LIS2DW12Component::loop() { } void LIS2DW12Component::update() { - float x, y, z; - if (!this->read_acceleration_data_(x, y, z)) { - this->status_set_warning(); + float x{}, y{}, z{}; + if (!this->status_comm_result(this->read_acceleration_data_(x, y, z))) return; - } ESP_LOGV(TAG, "Acceleration: {x = %+1.3f m/s², y = %+1.3f m/s², z = %+1.3f m/s²}", x, y, z); @@ -331,8 +326,6 @@ void LIS2DW12Component::update() { if (this->acceleration_z_sensor_ != nullptr) this->acceleration_z_sensor_->publish_state(z); #endif - - this->status_clear_warning(); } void LIS2DW12Component::set_offset(float offset_x, float offset_y, float offset_z) { diff --git a/esphome/components/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp index da392ea5c04..2010f93f0c3 100644 --- a/esphome/components/lp5562/lp5562_output.cpp +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -73,11 +73,7 @@ void LP5562Output::dump_config() { void LP5562Output::set_channel_value_(uint8_t channel, uint8_t value) { if (this->is_failed()) return; - if (!this->write_byte(progmem_read_byte(&LP5562_REG_PWM[channel]), value)) { - this->status_set_warning(); - return; - } - this->status_clear_warning(); + this->status_comm_result(this->write_byte(progmem_read_byte(&LP5562_REG_PWM[channel]), value)); } void LP5562Channel::write_state(float state) { diff --git a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp index 680b3bcd9e7..0eda7d9864d 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp +++ b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.cpp @@ -4,12 +4,9 @@ namespace esphome::m5stack_8angle { void M5Stack8AngleSwitchBinarySensor::update() { int8_t out = this->parent_->read_switch(); - if (out == -1) { - this->status_set_warning(LOG_STR("Could not read binary sensor state from M5Stack 8Angle.")); + if (!this->status_comm_result(out != -1, LOG_STR("Could not read binary sensor state from M5Stack 8Angle."))) return; - } this->publish_state(out != 0); - this->status_clear_warning(); } } // namespace esphome::m5stack_8angle diff --git a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp index b05e1e68163..24a40dd9d4d 100644 --- a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp +++ b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.cpp @@ -5,17 +5,14 @@ namespace esphome::m5stack_8angle { void M5Stack8AngleKnobSensor::update() { if (this->parent_ != nullptr) { int32_t raw_pos = this->parent_->read_knob_pos_raw(this->channel_, this->bits_); - if (raw_pos == -1) { - this->status_set_warning(LOG_STR("Could not read knob position from M5Stack 8Angle.")); + if (!this->status_comm_result(raw_pos != -1, LOG_STR("Could not read knob position from M5Stack 8Angle."))) return; - } if (this->raw_) { this->publish_state(raw_pos); } else { float knob_pos = (float) raw_pos / ((1 << this->bits_) - 1); this->publish_state(knob_pos); } - this->status_clear_warning(); }; } diff --git a/esphome/components/max6675/max6675.cpp b/esphome/components/max6675/max6675.cpp index 2fc8dbb73d7..14eaca89164 100644 --- a/esphome/components/max6675/max6675.cpp +++ b/esphome/components/max6675/max6675.cpp @@ -31,17 +31,15 @@ void MAX6675Sensor::read_data_() { uint16_t val = data[1] | (uint16_t(data[0]) << 8); this->disable(); - if ((val & 0x04) != 0) { - // Thermocouple open + // Bit 2 set means the thermocouple is open + if (!this->status_comm_result((val & 0x04) == 0, nullptr)) { ESP_LOGW(TAG, "Got invalid value from MAX6675Sensor (0x%04X)", val); - this->status_set_warning(); return; } float temperature = float(val >> 3) / 4.0f; ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", this->name_.c_str(), temperature); this->publish_state(temperature); - this->status_clear_warning(); } } // namespace esphome::max6675 diff --git a/esphome/components/mcp3221/mcp3221_sensor.cpp b/esphome/components/mcp3221/mcp3221_sensor.cpp index d38883c3421..94e7174baa7 100644 --- a/esphome/components/mcp3221/mcp3221_sensor.cpp +++ b/esphome/components/mcp3221/mcp3221_sensor.cpp @@ -8,12 +8,10 @@ ESPHOME_LOG_TAG(TAG, "mcp3221"); float MCP3221Sensor::sample() { uint8_t data[2]; - if (this->read(data, 2) != i2c::ERROR_OK) { + if (!this->status_comm_result(this->read(data, 2) == i2c::ERROR_OK)) { ESP_LOGW(TAG, "Read failed"); - this->status_set_warning(); return NAN; } - this->status_clear_warning(); uint16_t value = encode_uint16(data[0], data[1]); float voltage = value * this->reference_voltage_ / 4096.0f; diff --git a/esphome/components/mcp9808/mcp9808.cpp b/esphome/components/mcp9808/mcp9808.cpp index 309651970ca..2c96b4119d3 100644 --- a/esphome/components/mcp9808/mcp9808.cpp +++ b/esphome/components/mcp9808/mcp9808.cpp @@ -68,14 +68,11 @@ void MCP9808Sensor::update() { temp = (uint16_t) (msb) *16 + lsb / 16.0f; } - if (std::isnan(temp)) { - this->status_set_warning(); + if (!this->status_comm_result(!std::isnan(temp), nullptr)) return; - } ESP_LOGD(TAG, "%s: Got temperature=%.4f°C", this->name_.c_str(), temp); this->publish_state(temp); - this->status_clear_warning(); } } // namespace esphome::mcp9808 diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index eda4b4d8e78..3eb71c1adad 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -66,13 +66,11 @@ void MHZ19Component::update() { } uint8_t checksum = mhz19_checksum(response); - if (response[8] != checksum) { + if (!this->status_comm_result(response[8] == checksum)) { ESP_LOGW(TAG, "MHZ19 Checksum doesn't match: 0x%02X!=0x%02X", response[8], checksum); - this->status_set_warning(); return; } - this->status_clear_warning(); const uint16_t ppm = (uint16_t(response[2]) << 8) | response[3]; const int temp = int(response[4]) - 40; const uint8_t status = response[5]; diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index 957cdf90ef2..c42ca7ab465 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -43,11 +43,8 @@ void MICS4514Component::update() { return; } uint8_t data[6]; - if (this->read_register(SENSOR_REGISTER, data, 6) != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_register(SENSOR_REGISTER, data, 6) == i2c::ERROR_OK)) return; - } - this->status_clear_warning(); ESP_LOGV(TAG, "Got data: %02X %02X %02X %02X %02X %02X", data[0], data[1], data[2], data[3], data[4], data[5]); uint16_t ox = encode_uint16(data[0], data[1]); uint16_t red = encode_uint16(data[2], data[3]); diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index bbcee223e8c..781028e7f5c 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -112,13 +112,7 @@ bool MPR121Component::flush_gpio_() { this->write_byte(MPR121_GPIOEN, this->gpio_enable_); this->write_byte(MPR121_GPIODIR, this->gpio_direction_); - if (!this->write_byte(MPR121_GPIODATA, this->gpio_output_)) { - this->status_set_warning(); - return false; - } - - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_byte(MPR121_GPIODATA, this->gpio_output_)); } void MPR121GPIOPin::setup() { this->pin_mode(this->flags_); } diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index 8c0acbbe434..813321fac9e 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -289,14 +289,11 @@ void NAU7802Sensor::loop() { } void NAU7802Sensor::update() { - if (!this->is_data_ready_()) { + if (!this->status_comm_result(this->is_data_ready_(), nullptr)) { ESP_LOGW(TAG, "No measurements ready!"); - this->status_set_warning(); return; } - this->status_clear_warning(); - // Get the most recent sample to publish int32_t result = this->read_value_(ADCO_B2_REG, 3); diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index 374fd707561..caf7decde94 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -104,13 +104,11 @@ void OPT3001Sensor::update() { this->read_lx_([this](float val) { this->updating_ = false; - if (std::isnan(val)) { - this->status_set_warning(); + if (!this->status_comm_result(!std::isnan(val))) { this->publish_state(NAN); return; } ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), val); - this->status_clear_warning(); this->publish_state(val); }); } diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index 0825d6eac98..5f5b4ea4a10 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -138,13 +138,10 @@ bool PCA6416AComponent::read_register_(uint8_t reg, uint8_t *value) { } this->last_error_ = this->read_register(reg, value, 1); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - - this->status_clear_warning(); return true; } @@ -155,13 +152,10 @@ bool PCA6416AComponent::write_register_(uint8_t reg, uint8_t value) { } this->last_error_ = this->write_register(reg, &value, 1); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - - this->status_clear_warning(); return true; } diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index a12d291f6b2..4f44143c547 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -110,12 +110,10 @@ bool PCA9554Component::read_inputs_() { } this->last_error_ = this->read_register(INPUT_REG * this->reg_width_, inputs, this->reg_width_); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - this->status_clear_warning(); this->input_mask_ = inputs[0]; if (this->reg_width_ == 2) { this->input_mask_ |= inputs[1] << 8; @@ -128,13 +126,10 @@ bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { outputs[0] = (uint8_t) value; outputs[1] = (uint8_t) (value >> 8); this->last_error_ = this->write_register(reg * this->reg_width_, outputs, this->reg_width_); - if (this->last_error_ != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->last_error_ == i2c::ERROR_OK)) { ESP_LOGE(TAG, "write_register_(): I2C I/O error: %d", (int) this->last_error_); return false; } - - this->status_clear_warning(); return true; } diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 70068ea84da..5674b6a53e8 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -87,12 +87,9 @@ bool PCF8574Component::read_gpio_() { if (this->is_failed()) return false; uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read. - if (!this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1))) return false; - } this->input_mask_ = encode_value_le(data); - this->status_clear_warning(); return true; } bool PCF8574Component::write_gpio_() { @@ -108,13 +105,7 @@ bool PCF8574Component::write_gpio_() { uint8_t data[2]; data[0] = value; data[1] = value >> 8; - if (this->write(data, this->pcf8575_ ? 2 : 1) != i2c::ERROR_OK) { - this->status_set_warning(); - return false; - } - - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write(data, this->pcf8575_ ? 2 : 1) == i2c::ERROR_OK); } float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 0b5af94e696..28f98325ea4 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -105,15 +105,13 @@ bool PI4IOE5V6408Component::read_gpio_modes_() { return false; uint8_t data; - if (!this->read_byte(PI4IOE5V6408_REGISTER_IO_DIR, &data)) { - this->status_set_warning(LOG_STR("Failed to read GPIO modes")); + if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_IO_DIR, &data), + LOG_STR("Failed to read GPIO modes"))) return false; - } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Read GPIO modes: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(data)); #endif this->mode_mask_ = data; - this->status_clear_warning(); return true; } @@ -140,14 +138,12 @@ void PI4IOE5V6408Component::digital_write_hw(uint8_t pin, bool value) { } else { this->output_mask_ &= ~(1 << pin); } - if (!this->write_byte(PI4IOE5V6408_REGISTER_OUT_SET, this->output_mask_)) { - this->status_set_warning(LOG_STR("Failed to write output register")); + if (!this->status_comm_result(this->write_byte(PI4IOE5V6408_REGISTER_OUT_SET, this->output_mask_), + LOG_STR("Failed to write output register"))) return; - } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, "Wrote GPIO output: 0b" BYTE_TO_BINARY_PATTERN, BYTE_TO_BINARY(this->output_mask_)); #endif - this->status_clear_warning(); } bool PI4IOE5V6408Component::write_gpio_modes_() { diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index fd38edf136a..c83726949d9 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -106,16 +106,15 @@ void PN532::update() { obj->on_scan_end(); #endif - if (!this->write_command_({ - PN532_COMMAND_INLISTPASSIVETARGET, - 0x01, // max 1 card - 0x00, // baud rate ISO14443A (106 kbit/s) - })) { + const bool ok = this->write_command_({ + PN532_COMMAND_INLISTPASSIVETARGET, + 0x01, // max 1 card + 0x00, // baud rate ISO14443A (106 kbit/s) + }); + if (!this->status_comm_result(ok)) { ESP_LOGW(TAG, "Requesting tag read failed!"); - this->status_set_warning(); return; } - this->status_clear_warning(); this->requested_read_ = true; } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 9dce402fe02..83344de479d 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -288,12 +288,10 @@ bool QMP6988Component::calculate_pressure_() { int32_t t_int, p_int; err = this->read_register(QMP6988_PRESSURE_MSB_REG, a_data_uint8_tr, 6); - if (err != i2c::ERROR_OK) { + if (!this->status_comm_result(err == i2c::ERROR_OK)) { ESP_LOGE(TAG, "Error reading raw pressure/temp values"); - this->status_set_warning(); return false; } - this->status_clear_warning(); p_read = encode_uint24(a_data_uint8_tr[0], a_data_uint8_tr[1], a_data_uint8_tr[2]); p_raw = (int32_t) (p_read - SUBTRACTOR); diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 22a63ca4fd3..18ad08ab6d2 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -284,14 +284,12 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen this->store_.index = offset; esp_err_t error = rmt_transmit(this->channel_, this->encoder_, this->rmt_temp_.data(), this->rmt_temp_.size() * sizeof(rmt_symbol_half_t), &config); - if (error != ESP_OK) { + if (!this->status_comm_result(error == ESP_OK, nullptr)) { ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); - this->status_set_warning(); // nothing was queued, so there is no frame to wait for this->fire_complete_(false); return; } - this->status_clear_warning(); if (this->non_blocking_) { this->set_timeout(COMPLETE_TIMEOUT_ID, total_duration / 1000, [this]() { this->wait_for_rmt_(); }); diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index c94c64f8611..f33d39b8d0c 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -52,12 +52,10 @@ void RX8025TComponent::read_time() { } uint8_t date[7]; - if (!this->read_bytes(RX8025T_REG_SEC, date, sizeof(date))) { + if (!this->status_comm_result(this->read_bytes(RX8025T_REG_SEC, date, sizeof(date)))) { ESP_LOGE(TAG, "Can't read I2C data."); - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - this->status_clear_warning(); if (flags & RX8025T_FLAG_VLF) { ESP_LOGW(TAG, "VLF flag is set - time data may be invalid, not syncing to system clock."); diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp index b3f26cb2611..531c4233e47 100644 --- a/esphome/components/rx8111/rx8111.cpp +++ b/esphome/components/rx8111/rx8111.cpp @@ -81,13 +81,11 @@ void RX8111Component::read_time() { this->status_set_warning(LOG_STR("Invalid RTC time")); return; } - if (ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week != weekday) { + if (!this->status_comm_result(ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == weekday, + LOG_STR("Invalid RTC time"))) { ESP_LOGW(TAG, "RTC weekday does not match calendar date"); - this->status_set_warning(LOG_STR("Invalid RTC time")); return; } - - this->status_clear_warning(); ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month, rtc_time.hour, rtc_time.minute, rtc_time.second); time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index 9a895b0e0e9..db9eca8288c 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -133,11 +133,7 @@ void SelecMeter::on_read_input_registers(uint16_t start_address, std::spandecode_em2m_(registers); #endif - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(); - } + this->status_comm_result(ok); break; } #if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) diff --git a/esphome/components/sen21231/sen21231.cpp b/esphome/components/sen21231/sen21231.cpp index d45b247a437..b05e21eaacb 100644 --- a/esphome/components/sen21231/sen21231.cpp +++ b/esphome/components/sen21231/sen21231.cpp @@ -15,12 +15,10 @@ void Sen21231Sensor::dump_config() { void Sen21231Sensor::read_data_() { person_sensor_results_t results; - if (!this->read_bytes(PERSON_SENSOR_I2C_ADDRESS, (uint8_t *) &results, sizeof(results))) { + if (!this->status_comm_result(this->read_bytes(PERSON_SENSOR_I2C_ADDRESS, (uint8_t *) &results, sizeof(results)))) { ESP_LOGW(TAG, "Failed to read data from SEN21231"); - this->status_set_warning(); return; } - this->status_clear_warning(); ESP_LOGD(TAG, "SEN21231: %d faces detected", results.num_faces); this->publish_state(results.num_faces); if (results.num_faces == 1) { diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 9f95ed192c0..4e8a79124c6 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -678,8 +678,7 @@ void SEN6XComponent::parse_and_publish_number_concentration_() { this->pmc_4_0_sensor_, this->pmc_10_0_sensor_}; uint16_t measurements[std::size(sensors)]; - if (!this->read_data(measurements, std::size(measurements))) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_data(measurements, std::size(measurements)))) { ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_); return; } @@ -688,7 +687,6 @@ void SEN6XComponent::parse_and_publish_number_concentration_() { if (sensors[i] != nullptr) sensors[i]->publish_state(measurements[i] == 0xFFFF ? NAN : measurements[i] / 10.0f); } - this->status_clear_warning(); } #endif diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index d5849ddfc0a..7b40d0d8409 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -44,13 +44,11 @@ void SenseAirComponent::update() { uint16_t calc_checksum = crc16(response, 11); uint16_t resp_checksum = encode_value_le(&response[11]); - if (resp_checksum != calc_checksum) { + if (!this->status_comm_result(resp_checksum == calc_checksum)) { ESP_LOGW(TAG, "SenseAir checksum doesn't match: 0x%02X!=0x%02X", resp_checksum, calc_checksum); - this->status_set_warning(); return; } - this->status_clear_warning(); const uint8_t length = response[2]; const uint16_t status = encode_uint16(response[3], response[4]); const uint16_t ppm = encode_uint16(response[length + 1], response[length + 2]); diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index d46020b2d9a..5e6b8849dd1 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -87,15 +87,12 @@ void SHT4XComponent::update() { uint16_t buffer[2]; // Read measurement - if (!this->read_data(buffer, 2)) { + if (!this->status_comm_result(this->read_data(buffer, 2))) { // Using ESP_LOGW to force the warning to be printed ESP_LOGW(TAG, "Sensor read failed"); - this->status_set_warning(); return; } - this->status_clear_warning(); - // Evaluate and publish measurements if (this->temp_sensor_ != nullptr) { // Temp is contained in the first result word diff --git a/esphome/components/spd2010/spd2010_touchscreen.cpp b/esphome/components/spd2010/spd2010_touchscreen.cpp index 55fa177d18e..b70fe1acad9 100644 --- a/esphome/components/spd2010/spd2010_touchscreen.cpp +++ b/esphome/components/spd2010/spd2010_touchscreen.cpp @@ -147,11 +147,9 @@ bool SPD2010Touchscreen::read_data_() { return true; } if (!(status_low & (STATUS_POINT | STATUS_GESTURE))) { - if ((status_high & STATUS_RUNNING) && (status_low & STATUS_AUX) && !this->clear_interrupt_()) { - this->status_set_warning(LOG_STR("Failed to clear interrupt")); - return false; - } - this->status_clear_warning(); + const bool needs_clear = (status_high & STATUS_RUNNING) && (status_low & STATUS_AUX); + const bool cleared = !needs_clear || this->clear_interrupt_(); + this->status_comm_result(cleared, LOG_STR("Failed to clear interrupt")); return false; } if (length < REPORT_HEADER_LENGTH || length > MAX_REPORT_LENGTH || diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp index 5cd986f6976..2f21de3f6d3 100644 --- a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp @@ -73,12 +73,10 @@ void ST7123Touchscreen::update_touches() { // Reading from this register also clears the INT pin so the controller can report the next frame. uint8_t data[(ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + ST7123_MAX_TOUCHES * ST7123_TOUCH_STRIDE]; const size_t len = (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + this->max_touches_ * ST7123_TOUCH_STRIDE; - if (this->read_register16(ST7123_REG_ADV_TOUCH_INFO, data, len) != i2c::ERROR_OK) { + if (!this->status_comm_result(this->read_register16(ST7123_REG_ADV_TOUCH_INFO, data, len) == i2c::ERROR_OK)) { this->skip_update_ = true; - this->status_set_warning(); return; } - this->status_clear_warning(); const uint8_t *points = data + (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO); for (uint8_t i = 0; i != this->max_touches_; i++) { diff --git a/esphome/components/sts3x/sts3x.cpp b/esphome/components/sts3x/sts3x.cpp index fd9569b951d..9e224a024c6 100644 --- a/esphome/components/sts3x/sts3x.cpp +++ b/esphome/components/sts3x/sts3x.cpp @@ -50,15 +50,12 @@ void STS3XComponent::update() { this->set_timeout(50, [this]() { uint16_t raw_data[1]; - if (!this->read_data(raw_data, 1)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_data(raw_data, 1))) return; - } float temperature = 175.0f * float(raw_data[0]) / 65535.0f - 45.0f; ESP_LOGD(TAG, "Got temperature=%.2f°C", temperature); this->publish_state(temperature); - this->status_clear_warning(); }); } diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 754cfb14ca8..3efc4d8a3f9 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -123,14 +123,11 @@ void SY6970Component::update() { } // Read all registers in one transaction - if (!this->read_all_registers_()) { + if (!this->status_comm_result(this->read_all_registers_())) { ESP_LOGW(TAG, "Failed to read registers during update"); - this->status_set_warning(); return; } - this->status_clear_warning(); - // Notify all listeners with the new data for (auto *listener : this->listeners_) { listener->on_data(this->data_); diff --git a/esphome/components/t6615/t6615.cpp b/esphome/components/t6615/t6615.cpp index 6fb58345358..e06d3277842 100644 --- a/esphome/components/t6615/t6615.cpp +++ b/esphome/components/t6615/t6615.cpp @@ -44,18 +44,15 @@ void T6615Component::loop() { this->read_array(response_buffer, 5); // Read header - if (response_buffer[0] != T6615_MAGIC || response_buffer[1] != T6615_ADDR_HOST) { + if (!this->status_comm_result(response_buffer[0] == T6615_MAGIC && response_buffer[1] == T6615_ADDR_HOST)) { ESP_LOGW(TAG, "Got bad data from T6615! Magic was %02X and address was %02X", response_buffer[0], response_buffer[1]); this->discard_available(); /* try again to read the sensor */ this->send_ppm_command_(); - this->status_set_warning(); return; } - this->status_clear_warning(); - switch (this->command_) { case T6615Command::GET_PPM: { const uint16_t ppm = encode_uint16(response_buffer[3], response_buffer[4]); diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp index 204bc18c1e5..27e3e0337e5 100644 --- a/esphome/components/tca8418/tca8418.cpp +++ b/esphome/components/tca8418/tca8418.cpp @@ -86,11 +86,8 @@ void TCA8418Component::loop() { return; this->last_poll_ = now; - if (!this->drain_events_(true)) { - this->status_set_warning(); + if (!this->status_comm_result(this->drain_events_(true))) return; - } - this->status_clear_warning(); // Events that arrive during a read keep INT asserted without a new edge, so only stop once it is clear if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 6ee4c9e3ee6..e050325436c 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -451,12 +451,10 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { this->flush(); // On the single wire bus every byte sent is also received, drop that echo uint8_t echo[DATAGRAM_SIZE]; - if (!this->read_array(echo, DATAGRAM_SIZE) || memcmp(echo, datagram, DATAGRAM_SIZE) != 0) { + if (!this->status_comm_result(this->read_array(echo, DATAGRAM_SIZE) && memcmp(echo, datagram, DATAGRAM_SIZE) == 0)) { ESP_LOGV(TAG, "No echo writing register 0x%02X", reg); - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } - this->status_clear_warning(); if (uint32_t *shadow = this->shadow_register(reg)) { *shadow = value; @@ -483,12 +481,11 @@ optional TMC22XXStepper::read_register(uint8_t reg) { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return {}; } - if (reply[0] != SYNC_BYTE || reply[1] != MASTER_ADDRESS || reply[2] != reg || reply[7] != datagram_crc(reply, 7)) { + if (!this->status_comm_result(reply[0] == SYNC_BYTE && reply[1] == MASTER_ADDRESS && reply[2] == reg && + reply[7] == datagram_crc(reply, 7))) { ESP_LOGV(TAG, "Invalid reply reading register 0x%02X", reg); - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return {}; } - this->status_clear_warning(); return encode_uint32(reply[3], reply[4], reply[5], reply[6]); } diff --git a/esphome/components/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 0cb2456cbd3..58858b9b386 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -29,12 +29,10 @@ void TMP1075Sensor::setup() { void TMP1075Sensor::update() { uint16_t regvalue; - if (!read_byte_16(REG_TEMP, ®value)) { + if (!this->status_comm_result(this->read_byte_16(REG_TEMP, ®value), LOG_STR("can't read"))) { ESP_LOGW(TAG, "'%s' - unable to read temperature register", this->name_.c_str()); - this->status_set_warning(LOG_STR("can't read")); return; } - this->status_clear_warning(); const float temp = regvalue2temp(regvalue); this->publish_state(temp); diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 0ae96c9fd1c..33f33bda104 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -119,15 +119,12 @@ void TSL2561Sensor::read_data_() { return; } // Power off - if (!this->tsl2561_write_byte(TSL2561_REGISTER_CONTROL, 0b00000000)) { - this->status_set_warning(); + if (!this->status_comm_result(this->tsl2561_write_byte(TSL2561_REGISTER_CONTROL, 0b00000000))) return; - } float lx = this->calculate_lx_(data0, data1); ESP_LOGD(TAG, "Got illuminance=%.1flx", lx); this->publish_state(lx); - this->status_clear_warning(); } float TSL2561Sensor::get_integration_time_ms_() { switch (this->integration_time_) { diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.cpp b/esphome/components/ttp229_lsf/ttp229_lsf.cpp index bf5c3f67284..f50e5392915 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.cpp +++ b/esphome/components/ttp229_lsf/ttp229_lsf.cpp @@ -18,12 +18,9 @@ void TTP229LSFComponent::dump_config() { } void TTP229LSFComponent::loop() { uint16_t touched = 0; - if (this->read(reinterpret_cast(&touched), 2) != i2c::ERROR_OK) { - this->status_set_warning(); + if (!this->status_comm_result(this->read(reinterpret_cast(&touched), 2) == i2c::ERROR_OK)) return; - } touched = i2c::i2ctohs(touched); - this->status_clear_warning(); touched = reverse_bits(touched); for (auto *channel : this->channels_) { channel->process(touched); diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index ffaaf9eb2a3..1b8984247a4 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -54,15 +54,12 @@ void VEML3235Sensor::update() { void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { uint8_t als_regs[] = {0, 0}; - if ((this->read_register(ALS_REG, als_regs, sizeof als_regs) != i2c::ERROR_OK)) { - this->status_set_warning(); + if (!this->status_comm_result(this->read_register(ALS_REG, als_regs, sizeof als_regs) == i2c::ERROR_OK)) { this->publish_state(NAN); this->measurement_in_progress_ = false; return; } - this->status_clear_warning(); - uint16_t als_counts = encode_value_le(als_regs); if (this->auto_gain_ && adjustments_left > 0) { From 5575dd8928e0fd09a5fa02308fd72e1fc803d5e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:46:57 -1000 Subject: [PATCH 151/179] [core] Add final_validate.get_declaring_config (#20912) --- esphome/final_validate.py | 17 ++++++++- tests/unit_tests/test_final_validate.py | 47 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/test_final_validate.py diff --git a/esphome/final_validate.py b/esphome/final_validate.py index cebd2f1cdad..46be8f0b3f5 100644 --- a/esphome/final_validate.py +++ b/esphome/final_validate.py @@ -3,7 +3,7 @@ import contextvars from typing import Any import esphome.config_validation as cv -from esphome.types import ID, ConfigFragmentType, ConfigPathType +from esphome.types import ID, ConfigFragmentType, ConfigPathType, ConfigType class FinalValidateConfig(ABC): @@ -39,6 +39,21 @@ full_config: contextvars.ContextVar[FinalValidateConfig] = contextvars.ContextVa ) +def get_declaring_config( + id_: ID, config: FinalValidateConfig | None = None +) -> ConfigType: + """Return the config block that declares id_, such as a hub's own config. + + Uses the full config being validated unless config is given. + Raises KeyError if the id was not declared, TypeError if its parent is not a dict. + """ + fconf = full_config.get() if config is None else config + declaring = fconf.get_config_for_path(fconf.get_path_for_id(id_)[:-1]) + if not isinstance(declaring, dict): + raise TypeError(f"ID {id_} is not declared in a config block") + return declaring + + def id_declaration_match_schema(schema): """A final-validation schema function that applies a schema to the outer config fragment of an ID declaration. diff --git a/tests/unit_tests/test_final_validate.py b/tests/unit_tests/test_final_validate.py new file mode 100644 index 00000000000..834dc92ffbd --- /dev/null +++ b/tests/unit_tests/test_final_validate.py @@ -0,0 +1,47 @@ +"""Tests for esphome.final_validate helpers.""" + +import pytest + +from esphome import final_validate as fv +from esphome.config import Config +from esphome.const import CONF_ID +from esphome.core import ID + + +def _config() -> tuple[Config, ID, dict]: + hub_id = ID("my_hub", is_declaration=True) + hub = {CONF_ID: hub_id, "mode": "fast"} + config = Config() + config["other"] = {"x": 1} + config["my_comp"] = [{"y": 2}, hub] + config.declare_ids.append((hub_id, ["my_comp", 1, CONF_ID])) + return config, hub_id, hub + + +def test_returns_the_declaring_block_from_the_full_config() -> None: + config, hub_id, hub = _config() + token = fv.full_config.set(config) + try: + assert fv.get_declaring_config(hub_id) is hub + finally: + fv.full_config.reset(token) + + +def test_uses_the_given_config() -> None: + config, hub_id, hub = _config() + assert fv.get_declaring_config(ID("my_hub"), config) is hub + + +def test_unknown_id_raises_key_error() -> None: + config, _, _ = _config() + with pytest.raises(KeyError): + fv.get_declaring_config(ID("missing"), config) + + +def test_non_dict_parent_raises_type_error() -> None: + config, _, _ = _config() + list_id = ID("in_list", is_declaration=True) + config["listed"] = [list_id] + config.declare_ids.append((list_id, ["listed", 0])) + with pytest.raises(TypeError): + fv.get_declaring_config(ID("in_list"), config) From 1767e4b0be55984be42d172a5771f20deb836acf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:47:19 -1000 Subject: [PATCH 152/179] [sensor] Add SensorWithDedup for child sensors that skip repeated values (#20925) --- esphome/components/ld24xx/ld24xx.h | 38 +---------- esphome/components/sensor/sensor_with_dedup.h | 49 +++++++++++++++ .../sensor/sensor_with_dedup_test.cpp | 63 +++++++++++++++++++ 3 files changed, 114 insertions(+), 36 deletions(-) create mode 100644 esphome/components/sensor/sensor_with_dedup.h create mode 100644 tests/components/sensor/sensor_with_dedup_test.cpp diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index deac04e86f9..b7dddb92fad 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -7,14 +7,7 @@ #include #ifdef USE_SENSOR -#include "esphome/components/sensor/sensor.h" - -#define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ - protected: \ - ld24xx::SensorWithDedup name##_sensor_{}; \ -\ - public: \ - void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); } +#include "esphome/components/sensor/sensor_with_dedup.h" #endif #define LOG_SENSOR_WITH_DEDUP_SAFE(tag, name, sensor) \ @@ -61,33 +54,6 @@ inline void format_version_str(const uint8_t *version, std::span buffe } #ifdef USE_SENSOR -/// Sensor with deduplication — sensor may be null, null check is internal. -/// Stored inline, no heap allocation. Does nothing when no sensor is set. -template class SensorWithDedup { - public: - void set_sensor(sensor::Sensor *sens) { - this->sens_ = sens; - this->dedup_ = {}; - } - - void publish_state_if_not_dup(T state) { - if (this->sens_ != nullptr && this->dedup_.next(state)) { - this->sens_->publish_state(static_cast(state)); - } - } - - void publish_state_unknown() { - if (this->sens_ != nullptr && this->dedup_.next_unknown()) { - this->sens_->publish_state(NAN); - } - } - - bool has_sensor() const { return this->sens_ != nullptr; } - sensor::Sensor *get_sensor() const { return this->sens_; } - - protected: - sensor::Sensor *sens_{nullptr}; - Deduplicator dedup_; -}; +template using SensorWithDedup = sensor::SensorWithDedup; #endif } // namespace esphome::ld24xx diff --git a/esphome/components/sensor/sensor_with_dedup.h b/esphome/components/sensor/sensor_with_dedup.h new file mode 100644 index 00000000000..6fab40f1e68 --- /dev/null +++ b/esphome/components/sensor/sensor_with_dedup.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::sensor { + +/// Declare an optional child sensor that skips publishing a value equal to the last one. +#define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ + protected: \ + sensor::SensorWithDedup name##_sensor_{}; \ +\ + public: \ + void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); } + +#define LOG_SENSOR_WITH_DEDUP(prefix, type, s) LOG_SENSOR(prefix, type, (s).get_sensor()) + +/// A sensor pointer that may be null plus the last value it published, stored inline. +/// Publishing does nothing when no sensor is set, and skips a value equal to the last one. +template class SensorWithDedup { + public: + void set_sensor(Sensor *sens) { + this->sens_ = sens; + this->dedup_ = {}; + } + + void publish_state_if_not_dup(T state) { + if (this->sens_ != nullptr && this->dedup_.next(state)) { + this->sens_->publish_state(static_cast(state)); + } + } + + void publish_state_unknown() { + if (this->sens_ != nullptr && this->dedup_.next_unknown()) { + this->sens_->publish_state(NAN); + } + } + + bool has_sensor() const { return this->sens_ != nullptr; } + Sensor *get_sensor() const { return this->sens_; } + + protected: + Sensor *sens_{nullptr}; + Deduplicator dedup_; +}; + +} // namespace esphome::sensor diff --git a/tests/components/sensor/sensor_with_dedup_test.cpp b/tests/components/sensor/sensor_with_dedup_test.cpp new file mode 100644 index 00000000000..1bff52193b0 --- /dev/null +++ b/tests/components/sensor/sensor_with_dedup_test.cpp @@ -0,0 +1,63 @@ +#include +#include + +#include "esphome/components/sensor/sensor_with_dedup.h" + +namespace esphome::sensor::testing { + +class SensorWithDedupTest : public ::testing::Test { + protected: + void SetUp() override { + this->sensor_.add_on_state_callback([this](float value) { this->published_.push_back(value); }); + } + + Sensor sensor_; + std::vector published_; +}; + +TEST_F(SensorWithDedupTest, NoSensorDoesNothing) { + SensorWithDedup dedup; + EXPECT_FALSE(dedup.has_sensor()); + EXPECT_EQ(dedup.get_sensor(), nullptr); + dedup.publish_state_if_not_dup(5); + dedup.publish_state_unknown(); + EXPECT_TRUE(this->published_.empty()); +} + +TEST_F(SensorWithDedupTest, SkipsRepeatedValues) { + SensorWithDedup dedup; + dedup.set_sensor(&this->sensor_); + EXPECT_TRUE(dedup.has_sensor()); + EXPECT_EQ(dedup.get_sensor(), &this->sensor_); + dedup.publish_state_if_not_dup(-3); + dedup.publish_state_if_not_dup(-3); + dedup.publish_state_if_not_dup(7); + dedup.publish_state_if_not_dup(7); + ASSERT_EQ(this->published_.size(), 2u); + EXPECT_EQ(this->published_[0], -3.0f); + EXPECT_EQ(this->published_[1], 7.0f); +} + +TEST_F(SensorWithDedupTest, UnknownPublishedOnceThenValueAgain) { + SensorWithDedup dedup; + dedup.set_sensor(&this->sensor_); + dedup.publish_state_if_not_dup(1.5f); + dedup.publish_state_unknown(); + dedup.publish_state_unknown(); + dedup.publish_state_if_not_dup(1.5f); + ASSERT_EQ(this->published_.size(), 3u); + EXPECT_EQ(this->published_[0], 1.5f); + EXPECT_TRUE(std::isnan(this->published_[1])); + EXPECT_EQ(this->published_[2], 1.5f); +} + +TEST_F(SensorWithDedupTest, SetSensorResetsHistory) { + SensorWithDedup dedup; + dedup.set_sensor(&this->sensor_); + dedup.publish_state_if_not_dup(100); + dedup.set_sensor(&this->sensor_); + dedup.publish_state_if_not_dup(100); + EXPECT_EQ(this->published_.size(), 2u); +} + +} // namespace esphome::sensor::testing From 1753c45e5358b4cea9113b911b6cde4384f9dae0 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:47:37 +1100 Subject: [PATCH 153/179] [core] Move CONF_PROVIDER and CONF_REMOTE_ID to components/const (#21096) Co-authored-by: Claude Opus 5.5 --- esphome/components/const/__init__.py | 2 ++ esphome/components/dlms_meter/__init__.py | 2 +- esphome/components/packet_transport/__init__.py | 3 +-- esphome/components/packet_transport/binary_sensor.py | 3 +-- esphome/components/packet_transport/sensor.py | 8 ++------ esphome/components/remote_base/__init__.py | 2 +- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 99d7bf3f525..c2ae1a1a87b 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -55,10 +55,12 @@ CONF_ON_STATE_CHANGE = "on_state_change" CONF_ON_STOP = "on_stop" CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" +CONF_PROVIDER = "provider" CONF_QUERY_PARAMS = "query_params" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_RED_CHANNEL = "red_channel" +CONF_REMOTE_ID = "remote_id" CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 46ccc2e44e6..4520d4e212d 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -3,6 +3,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart +from esphome.components.const import CONF_PROVIDER import esphome.config_validation as cv from esphome.const import CONF_NAME, CONF_PATTERN, CONF_PRIORITY, CONF_RECEIVE_TIMEOUT from esphome.core import CORE @@ -20,7 +21,6 @@ CONF_OBIS_CODE = "obis_code" CONF_CUSTOM_PATTERNS = "custom_patterns" CONF_SKIP_CRC = "skip_crc" CONF_DEFAULT_OBIS = "default_obis" -CONF_PROVIDER = "provider" DLMS_PARSER_VERSION = "2.2.0" diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index c36d421a355..e63782c26e4 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -7,6 +7,7 @@ from typing import Any import esphome.codegen as cg from esphome.components.binary_sensor import BinarySensor +from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID from esphome.components.sensor import Sensor import esphome.config_validation as cv from esphome.const import ( @@ -34,9 +35,7 @@ IS_PLATFORM_COMPONENT = True DOMAIN = "packet_transport" CONF_BROADCAST = "broadcast" CONF_BROADCAST_ID = "broadcast_id" -CONF_PROVIDER = "provider" CONF_PROVIDERS = "providers" -CONF_REMOTE_ID = "remote_id" CONF_PING_PONG_ENABLE = "ping_pong_enable" CONF_PING_PONG_RECYCLE_TIME = "ping_pong_recycle_time" CONF_ROLLING_CODE_ENABLE = "rolling_code_enable" diff --git a/esphome/components/packet_transport/binary_sensor.py b/esphome/components/packet_transport/binary_sensor.py index 37c46882423..c670190af05 100644 --- a/esphome/components/packet_transport/binary_sensor.py +++ b/esphome/components/packet_transport/binary_sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID import esphome.config_validation as cv from esphome.const import ( CONF_DATA, @@ -16,9 +17,7 @@ from esphome.types import ConfigType from . import ( CONF_ENCRYPTION, CONF_PING_PONG_ENABLE, - CONF_PROVIDER, CONF_PROVIDERS, - CONF_REMOTE_ID, CONF_TRANSPORT_ID, PacketTransport, packet_transport_sensor_schema, diff --git a/esphome/components/packet_transport/sensor.py b/esphome/components/packet_transport/sensor.py index 018f1c3a9b0..7eb48f6214b 100644 --- a/esphome/components/packet_transport/sensor.py +++ b/esphome/components/packet_transport/sensor.py @@ -1,14 +1,10 @@ import esphome.codegen as cg +from esphome.components.const import CONF_PROVIDER, CONF_REMOTE_ID from esphome.components.sensor import new_sensor, sensor_schema from esphome.const import CONF_ID from esphome.types import ConfigType -from . import ( - CONF_PROVIDER, - CONF_REMOTE_ID, - CONF_TRANSPORT_ID, - packet_transport_sensor_schema, -) +from . import CONF_TRANSPORT_ID, packet_transport_sensor_schema CONFIG_SCHEMA = packet_transport_sensor_schema(sensor_schema()) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index b01b0f3645e..3d5053ca7e0 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -5,6 +5,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.components.const import CONF_REMOTE_ID from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -72,7 +73,6 @@ CONF_CONTROL = "control" CONF_CONTROL_IN_KEY = "control_in_key" CONF_ROLLING_CODE = "rolling_code" CONF_EXTENSION = "extension" -CONF_REMOTE_ID = "remote_id" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") From c793ab30a74cad51e9990f3284d268927463ba76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 14:48:35 -1000 Subject: [PATCH 154/179] [core][gpio_expander] Add a shared expander pin class and pin codegen helpers (#20866) --- .../gpio_expander/expander_gpio_pin.h | 43 ++++++++++ esphome/pins.py | 36 ++++++++- .../gpio_expander/test_expander_gpio_pin.cpp | 78 +++++++++++++++++++ tests/unit_tests/test_pins.py | 58 ++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 esphome/components/gpio_expander/expander_gpio_pin.h create mode 100644 tests/components/gpio_expander/test_expander_gpio_pin.cpp diff --git a/esphome/components/gpio_expander/expander_gpio_pin.h b/esphome/components/gpio_expander/expander_gpio_pin.h new file mode 100644 index 00000000000..5dbb2883551 --- /dev/null +++ b/esphome/components/gpio_expander/expander_gpio_pin.h @@ -0,0 +1,43 @@ +#pragma once + +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" + +namespace esphome::gpio_expander { + +/// Pin of a GPIO expander. Parent provides pin_mode(pin, flags), digital_read(pin) and digital_write(pin, value). +/// dump_summary() is specialized per expander with the macros below, outside the template so that its format +/// string stays in flash on ESP8266; a missing macro is an undefined reference to +/// ExpanderGPIOPin::dump_summary. Built by pins.new_parented_pin(). +template class ExpanderGPIOPin final : public GPIOPin, public Parented { + public: + // User provided, not "= default": `new(p) ExpanderGPIOPin()` would zero-fill .bss that is already zero. + ExpanderGPIOPin() {} + + void setup() override { this->pin_mode(this->flags_); } + void pin_mode(gpio::Flags flags) override { this->parent_->pin_mode(this->pin_, flags); } + bool digital_read() override { return this->parent_->digital_read(this->pin_) != this->inverted_; } + void digital_write(bool value) override { this->parent_->digital_write(this->pin_, value != this->inverted_); } + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::gpio_expander + +/// In the expander's header, at file scope: declares the pin summary its .cpp defines. +#define ESPHOME_EXPANDER_PIN_SUMMARY_DECL(Parent) \ + template<> size_t esphome::gpio_expander::ExpanderGPIOPin::dump_summary(char *buffer, size_t len) const + +/// In the expander's .cpp, at file scope: the pin summary, a printf format taking the pin number. +#define ESPHOME_EXPANDER_PIN_SUMMARY(Parent, format) \ + ESPHOME_EXPANDER_PIN_SUMMARY_DECL(Parent) { return buf_append_printf(buffer, len, 0, format, this->pin_); } diff --git a/esphome/pins.py b/esphome/pins.py index d42cc489a1b..f18cd25a4aa 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, CONF_ALLOW_OTHER_USES, + CONF_ID, CONF_IGNORE_STRAPPING_WARNING, CONF_INPUT, CONF_INVERTED, @@ -19,8 +20,9 @@ from esphome.const import ( CONF_PULLUP, ) from esphome.core import CORE, ID -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass from esphome.schema_extractors import schema_extractor +from esphome.types import ConfigType, SafeExpType class PinRegistry(dict): @@ -348,6 +350,38 @@ def gpio_validate_modes(value): return value +def gpio_validate_input_xor_output(value: dict[str, bool]) -> dict[str, bool]: + """Mode validator for pins that are either an input or an output, never both.""" + if value[CONF_INPUT] == value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +def gpio_validate_input_xor_output_pullup(value: dict[str, bool]) -> dict[str, bool]: + """``gpio_validate_input_xor_output`` for chips whose pull-up only works on inputs.""" + gpio_validate_input_xor_output(value) + if value[CONF_PULLUP] and not value[CONF_INPUT]: + raise cv.Invalid("Pullup only available with input") + return value + + +async def new_parented_pin( + config: ConfigType, parent_key: str, *args: SafeExpType +) -> MockObj: + """Create a pin parented to ``config[parent_key]`` and set its number, inversion and mode flags. + + ``args`` go to ``cg.new_Pvariable``, e.g. ``cg.TemplateArguments`` for a templated pin class. + """ + import esphome.codegen as cg + + var = cg.new_Pvariable(config[CONF_ID], *args) + await cg.register_parented(var, config[parent_key]) + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(gpio_flags_expr(config[CONF_MODE]))) + return var + + def gpio_base_schema( pin_type: MockObjClass, number_validator: Callable[[Any], Any], diff --git a/tests/components/gpio_expander/test_expander_gpio_pin.cpp b/tests/components/gpio_expander/test_expander_gpio_pin.cpp new file mode 100644 index 00000000000..38b3823aa6e --- /dev/null +++ b/tests/components/gpio_expander/test_expander_gpio_pin.cpp @@ -0,0 +1,78 @@ +#include + +#include "esphome/components/gpio_expander/expander_gpio_pin.h" + +namespace esphome::gpio_expander::testing { + +class FakeExpander { + public: + void pin_mode(uint8_t pin, gpio::Flags flags) { + this->mode_pin = pin; + this->mode_flags = flags; + } + bool digital_read(uint8_t pin) { return this->level; } + void digital_write(uint8_t pin, bool value) { + this->write_pin = pin; + this->written = value; + } + + uint8_t mode_pin{0xFF}; + gpio::Flags mode_flags{gpio::FLAG_NONE}; + bool level{false}; + uint8_t write_pin{0xFF}; + bool written{false}; +}; + +using FakeExpanderPin = ExpanderGPIOPin; + +} // namespace esphome::gpio_expander::testing + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::gpio_expander::testing::FakeExpander); +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::gpio_expander::testing::FakeExpander, "%u via FAKE") + +namespace esphome::gpio_expander::testing { + +static FakeExpanderPin make_pin(FakeExpander *parent, uint8_t pin, bool inverted, gpio::Flags flags) { + FakeExpanderPin gpio_pin; + gpio_pin.set_parent(parent); + gpio_pin.set_pin(pin); + gpio_pin.set_inverted(inverted); + gpio_pin.set_flags(flags); + return gpio_pin; +} + +TEST(ExpanderGPIOPin, SetupAppliesConfiguredFlags) { + FakeExpander parent; + auto pin = make_pin(&parent, 3, false, gpio::FLAG_INPUT | gpio::FLAG_PULLUP); + pin.setup(); + EXPECT_EQ(parent.mode_pin, 3); + EXPECT_EQ(parent.mode_flags, gpio::FLAG_INPUT | gpio::FLAG_PULLUP); + EXPECT_EQ(pin.get_flags(), gpio::FLAG_INPUT | gpio::FLAG_PULLUP); +} + +TEST(ExpanderGPIOPin, ReadAndWriteHonourInversion) { + FakeExpander parent; + auto plain = make_pin(&parent, 1, false, gpio::FLAG_OUTPUT); + auto inverted = make_pin(&parent, 2, true, gpio::FLAG_OUTPUT); + + parent.level = true; + EXPECT_TRUE(plain.digital_read()); + EXPECT_FALSE(inverted.digital_read()); + + inverted.digital_write(true); + EXPECT_EQ(parent.write_pin, 2); + EXPECT_FALSE(parent.written); + plain.digital_write(true); + EXPECT_EQ(parent.write_pin, 1); + EXPECT_TRUE(parent.written); +} + +TEST(ExpanderGPIOPin, SummaryUsesTheExpanderFormat) { + FakeExpander parent; + auto pin = make_pin(&parent, 7, false, gpio::FLAG_INPUT); + char buffer[32]; + pin.dump_summary(buffer, sizeof(buffer)); + EXPECT_STREQ(buffer, "7 via FAKE"); +} + +} // namespace esphome::gpio_expander::testing diff --git a/tests/unit_tests/test_pins.py b/tests/unit_tests/test_pins.py index 18dc95a5c6c..cd4a71d2c0b 100644 --- a/tests/unit_tests/test_pins.py +++ b/tests/unit_tests/test_pins.py @@ -1,5 +1,7 @@ """Unit tests for esphome/pins.py.""" +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from esphome import config_validation as cv, pins @@ -108,3 +110,59 @@ def test_use_id_or_address_registers_as_use_id_schema(monkeypatch) -> None: validator = pins.use_id_or_address(SomeHub) assert schema_extractors.hidden_schemas.get(repr(validator)) == "use_id" + + +def _mode(input_: bool, output: bool, pullup: bool = False) -> dict[str, bool]: + return {"input": input_, "output": output, "pullup": pullup} + + +@pytest.mark.parametrize(("input_", "output"), [(True, False), (False, True)]) +def test_input_xor_output_accepts_one(input_: bool, output: bool) -> None: + mode = _mode(input_, output) + assert pins.gpio_validate_input_xor_output(mode) is mode + + +@pytest.mark.parametrize(("input_", "output"), [(True, True), (False, False)]) +def test_input_xor_output_rejects_both_or_neither(input_: bool, output: bool) -> None: + with pytest.raises(cv.Invalid, match="either input or output"): + pins.gpio_validate_input_xor_output(_mode(input_, output)) + + +def test_input_xor_output_pullup_allows_input_pullup() -> None: + mode = _mode(True, False, pullup=True) + assert pins.gpio_validate_input_xor_output_pullup(mode) is mode + + +def test_input_xor_output_pullup_rejects_output_pullup() -> None: + with pytest.raises(cv.Invalid, match="Pullup only available with input"): + pins.gpio_validate_input_xor_output_pullup(_mode(False, True, pullup=True)) + + +def test_input_xor_output_pullup_still_requires_one_mode() -> None: + with pytest.raises(cv.Invalid, match="either input or output"): + pins.gpio_validate_input_xor_output_pullup(_mode(True, True)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("args", [(), ("templ",)]) +async def test_new_parented_pin(args: tuple[str, ...]) -> None: + config = { + "id": "pin_id", + "hub": "hub_id", + "number": 5, + "inverted": True, + "mode": {"input": True, "output": False}, + } + var = MagicMock() + with ( + patch("esphome.codegen.new_Pvariable", return_value=var) as new_pvariable, + patch("esphome.codegen.register_parented", new=AsyncMock()) as parented, + patch("esphome.codegen.add") as add, + ): + assert await pins.new_parented_pin(config, "hub", *args) is var + new_pvariable.assert_called_once_with("pin_id", *args) + parented.assert_awaited_once_with(var, "hub_id") + var.set_pin.assert_called_once_with(5) + var.set_inverted.assert_called_once_with(True) + var.set_flags.assert_called_once() + assert add.call_count == 3 From 490d4723ca1ea23bccb557fb371f7356c12a4be6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:08:46 -1000 Subject: [PATCH 155/179] [ota] Log why the upload switched to UDP (#20996) --- esphome/espota2.py | 33 ++++++++++++++++----------- tests/unit_tests/test_espota2.py | 39 ++++++++++++++++++-------------- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/esphome/espota2.py b/esphome/espota2.py index aaef178753a..340722206ce 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -918,29 +918,36 @@ def _tcp_retransmits(sock: socket.socket) -> int | None: return struct.unpack_from(fmt, info, offset)[0] -def _link_lossy( +def _loss_reason( tcp_sock: socket.socket, slowest_exchange: float, prefer_udp: bool -) -> bool: - """Whether the handshake showed loss, so the data phase should use UDP.""" - return ( - prefer_udp - or slowest_exchange > UDP_SLOW_EXCHANGE - or bool(_tcp_retransmits(tcp_sock)) - ) +) -> str | None: + """Why the link looks lossy, so the data phase should use UDP; None if clean.""" + if prefer_udp: + return "an earlier attempt failed" + if slowest_exchange > UDP_SLOW_EXCHANGE: + return f"a handshake reply took {slowest_exchange:.1f}s" + if _tcp_retransmits(tcp_sock): + return "TCP resent data during the handshake" + return None def _start_udp( - sock: socket.socket | NoiseSocketWrapper, tcp_sock: socket.socket, lossy: bool + sock: socket.socket | NoiseSocketWrapper, + tcp_sock: socket.socket, + loss_reason: str | None, ) -> UdpChannel | None: """Move the data phase to UDP; None keeps it on TCP.""" # A clean link asks to stay on TCP - send_check(sock, UDP_REQUEST if lossy else UDP_FALLBACK, "UDP request") + send_check( + sock, UDP_FALLBACK if loss_reason is None else UDP_REQUEST, "UDP request" + ) token = receive_exactly(sock, 4, "UDP token", None, decode=False) - if not lossy: + if loss_reason is None: _LOGGER.debug("Link looks clean, staying on TCP") return None + _LOGGER.info("Link is lossy (%s), trying UDP", loss_reason) if token == bytes(4): - _LOGGER.debug("Device declined UDP") + _LOGGER.info("Device declined UDP, using TCP") return None udp_sock = socket.socket(tcp_sock.family, socket.SOCK_DGRAM) try: @@ -1192,7 +1199,7 @@ def perform_ota( # Still under SETUP_TIMEOUT, which the device's commit wait is sized for if extended_proto and features & SERVER_FEATURE_SUPPORTS_UDP: udp = _start_udp( - sock, tcp_sock, _link_lossy(tcp_sock, slowest_exchange, prefer_udp) + sock, tcp_sock, _loss_reason(tcp_sock, slowest_exchange, prefer_udp) ) if udp is not None: # The device drops TCP once it hears the committed channel diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 04b09b4d542..c28944024bc 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -1695,45 +1695,50 @@ def test_start_udp_falls_back_when_blocked(mock_socket: Mock) -> None: mock_socket.getpeername.return_value = device.sock.getsockname() mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x08"] with patch.object(espota2, "UDP_PROBE_TIMEOUT", 0.3): - assert espota2._start_udp(mock_socket, mock_socket, True) is None + assert espota2._start_udp(mock_socket, mock_socket, "lossy") is None assert mock_socket.sendall.call_args_list == [ call(espota2.UDP_REQUEST), call(espota2.UDP_FALLBACK), # raw on the TCP socket ] -def test_start_udp_declined(mock_socket: Mock) -> None: +def test_start_udp_declined( + mock_socket: Mock, caplog: pytest.LogCaptureFixture +) -> None: """A zero token means the device could not open UDP; nothing is probed.""" mock_socket.recv.side_effect = [b"\x00", b"\x00\x00\x00"] - assert espota2._start_udp(mock_socket, mock_socket, True) is None + with caplog.at_level(logging.INFO, logger=espota2.__name__): + assert espota2._start_udp(mock_socket, mock_socket, "lossy") is None assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)] + assert "Link is lossy (lossy), trying UDP" in caplog.text + assert "Device declined UDP, using TCP" in caplog.text def test_start_udp_clean_link_stays_on_tcp(mock_socket: Mock) -> None: """A clean handshake asks to stay on TCP and opens no UDP socket.""" mock_socket.recv.side_effect = [b"\x00", b"\x00\x00\x00"] with patch.object(espota2.socket, "socket") as udp_socket: - assert espota2._start_udp(mock_socket, mock_socket, False) is None + assert espota2._start_udp(mock_socket, mock_socket, None) is None udp_socket.assert_not_called() assert mock_socket.sendall.call_args_list == [call(espota2.UDP_FALLBACK)] @pytest.mark.parametrize( - ("slowest", "prefer_udp", "retransmits", "lossy"), + ("slowest", "prefer_udp", "retransmits", "reason"), [ - (0.01, False, 0, False), - (0.01, False, None, False), - (espota2.UDP_SLOW_EXCHANGE + 0.1, False, 0, True), - (0.01, True, 0, True), - (0.01, False, 2, True), + (0.01, False, 0, None), + (0.01, False, None, None), + (1.5, False, 0, "a handshake reply took 1.5s"), + (0.01, True, 0, "an earlier attempt failed"), + (0.01, False, 2, "TCP resent data during the handshake"), ], ) -def test_link_lossy( - slowest: float, prefer_udp: bool, retransmits: int | None, lossy: bool +def test_loss_reason( + slowest: float, prefer_udp: bool, retransmits: int | None, reason: str | None ) -> None: - """A slow exchange, a retransmit or an earlier failure means UDP.""" + """A slow exchange, a retransmit or an earlier failure means UDP, with the reason.""" with patch.object(espota2, "_tcp_retransmits", return_value=retransmits): - assert espota2._link_lossy(Mock(), slowest, prefer_udp) is lossy + assert espota2._loss_reason(Mock(), slowest, prefer_udp) == reason @pytest.mark.parametrize( @@ -1825,7 +1830,7 @@ def test_perform_ota_over_udp(mock_socket: Mock) -> None: espota2.perform_ota( mock_socket, None, io.BytesIO(bytes(20000)), "test.bin", prefer_udp=True ) - assert start.call_args.args[2] is True # an earlier failure means lossy + assert start.call_args.args[2] == "an earlier attempt failed" assert udp.recv.call_count == 4 # no chunk acks udp.flush.assert_called_once() udp.close.assert_called_once() @@ -1887,7 +1892,7 @@ def test_start_udp_probe_succeeds(mock_socket: Mock) -> None: mock_socket.family = socket.AF_INET mock_socket.getpeername.return_value = device.sock.getsockname() mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x09"] - channel = espota2._start_udp(mock_socket, mock_socket, True) + channel = espota2._start_udp(mock_socket, mock_socket, "lossy") assert channel is not None channel.close() assert mock_socket.sendall.call_args_list == [call(espota2.UDP_REQUEST)] @@ -1971,7 +1976,7 @@ def test_start_udp_socket_error_falls_back(mock_socket: Mock) -> None: mock_socket.family = socket.AF_INET mock_socket.getpeername.side_effect = OSError("not connected") mock_socket.recv.side_effect = [b"\x05", b"\x06\x07\x08"] - assert espota2._start_udp(mock_socket, mock_socket, True) is None + assert espota2._start_udp(mock_socket, mock_socket, "lossy") is None assert mock_socket.sendall.call_args_list == [ call(espota2.UDP_REQUEST), call(espota2.UDP_FALLBACK), From 73ba726838e082c8b7e33e62384f52664abdaa2a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:09:01 -1000 Subject: [PATCH 156/179] [ci] Extend the hardware-test skill with the flows learned since it was added (#21011) --- .agents/skills/hardware-test/SKILL.md | 43 ++- .../skills/hardware-test/reference/boards.md | 56 ++- .../skills/hardware-test/reference/recipes.md | 117 ------ .../hardware-test/reference/recipes/README.md | 26 ++ .../hardware-test/reference/recipes/api.md | 7 + .../reference/recipes/captive_portal.md | 21 ++ .../reference/recipes/cse7766.md | 11 + .../hardware-test/reference/recipes/espnow.md | 7 + .../reference/recipes/hdmi_cec.md | 9 + .../hardware-test/reference/recipes/ld2450.md | 7 + .../reference/recipes/libretiny.md | 58 +++ .../hardware-test/reference/recipes/mqtt.md | 12 + .../hardware-test/reference/recipes/nrf52.md | 6 + .../reference/recipes/online_image.md | 20 + .../reference/recipes/openthread.md | 5 + .../hardware-test/reference/recipes/ota.md | 8 + .../reference/recipes/rtsp_server.md | 18 + .../reference/recipes/scheduler.md | 8 + .../hardware-test/reference/recipes/udp.md | 11 + .../reference/recipes/usb_cdc_acm.md | 20 + .../hardware-test/reference/recipes/wifi.md | 25 ++ .../hardware-test/reference/recipes/zephyr.md | 10 + .../reference/recipes/zephyr_coredump.md | 17 + .../reference/recipes/zwave_proxy.md | 23 ++ .../hardware-test/scripts/cse7766/observe.py | 71 ++++ .../hardware-test/scripts/cse7766/run_obs.sh | 27 ++ .../scripts/libretiny_crash/bkprobe.hpp | 116 ++++++ .../scripts/libretiny_crash/decode_capture.py | 25 ++ .../scripts/libretiny_prefs/ltprefs.hpp | 155 ++++++++ .../scripts/online_image/drive.py | 179 +++++++++ .../scripts/online_image/make_images.py | 38 ++ .../scripts/online_image/oi_server.py | 142 +++++++ .../scripts/rtsp_server/cam_api.py | 33 ++ .../scripts/rtsp_server/measure.sh | 32 ++ .../scripts/rtsp_server/rawrate2.py | 83 +++++ .../scripts/rtsp_server/rtsp_client.py | 345 ++++++++++++++++++ .../scripts/usb_cdc_acm/cdc_probe.hpp | 13 + .../scripts/usb_cdc_acm/drive.py | 203 +++++++++++ .../scripts/zephyr_coredump/decode.sh | 39 ++ .../scripts/zephyr_coredump/flow.sh | 29 ++ .../scripts/zwave_proxy/devinfo.py | 24 ++ .../scripts/zwave_proxy/verify_checksums.py | 45 +++ .../templates/bk72xx-crash-probe-dut.yaml | 44 +++ .../templates/bk72xx-rtc-prefs-dut.yaml | 38 ++ .../dut-ap-only-wipe-once-bk72xx.yaml | 34 ++ .../templates/nrf52-coredump-crash-dut.yaml | 50 +++ .../templates/nrf52-preferences-dut.yaml | 31 ++ .../templates/online-image-dut.yaml | 84 +++++ .../templates/rtsp-waveshare-s3-eth-dut.yaml | 105 ++++++ .../templates/s31-cse7766-dut.yaml | 107 ++++++ .../templates/usb-cdc-acm-dongle-dut.yaml | 112 ++++++ .../templates/waveshare-s3-eth-camdiag.yaml | 83 +++++ 52 files changed, 2703 insertions(+), 129 deletions(-) delete mode 100644 .agents/skills/hardware-test/reference/recipes.md create mode 100644 .agents/skills/hardware-test/reference/recipes/README.md create mode 100644 .agents/skills/hardware-test/reference/recipes/api.md create mode 100644 .agents/skills/hardware-test/reference/recipes/captive_portal.md create mode 100644 .agents/skills/hardware-test/reference/recipes/cse7766.md create mode 100644 .agents/skills/hardware-test/reference/recipes/espnow.md create mode 100644 .agents/skills/hardware-test/reference/recipes/hdmi_cec.md create mode 100644 .agents/skills/hardware-test/reference/recipes/ld2450.md create mode 100644 .agents/skills/hardware-test/reference/recipes/libretiny.md create mode 100644 .agents/skills/hardware-test/reference/recipes/mqtt.md create mode 100644 .agents/skills/hardware-test/reference/recipes/nrf52.md create mode 100644 .agents/skills/hardware-test/reference/recipes/online_image.md create mode 100644 .agents/skills/hardware-test/reference/recipes/openthread.md create mode 100644 .agents/skills/hardware-test/reference/recipes/ota.md create mode 100644 .agents/skills/hardware-test/reference/recipes/rtsp_server.md create mode 100644 .agents/skills/hardware-test/reference/recipes/scheduler.md create mode 100644 .agents/skills/hardware-test/reference/recipes/udp.md create mode 100644 .agents/skills/hardware-test/reference/recipes/usb_cdc_acm.md create mode 100644 .agents/skills/hardware-test/reference/recipes/wifi.md create mode 100644 .agents/skills/hardware-test/reference/recipes/zephyr.md create mode 100644 .agents/skills/hardware-test/reference/recipes/zephyr_coredump.md create mode 100644 .agents/skills/hardware-test/reference/recipes/zwave_proxy.md create mode 100644 .agents/skills/hardware-test/scripts/cse7766/observe.py create mode 100755 .agents/skills/hardware-test/scripts/cse7766/run_obs.sh create mode 100644 .agents/skills/hardware-test/scripts/libretiny_crash/bkprobe.hpp create mode 100644 .agents/skills/hardware-test/scripts/libretiny_crash/decode_capture.py create mode 100644 .agents/skills/hardware-test/scripts/libretiny_prefs/ltprefs.hpp create mode 100644 .agents/skills/hardware-test/scripts/online_image/drive.py create mode 100644 .agents/skills/hardware-test/scripts/online_image/make_images.py create mode 100644 .agents/skills/hardware-test/scripts/online_image/oi_server.py create mode 100644 .agents/skills/hardware-test/scripts/rtsp_server/cam_api.py create mode 100755 .agents/skills/hardware-test/scripts/rtsp_server/measure.sh create mode 100644 .agents/skills/hardware-test/scripts/rtsp_server/rawrate2.py create mode 100644 .agents/skills/hardware-test/scripts/rtsp_server/rtsp_client.py create mode 100644 .agents/skills/hardware-test/scripts/usb_cdc_acm/cdc_probe.hpp create mode 100644 .agents/skills/hardware-test/scripts/usb_cdc_acm/drive.py create mode 100755 .agents/skills/hardware-test/scripts/zephyr_coredump/decode.sh create mode 100755 .agents/skills/hardware-test/scripts/zephyr_coredump/flow.sh create mode 100644 .agents/skills/hardware-test/scripts/zwave_proxy/devinfo.py create mode 100644 .agents/skills/hardware-test/scripts/zwave_proxy/verify_checksums.py create mode 100644 .agents/skills/hardware-test/templates/bk72xx-crash-probe-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/bk72xx-rtc-prefs-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/dut-ap-only-wipe-once-bk72xx.yaml create mode 100644 .agents/skills/hardware-test/templates/nrf52-coredump-crash-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/nrf52-preferences-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/online-image-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/rtsp-waveshare-s3-eth-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/s31-cse7766-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/usb-cdc-acm-dongle-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/waveshare-s3-eth-camdiag.yaml diff --git a/.agents/skills/hardware-test/SKILL.md b/.agents/skills/hardware-test/SKILL.md index 8ad7cd5d847..d5e994a82f0 100644 --- a/.agents/skills/hardware-test/SKILL.md +++ b/.agents/skills/hardware-test/SKILL.md @@ -15,19 +15,26 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul 1. **Serial ports cannot be shared.** One bounded capture per port, stopped before any flash on that port, never an open-ended `esphome logs`. Stop a capture with `pkill -f "[s]erial_capture.py "`, naming the port so a capture on another board survives; the bracket keeps the pattern from matching - the shell that runs it. Never run `esphome logs` in a - `while true` loop: the loop respawns it and holds the port. + the shell that runs it. When more than one agent session shares the bench, run a copy of the script + under a session-specific name (`cap.py`) so another session's `pkill` cannot take your capture + down. Never run `esphome logs` in a `while true` loop: the loop respawns it and holds the port. 2. **Credentials stay local.** Put a `secrets.yaml` next to the scratch configs and reference it with - `!secret`. Never paste a config with credentials into a PR, never commit scratch configs or logs. + `!secret`. A copy of a device's own config keeps using the secrets file next to that config: two + checkouts can hold different networks, and the wrong one leaves the device unreachable after the OTA. Never paste a config with credentials into a PR, never commit scratch configs or logs. 3. **Build from the tree under test.** `python -m esphome` puts the current directory first on `sys.path`, so run it with the checkout or worktree as the working directory and pass the yaml by its absolute path. A venv `esphome` entry point may import an installed snapshot instead of the checkout. Confirm with `python -c 'import esphome; print(esphome.__file__)'`, and for an A/B grep - the build's `src/` for the change before trusting any number. + the build's `src/` for the change before trusting any number. Tool calls sent together share one + shell, so a `cd` in one can land another's compile in the wrong tree: pass `PYTHONPATH=` on + every compile and upload command and print `esphome.__file__` into the compile log. 4. **PR build and baseline build get different `name:` values.** Builds share `.esphome/build/`, so the same name means `esphome upload` can flash the wrong binary. Saved - Wi-Fi credentials are keyed by the name too, so a test that must start unprovisioned gets a fresh - name every run. + Wi-Fi credentials are keyed by the config hash when the config has `networks:`, so a test that must + start unprovisioned gets a fresh name every run. A config with `ap:` only and no `networks:` uses one + fixed key shared by every such config on the board, so a new name does not help there: wipe once at + boot with an `on_boot` lambda that resets the preferences and saves a marker key + (`templates/dut-ap-only-wipe-once-bk72xx.yaml`). 5. **Flash and RAM deltas are measured against the PR's merge base** (`git merge-base origin/dev HEAD` checked out in a throwaway worktree), with the baseline commit named in the table. When a delta looks odd, diff symbol sizes (`nm -S -C --size-sort` from the matching toolchain) before quoting it. @@ -73,7 +80,7 @@ driven, what the logs show, and the same flow on a `dev` build when a quirk coul | ESP32-S3, C3, C6, H2 native USB | `esphome upload --device /dev/cu.usbmodem*` | `logger: hardware_uart: USB_SERIAL_JTAG`. The USB console can wedge when the app takes over the console (0 bytes, esptool `No serial data received`); replug, or watch through `esphome logs --device .local` | | Pico W / Pico 2 W | `esphome upload --device ` (1200 baud touch); first flash ever via BOOTSEL and `--device /Volumes/RPI-RP2` or `RP2350` | port re-enumerates after upload: `ports.py --wait 2e8a` then capture | | BW15 kit (RTL8720CF, rtl87xx) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_rtl87xx_kit.py flash write -d /.pioenvs//*.uf2`. Plain `esphome upload` and plain ltchiptool time out because opening the port asserts DTR/RTS and holds the chip in reset | `serial_capture.py --released`, never `esphome logs` | -| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d ` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. Opening the port resets the chip, so keep one capture open for the whole test | +| BK7238 / CB3S (bk72xx, RTS on CEN) | `~/.platformio/penv/.libretiny/bin/python $SKILL/scripts/flash_bk72xx.py flash write -d ` (about 75 s) | logs are on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. `serial_capture.py --reset` gives a chip-enable reset; a plain open may or may not reset it, check for the bootloader banner | | LN882H | ltchiptool forces 1 Mbaud for writes; weak adapters need `LN882H_YM_BAUDRATE` patched to 115200 in the penv's `ln882htool.py`; short BOOT to GND while the tool connects | OTA after the first flash | | nRF52840 Dongle (PCA10059) | Nordic `nrfutil nrf5sdk-tools dfu usb-serial` on the bootloader port, or `dfu: true` in the config so the 1200 baud touch pin-resets into the bootloader; adafruit-nrfutil is refused | app has two CDC ports, ESPHome log on the second | | Adafruit-style nRF52 (UF2 bootloader) | `UF2BOOT` volume or adafruit-nrfutil; `dfu: true` works through GPREGRET | single CDC port | @@ -91,7 +98,8 @@ ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA deadline. OUT is truncated unless `--append` is given, so counts over it cover one run. - `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not. -- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. +- `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. It misses the link now + and then; retry up to three times. - `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`: the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0), @@ -100,6 +108,13 @@ ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA (exit 2: a precondition was missed, the stimulus was insufficient, or a capture did not last the run) or exits 1 when a build, flash or port step failed. +Per-topic scripts, each described in the matching `reference/recipes/.md`: `online_image/` +(server, image generator, API driver), `zwave_proxy/` (checksum verifier, device info), `usb_cdc_acm/` +(host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set, +API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the +Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and +capture decoder). + ## Templates in `$SKILL/templates` - `dut-fallback-ap.yaml` (package): a device that never finds its network, falls back to its AP and @@ -107,17 +122,25 @@ ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA - `client-wifisave.yaml` (package): joins that AP, posts N bogus `/wifisave` requests, then the real credentials from `!secret`. `example-client.yaml` is the per-run file. `bogus_saves: "1"` gives a plain provisioning check. +- Per-topic device configs, each described in `reference/recipes/.md`: `online-image-dut.yaml`, + `usb-cdc-acm-dongle-dut.yaml`, `nrf52-preferences-dut.yaml`, `rtsp-waveshare-s3-eth-dut.yaml`, + `waveshare-s3-eth-camdiag.yaml`, `s31-cse7766-dut.yaml`, `nrf52-coredump-crash-dut.yaml`, + `bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`. ## When something looks wrong - A capture stopped early or a line is missing: check the timestamps before trusting it. - The client never joins the AP: another board may still run an AP with the same SSID, or the device booted with saved credentials because the name was reused. One AP device at a time on the bench. -- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). +- Builds come out byte-identical between branches: the wrong tree was imported (rule 3). The reverse + too: two binaries that differ only in the build timestamp and in `__LINE__` values of log calls below + the changed code are the same build; map the offsets with `objdump -h` and `addr2line` first. +- A boot the host cannot capture (a power cut, an OTA-only board): keep the boot report in a flash + preference and repeat it in a periodic alive line, as `scripts/libretiny_prefs/ltprefs.hpp` does. - Multicast from the host never arrives on some access points: test it device to device. Broadcast is lossy under Wi-Fi power save, unicast is not. Throttle UDP floods to a fixed packet rate. - `api:` reboots a board after 15 min without a client: `reboot_timeout: 0s` on `api:` and `mqtt:` for soak tests. -`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes.md` has the proven +`reference/boards.md` has per-chip facts and crash decoding; `reference/recipes/` has the proven flows. Add new findings there. diff --git a/.agents/skills/hardware-test/reference/boards.md b/.agents/skills/hardware-test/reference/boards.md index fd2cd448785..805643b59f1 100644 --- a/.agents/skills/hardware-test/reference/boards.md +++ b/.agents/skills/hardware-test/reference/boards.md @@ -88,8 +88,28 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever pulses the reset right before the link checks. A write takes about 75 s. Only a CEN or power reset reaches the downloader; no software reboot does. - ESPHome logs on UART2 by default; `logger: hardware_uart: UART1` puts them on the flashing adapter. -- Opening the port resets the chip (bootloader banner on every open, even with DTR/RTS low), so keep one - capture open for the whole test and never reopen mid-phase. +- Opening the port may reset the chip (bootloader banner on open) or may not, depending on the adapter and + what pyserial does with DTR and RTS; `serial_capture.py --reset` pulses RTS on CEN and always does, and + `lt_get_reboot_reason()` reports that as Power-On. Check for the banner at the start of a capture before + assuming either. +- `flash_bk72xx.py` misses the link now and then (`Timed out attempting to link with chip`); retry up to 3 + times before suspecting the board. +- Crashes: `__builtin_trap()` and a call into unmapped memory both end as an undefined-instruction trap + (`bk_misc_get_start_type()` 6, `lt_get_reboot_reason()` Crash); reads, unaligned loads and stores to + unmapped addresses never fault on the ARM968. Without Wi-Fi the SDK trap handler spins with interrupts off + for 10 s and the hardware watchdog resets the chip. With Wi-Fi connected a dev build hangs forever after a + crash, and never resets from a plain `portDISABLE_INTERRUPTS(); while (1) {}` either: the boot-time + watchdog is dead once Wi-Fi is up, for any lockup. The crash handler fix (#21012) re-arms the watchdog + from its wrapper, after which the reboot comes within a second (BK7238 and BK7231N). +- The SDK prints nothing on UART1 under `sdk_silent: all`; the bootloader then prints the banked + `cpsr/spsr/r13/r14` of SVC, IRQ, FIQ and SYS plus `ST:` (the retention register) before `go os_addr`. + A CEN or power reset wipes ITCM (reads `0xaaaaaaaa`), so only a watchdog reset keeps a record there. What + survives that reboot on the BK7238: ITCM (`0x3F0000..0x3F4000`) and the top 96 KB of SRAM + (`0x428000..0x440000`, the heap); the bootloader zeroes SRAM up to about `0x420000` and rewrites + `0x400024..0x4000FF` with its own register dump every boot, so neither a `.noinit` after `.bss` nor the + SDK's saved register banks are usable. Code is compiled Thumb, the SDK assembly is ARM; in an exception + handler only r0 to r3 are shared by all modes. BK7231N ITCM is `0x3FEE00..0x400000` (4.5 KB); a `.noinit` + placed there after `.itcm.code` works for a crash record. - Callbacks run on the `async_tcp` task and log lines can lose a byte. Wi-Fi connect takes tens of ms; radio enable 160 to 278 ms, disable about 200 ms. @@ -123,6 +143,38 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever - Makerdiary MDK USB dongle: UF2 bootloader without SoftDevice, app at 0x1000, `UF2BOOT` volume, adafruit-nrfutil, GPREGRET 0x57 works. RGB LED active low (red P0.23, green P0.22, blue P0.24), button P0.18. +- Adafruit ItsyBitsy nRF52840 Express (UF2 bootloader with S140, bootloader port `239a:0051` plus an + `ITSY840BOOT` volume): the board DTS already has `cdc_acm_uart0`, so the logger port is the first CDC + port and a `usb_cdc_acm` interface 1 the second. A `dfu: true` build enters the bootloader on the + 1200 bps touch (an app without it needs a double tap on RESET); `adafruit-nrfutil` over the bootloader + serial port can fail, copying `.pioenvs//zephyr/zephyr.uf2` to the volume works (the copy reports + an I/O error as the board reboots, which is normal). Reboot a running `dfu: true` app with a 2001 bps touch. +- The PCA10059 board DTS already defines a `cdc_acm_uart` node, so every ESPHome CDC port is one USB port + later than its number (log on `cdc_acm_uart0` is the second port). A third CDC instance needs + `CONFIG_USB_MAX_NUM_TRANSFERS` above 4 or it never moves data (see `recipes/usb_cdc_acm.md`). +- USB CDC boot output: the Zephyr legacy stack's `cdc_acm` TX ring is 1 KB and `uart_poll_out` drops when it + is full, so anything printed in one burst beyond about 1 KB (a coredump, a long table) is lost even with + the port open, and the logger warns `logger took a long time`. Print one line per `loop()` instead. +- Zephyr settings survive a reflash: key a `restore_value` global by a `run` substitution when a test needs + a never-saved value. + +## Waveshare ESP32-S3-ETH with the OV5640 camera module + +- Native USB console (`303a:1001`), ESP32-S3R8, 16 MB flash, 8 MB octal PSRAM, W5500 on SPI (MOSI 11, + MISO 12, SCLK 13, CS 14, INT 10, RST 9), TF card on 4..7, WS2812 on 21. +- Camera header (vendor schematic): XCLK 3, SIOD 48, SIOC 47, VSYNC 1, HREF 2, PCLK 39, D0..D7 = 41, 45, + 46, 42, 40, 38, 15, 18. PWDN is tied to GND and RESET is an RC to 3.3 V on the board, so no reset or + power-down GPIO exists. +- GPIO8 switches the camera 3.3 V rail through a P-channel MOSFET: LOW = camera powered, gate pulled up so + the camera is off by default. `power_down_pin: GPIO8` only pulls it low 10 ms before the probe, which is + not enough; use `switch: gpio GPIO8 restore_mode: ALWAYS_OFF setup_priority: 800` plus `on_boot: + priority: 700` with `delay(1200)` and `esp32_camera: setup_priority: 200` + (`templates/rtsp-waveshare-s3-eth-dut.yaml`). `esphome config` rejects a config that uses GPIO8 for both the switch + and `power_down_pin` (pin reuse), so it is one or the other. +- The sensor at SCCB 0x3C answers only with XCLK running, so the ESPHome i2c scan at boot (before XCLK) + reporting no devices on 48/47 is expected. If the SCCB ID register (0x3100 = 0x78) reads fine but every + 0x30xx register reads 0x00 (`Detected camera not supported`, 0x106), reseat the ribbon before blaming + software; `templates/waveshare-s3-eth-camdiag.yaml` runs that probe with LEDC XCLK and raw SCCB reads. ## ESP Thread Border Router board v1.2 (S3 host + H2 RCP) diff --git a/.agents/skills/hardware-test/reference/recipes.md b/.agents/skills/hardware-test/reference/recipes.md deleted file mode 100644 index 32944ce75c4..00000000000 --- a/.agents/skills/hardware-test/reference/recipes.md +++ /dev/null @@ -1,117 +0,0 @@ -# Proven test flows - -Each flow names the pull requests it was used on, so their result tables show what to post. - -## Captive portal `/wifisave` hammer (#20802, #20784) - -Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board. -Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`, -`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the -device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown -is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on -ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md). - -Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash -markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`. -A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters -the adapter restart), no panic, and the real credentials connect the device within about 10 s. - -Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in -`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag -(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive -portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection -refused proves the portal's server is gone. - -## `reboot_timeout` with a fallback AP (#9958) - -Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network, -`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and -ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes: -`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles -are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot -with no station joined, no reboot for minutes while one is, then a reboot within a second of the last -disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way. - -## MQTT (#20433, #17935, #20762, #20767, #20768) - -A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives -each subscription's QoS, the device address (`New client connected from as -`) and exact -subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription. -Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right -after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a -leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish. -`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a -paho script publishes and polls the serial capture file. - -## UDP, multicast, sockets (#20556 to #20559, #20585, #20590) - -A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device -multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock -listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate -(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi -reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect` -(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action -rejects `%.*s`; use a lambda. - -## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037) - -All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release -after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A -refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot. -`pkill -f "a\|b"` does not alternate; use two pkills. - -## DHCP lease cache (#17767) - -Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the -`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep -sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use -`upload` then `logs`. - -## HDMI-CEC two-board wire (#9189) - -Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:` -filter on the reply handler, template buttons for each target plus lambdas calling -`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms, -round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep -lags the press). - -## LD2450 polygon zones (#20154) - -To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and -switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after. -Writing those numbers blocks the ld2450 loop about 50 ms. - -## Scheduler and loop wake A/B (#20356) - -An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266; -the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout -logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus -`button_command`). Build the same yaml twice with different names, one per tree. - -## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233) - -Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over -two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with -`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml. - -## ESP-NOW on the ESP8266 (#14407) - -The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is -up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only -mode the device transmits from the soft-AP MAC. - -## Wi-Fi Easy Connect (DPP) enrollee (#17934) - -Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's -QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG. -`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the -supplicant state machine. - -## Thread border router (#18104) - -See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR. - -## nRF52840 dongle boards (#19009, #20814) - -Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a -10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first. diff --git a/.agents/skills/hardware-test/reference/recipes/README.md b/.agents/skills/hardware-test/reference/recipes/README.md new file mode 100644 index 00000000000..7c7871f45ce --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/README.md @@ -0,0 +1,26 @@ +# Proven test flows + +Each flow names the pull requests it was used on, so their result tables show what to post. + +One file per component, named after it: + +- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266 +- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer +- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API +- [`espnow.md`](espnow.md): ESP8266 broadcast receive +- [`hdmi_cec.md`](hdmi_cec.md): two-board wire +- [`ld2450.md`](ld2450.md): polygon zones +- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot +- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT +- [`nrf52.md`](nrf52.md): dongle board support +- [`online_image.md`](online_image.md): stalled and malformed downloads +- [`openthread.md`](openthread.md): border router on the S3 host plus H2 RCP +- [`ota.md`](ota.md): overlap and lock, timeouts, deflate +- [`rtsp_server.md`](rtsp_server.md): camera streaming on the Waveshare ESP32-S3-ETH +- [`scheduler.md`](scheduler.md): loop wake A/B +- [`udp.md`](udp.md): datagrams, multicast, sockets +- [`usb_cdc_acm.md`](usb_cdc_acm.md): nRF52840 dongle host driver +- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP) +- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle +- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards +- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy diff --git a/.agents/skills/hardware-test/reference/recipes/api.md b/.agents/skills/hardware-test/reference/recipes/api.md new file mode 100644 index 00000000000..fa6733a72c6 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/api.md @@ -0,0 +1,7 @@ +# api + +## Noise handshake and OTA encryption on the ESP8266 (#19062, #19233) + +Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over +two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with +`espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml. diff --git a/.agents/skills/hardware-test/reference/recipes/captive_portal.md b/.agents/skills/hardware-test/reference/recipes/captive_portal.md new file mode 100644 index 00000000000..6b9710e531b --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/captive_portal.md @@ -0,0 +1,21 @@ +# captive_portal + +## Captive portal `/wifisave` hammer (#20802, #20784) + +Reproduces a `/wifisave` arriving during the STA cooldown, and proves a fix on every board. +Device under test: `templates/example-dut.yaml` (bogus network, `ap:` with `ap_timeout: 15s`, +`captive_portal:`). Client: `templates/example-client.yaml` on an ESP32 or a D1 mini; it joins the +device's AP, posts 15 bogus saves 3 s apart, then the real credentials. With the portal up the cooldown +is 30 s, so nearly every save lands inside one. Driver: `scripts/wifisave_hammer.sh`. Settle 40 s on +ESP boards, 90 s on the BW15 (its first attempt on the real network fails, see boards.md). + +Counts that matter: `Requested WiFi Settings Change` (saves received), `Exiting cooldown early`, crash +markers, boots (`Log initialized`), and the client's `bogus save status 200` and `real save status 200`. +A healthy run: every save answered 200, one cooldown exit per save (two on the BW15, whose retry enters +the adapter restart), no panic, and the real credentials connect the device within about 10 s. + +Fault injection for AP start failures: wrap `this->ap_setup_ = this->wifi_start_ap_(this->ap_)` in +`setup_ap_config_()` with an `HWTEST_AP_FAIL_COUNT` build flag +(`esphome: platformio_options: build_flags: -DHWTEST_AP_FAIL_COUNT=2`), raise the portal's "Captive +portal started" from LOGV to LOGI, and after provisioning curl the device's STA address: connection +refused proves the portal's server is gone. diff --git a/.agents/skills/hardware-test/reference/recipes/cse7766.md b/.agents/skills/hardware-test/reference/recipes/cse7766.md new file mode 100644 index 00000000000..369b3636941 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/cse7766.md @@ -0,0 +1,11 @@ +# cse7766 + +## cse7766 on a Sonoff S31 (#20948) + +`templates/s31-cse7766-dut.yaml`: `logger: baud_rate: 0` because the CSE7766 owns the UART, VERBOSE logger +with everything but cse7766 quieted, 2 s throttles, and a `globals` frame counter fed by the voltage sensor's +`on_raw_value` and logged every 10 s (about 96 frames per 10 s). OTA only, every reflash reboots the relay. +`scripts/cse7766/run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER]` subscribes to states and logs over the +API with a concurrent ping and prints the frame counter, API gaps and warnings. API state gaps of 10 to 20 s +can appear on a weak Wi-Fi path while the device counter stays flat, so judge a UART parser by the +device-side counter, not by the API cadence. With nothing plugged in, current and power read 0. diff --git a/.agents/skills/hardware-test/reference/recipes/espnow.md b/.agents/skills/hardware-test/reference/recipes/espnow.md new file mode 100644 index 00000000000..3fee12981f9 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/espnow.md @@ -0,0 +1,7 @@ +# espnow + +## ESP-NOW on the ESP8266 (#14407) + +The NONOS SDK only delivers broadcast ESP-NOW frames while the station is associated or a soft-AP is +up; a hidden, open, client-less soft-AP (60 s beacon, DHCP stopped) makes broadcasts arrive. In AP-only +mode the device transmits from the soft-AP MAC. diff --git a/.agents/skills/hardware-test/reference/recipes/hdmi_cec.md b/.agents/skills/hardware-test/reference/recipes/hdmi_cec.md new file mode 100644 index 00000000000..6582626167e --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/hdmi_cec.md @@ -0,0 +1,9 @@ +# hdmi_cec + +## HDMI-CEC two-board wire (#9189) + +Two boards on one wire: promiscuous mode, a 0x8F request answered with 0x90 0x00, a `destination:` +filter on the reply handler, template buttons for each target plus lambdas calling +`send(source, dest, span)` as another initiator. Healthy numbers: device-side turnaround 15 to 22 ms, +round trip 144 to 156 ms. Press buttons over the API; judge timings from device logs (ESP32 light sleep +lags the press). diff --git a/.agents/skills/hardware-test/reference/recipes/ld2450.md b/.agents/skills/hardware-test/reference/recipes/ld2450.md new file mode 100644 index 00000000000..9d1a8d8f32b --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/ld2450.md @@ -0,0 +1,7 @@ +# ld2450 + +## LD2450 polygon zones (#20154) + +To make a target "leave" without anyone moving, set the radar's Zone-1 numbers over the target and +switch the zone type to `Filter` (the count drops to 0 within 0.5 s); set it back to `Disabled` after. +Writing those numbers blocks the ld2450 loop about 50 ms. diff --git a/.agents/skills/hardware-test/reference/recipes/libretiny.md b/.agents/skills/hardware-test/reference/recipes/libretiny.md new file mode 100644 index 00000000000..fb784fbc620 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/libretiny.md @@ -0,0 +1,58 @@ +# libretiny + +## LibreTiny rtc preference emulation (#21007) + +The PR keeps `storage: rtc` preferences in FlashDB and deletes them on the first load or save after a boot +whose `lt_get_reboot_reason()` is not software, watchdog, crash or sleep. Only the families listed in +`_libretiny_rtc_supported()` accept the config keys; on others force the backend in with +`platformio_options: build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"]` after confirming the rejection. + +- Harness: `scripts/libretiny_prefs/ltprefs.hpp` plus `templates/bk72xx-rtc-prefs-dut.yaml`. `ltprefs_boot()` + loads one rtc and one flash preference, a second pair that shares one key as rtc and as flash, and a third + rtc entry (so a hard reset drops three entries in one pass), logs the reason and both results, saves `100 + step`, syncs; after + a templated delay `ltprefs_exit()` bumps the flash-backed step and runs the step's action (`lt_reboot()`, + `__builtin_trap()`, `lt_reboot_wdt()` or idle). The last 8 boot reports are kept in a flash preference and + repeated in the 10 s alive line, so a boot missed by the capture (a power cut, an OTA-only board) is still + readable. Bump `run:` for fresh keys. +- A store to an unmapped address does not fault on the ARM968, the board carries on: use `__builtin_trap()` + for the crash row. The SDK crash handler reboots about 10 s later. Works on BK7238 and BK7231N. +- Hard resets: `serial_capture.py ... --reset` pulses RTS on CEN (reported as Power-On). A real power cut + needs a hand or a smart plug; the history in the alive line covers the boot the host cannot see. On the + Beken SDK a chip-enable reset and a power cut both report Power-On, there is no separate reset-pin source. +- Step 0 and step 8 wait 30 s before acting so the host can open the port (and pulse a reset) after a flash + without losing the first boot; the flash tool's own reset starts the sequence immediately otherwise. +- OTA-only board: keep the device's wifi, api and ota blocks verbatim, keep safe_mode, read logs with + `esphome logs --device .local` (the API attaches late, hence the history), and restore the + device's own config afterwards. +- Plain-build comparison: `raw_firmware.bin` differs by the timestamp and by `__LINE__` in log calls of + functions below the added code; map offsets through `objdump -h` load addresses and `addr2line` before + calling a byte difference a code change. + +## BK72xx crash report on the next boot (#21012) + +Harness: `scripts/libretiny_crash/bkprobe.hpp` plus `templates/bk72xx-crash-probe-dut.yaml`. A flash +preference holds the step; `on_boot` (priority -100) logs the reset reason, the SDK start type, two canaries +and hex dumps of the low SRAM, a set of marker words and the record area after `.bss`, waits 30 s on step 0 +(so a capture can be opened after the flash) or 5 s, saves `step + 1`, syncs, then acts: 0 `__builtin_trap()`, +1 call into unmapped memory, 2 unmapped and unaligned reads and a store (no fault on the ARM968), 3 +`lt_reboot()`. A `globals: restore_value` step counter does not work here: it is only saved from the global's +loop, which a crash 50 ms later never reaches; save an `ESPPreferenceObject` and `global_preferences->sync()` +first. Bump `run:` for fresh keys. Keep the `wifi:` block: the bug the fix addresses only shows with Wi-Fi up. + +Flow: `flash_bk72xx.py` (retry up to three times), one `serial_capture.py ... --reset` capture of 150 to 230 s, +then `grep -a "libretiny.crash\|probe"`. Decode with +`arm-none-eabi-addr2line -pfiaC -e .pioenvs//raw_firmware.elf ` (the ELF is `raw_firmware.elf` +on LibreTiny), or run `scripts/libretiny_crash/decode_capture.py ` from the config directory +to feed the capture through the CLI's `LogLineProcessor`. + +Expected: `*** CRASH DETECTED ON PREVIOUS BOOT ***`, `Reason: Undefined instruction`, PC on the `udf`, +LR = PC + 1, BT0..BT7 walking the lambda, `StatelessLambdaAction`, `Action::play_complex`, `DelayAction`, +`Scheduler::call`, `Application::loop`. The wild call reports where the core ended up (it executes zeros as +Thumb NOPs until it wraps into real code), not the called address. Without the fix a dev build with Wi-Fi +connected never comes back from the crash; see the BK72xx section in `boards.md`. + +OTA-only board: copy the device's own config, add the probe include and the step machine (step 0 crashes +45 s after boot, later steps idle), keep its wifi, api, ota and safe_mode blocks and use the secrets file +next to that config. A LibreTiny OTA is a 1.5 MB UF2 and takes minutes on a slow link, so do not wrap the +upload in a short timeout. Read the replayed report with `esphome logs ... --device .local` (an API +client gets it on subscribe) or on the board's serial log port if it has one. diff --git a/.agents/skills/hardware-test/reference/recipes/mqtt.md b/.agents/skills/hardware-test/reference/recipes/mqtt.md new file mode 100644 index 00000000000..6298f769aa9 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/mqtt.md @@ -0,0 +1,12 @@ +# mqtt + +## MQTT (#20433, #17935, #20762, #20767, #20768) + +A local mosquitto with `allow_anonymous true`, `log_type all` and `log_dest file`: the broker log gives +each subscription's QoS, the device address (`New client connected from as -`) and exact +subscribe counts. Kill and restart mosquitto to exercise `on_disconnect` (reason 0) and resubscription. +Keep `reboot_timeout: 0s` on `api:` and `mqtt:`. The ESP-IDF outbox swings free heap about 2 kB right +after bulk subscribe and unsubscribe; read the heap seconds later over several rounds before calling a +leak. Spawned subscriptions need about 2.5 s to reach the broker before the next publish. +`esphome logs --device MQTT` only uses the MQTT transport when the config has no `api:`. Driver pattern: a +paho script publishes and polls the serial capture file. diff --git a/.agents/skills/hardware-test/reference/recipes/nrf52.md b/.agents/skills/hardware-test/reference/recipes/nrf52.md new file mode 100644 index 00000000000..a9ae66e1b15 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/nrf52.md @@ -0,0 +1,6 @@ +# nrf52 + +## nRF52840 dongle boards (#19009, #20814) + +Test config: the two LEDs as inverted GPIO outputs, the button as an inverted pull-up binary sensor, a +10 s alive interval. To retest the nrfutil download path, move the cached `sdk-nrf/nrfutil` aside first. diff --git a/.agents/skills/hardware-test/reference/recipes/online_image.md b/.agents/skills/hardware-test/reference/recipes/online_image.md new file mode 100644 index 00000000000..da81c1eb969 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/online_image.md @@ -0,0 +1,20 @@ +# online_image + +## online_image stalled downloads (#20594) + +One classic ESP32, both frameworks (the ESP32 Arduino build also uses `http_request_idf.cpp`). The host +serves the images: `scripts/online_image/make_images.py ` writes 16x16 BMP/PNG/JPEG/QOI plus an 8 bit +palette BMP with a 1078 byte header; `scripts/online_image/oi_server.py 8765 ` answers one +misbehaviour per path name (`ok`, `truncated`, `large_header`, `empty`, `stall` holds the socket 40 s, +`closed_early`, `slow` pauses 2.5 s, `chunked`, `chunked_cut`, `noclen`, `huge` claims 20 MB) with an ETag +and a 304 on If-None-Match. `templates/online-image-dut.yaml` has two AUTO-format images (default and +256 byte buffer), API actions `fetch`/`fetch_small` (set_url with `update: false`, then component.update) +and `refetch`/`refetch_small` (component.update only, keeps the cache headers), and a restart button. +`scripts/online_image/drive.py http://:8765 pr|base` runs the +case list over aioesphomeapi and judges each from the serial capture. ESP-IDF facts: a stalled server +ends with `Error downloading image: -28679` (EAGAIN) after the transport timeout, an early close with +`-1`, both before and after the PR; a body without Content-Length and without chunking reads as complete +(Size: 0) and finishes. A build without the fix hangs on the truncated, oversized header, empty, chunked PNG +and cut chunked cases and every later update logs `Image already being updated.`, so a baseline run needs +a reboot per group, the driver's 45 s cap per case, and an outer `timeout` that covers the whole list. +A case expected as `any` passes on `finished` or `error`, never on no outcome. diff --git a/.agents/skills/hardware-test/reference/recipes/openthread.md b/.agents/skills/hardware-test/reference/recipes/openthread.md new file mode 100644 index 00000000000..e126eb6e235 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/openthread.md @@ -0,0 +1,5 @@ +# openthread + +## Thread border router (#18104) + +See boards.md for the ESP Thread BR board. Check that the H2 answers Spinel before blaming a PR. diff --git a/.agents/skills/hardware-test/reference/recipes/ota.md b/.agents/skills/hardware-test/reference/recipes/ota.md new file mode 100644 index 00000000000..1c9d6642bb0 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/ota.md @@ -0,0 +1,8 @@ +# ota + +## OTA overlap and lock (#20588), OTA timeouts (#19041), deflate (#19037) + +All overlap orders (a web upload during a native OTA and the reverse) must be refused; the lock release +after an interrupted web upload waits about 14 s (ESP8266) or 7 s (ESP-IDF) for disconnect detection. A +refused web client on `web_server_idf` never gets the busy reply; the connection drops at reboot. +`pkill -f "a\|b"` does not alternate; use two pkills. diff --git a/.agents/skills/hardware-test/reference/recipes/rtsp_server.md b/.agents/skills/hardware-test/reference/recipes/rtsp_server.md new file mode 100644 index 00000000000..10438ccd0f4 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/rtsp_server.md @@ -0,0 +1,18 @@ +# rtsp_server + +## RTSP camera streaming on the Waveshare ESP32-S3-ETH (#17990) + +`templates/rtsp-waveshare-s3-eth-dut.yaml` (OV5640 pins, GPIO8 camera power switch with the 1.2 s boot delay, +`rtsp_server`, logger VERBOSE for `rtsp_server`); swap the `wifi:` block for the W5500 `ethernet:` block when +the cable is in, and add `network: enable_high_performance: true` for the big TCP buffers. Flash over native +USB, capture the log with `serial_capture.py`, take the IP from the last `IP Address` line that is not +0.0.0.0 (or `dns-sd -Q .local`). `scripts/rtsp_server/rtsp_client.py IP [cases]` covers play, udp, +pipeline, badreq, replay, getparam, teardown, third, stall, timeout; `rawrate2.py IP quiet|chatty|rtcp|bigbuf` +measures the raw receive rate; `measure.sh IP LABEL LOGFILE [OUTDIR]` runs the fixed set (ping, ffmpeg 12 s +twice, two ffmpeg clients, raw reader) and appends to `results.txt`; `cam_api.py IP` pulls one image over the +API; `ffmpeg -rtsp_transport tcp -i rtsp://IP:554/ -frames:v 30 -f image2 f%03d.jpg` plus Pillow checks +decoding. The 5760 byte default TCP send buffer makes the stream stall over Wi-Fi (about 1 fps, dozens of +stall drops per run); `enable_high_performance` gives several fps on Wi-Fi and removes the stalls on Ethernet. +Frame counts vary a lot between runs (auto exposure changes the JPEG size), so always measure twice. Home +Assistant's stream pipeline does not accept MJPEG over RTSP; its ffmpeg camera opens one RTSP session per +snapshot, so `max_connections` needs headroom, and go2rtc transcoding to H.264 is the way to a live view. diff --git a/.agents/skills/hardware-test/reference/recipes/scheduler.md b/.agents/skills/hardware-test/reference/recipes/scheduler.md new file mode 100644 index 00000000000..d3210f105be --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/scheduler.md @@ -0,0 +1,8 @@ +# scheduler + +## Scheduler and loop wake A/B (#20356) + +An external component whose template button arms an SDK `os_timer` (50 ms, one shot) on the ESP8266; +the callback runs in SYS context, records `millis()` and calls `set_timeout` or `defer`; the timeout +logs the elapsed time and loop count delta. Drive with an aioesphomeapi script (`subscribe_logs` plus +`button_command`). Build the same yaml twice with different names, one per tree. diff --git a/.agents/skills/hardware-test/reference/recipes/udp.md b/.agents/skills/hardware-test/reference/recipes/udp.md new file mode 100644 index 00000000000..b029e42ff27 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/udp.md @@ -0,0 +1,11 @@ +# udp + +## UDP, multicast, sockets (#20556 to #20559, #20585, #20590) + +A listener on port 9 needs no privileges on macOS. Test multicast device to device; host to device +multicast never arrives on some access points, and `239.255.x.x` groups are unreliable even to a stock +listener. Broadcast to boards is lossy under power save, unicast is not. Flood at a fixed packet rate +(the host hits `ENOBUFS` unthrottled); 1500 packets per second survived on every board. A forced Wi-Fi +reconnect on the Pico keeps IGMP membership. Set a long loop interval from `wifi.on_connect` +(`App.set_loop_interval(20000)`), not `on_boot`, or Wi-Fi connect crawls. The `logger.log` action +rejects `%.*s`; use a lambda. diff --git a/.agents/skills/hardware-test/reference/recipes/usb_cdc_acm.md b/.agents/skills/hardware-test/reference/recipes/usb_cdc_acm.md new file mode 100644 index 00000000000..688a415f3ad --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/usb_cdc_acm.md @@ -0,0 +1,20 @@ +# usb_cdc_acm + +## usb_cdc_acm on the nRF52840 dongle (#11634) + +`templates/usb-cdc-acm-dongle-dut.yaml`: logger on USB_CDC port 0, `usb_cdc_acm` interface 0 disabled, +interface 1 echoing from a 20 ms lambda, 0x01-prefixed commands for burst, stream, flush, peek, line coding +and counters. Build and flash with `dfu: true` through the app port; the dongle then shows three CDC ports: +the board DTS node `cdc_acm_uart` (unused), `cdc_acm_uart0` (log) and `cdc_acm_uart1` (data), in that order. +Capture the log port with `serial_capture.py`, drive the data port with `scripts/usb_cdc_acm/drive.py +DATA_PORT` (11 cases, prints PASS/FAIL and a summary) and grep the log for `Opened`, `Closed`, `buffer full`, +`flush result`, `line coding`. Boot lines are held in the driver's 1 KB ring until the host opens the port, so +a capture started later still shows the first 1 KB of the boot and then a truncated line. + +With three CDC instances the Zephyr legacy stack's `CONFIG_USB_MAX_NUM_TRANSFERS=4` starves the last instance +(each instance holds an OUT read and an IN ZLP), so interface 1 shows DTR changes but moves no data. +`scripts/usb_cdc_acm/cdc_probe.hpp` (include it, call `cdc_probe()` from an interval) reads the Zephyr driver +directly to tell a dead endpoint from a component bug. Raising the transfer pool to 8, or disabling the +board's `cdc_acm_uart` node, fixes it. Design limit of the driver: `fifo_fill` holds `tx_ready` until the +write callback and `write_array` drops when the ring is full, so a 64 KiB full-duplex echo loses one 64 byte +tail on every buffer size. diff --git a/.agents/skills/hardware-test/reference/recipes/wifi.md b/.agents/skills/hardware-test/reference/recipes/wifi.md new file mode 100644 index 00000000000..055fe267084 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/wifi.md @@ -0,0 +1,25 @@ +# wifi + +## `reboot_timeout` with a fallback AP (#9958) + +Only one board runs the AP at a time, or stations join the wrong one. AP probe: bogus network, +`ap: {ssid, password, ap_timeout: 5s}`, `reboot_timeout: 60s`, `logger: level: VERBOSE` (ESP32 and +ESP8266 log `AP client connected MAC=` at verbose), `on_boot: logger.log: BOOT`. Station probes: +`reboot_timeout: 0s`, `interval: 150s` running `wifi.disable`, `delay: 20s`, `wifi.enable` (60 s cycles +are too short to cover the due moment). Pass pattern: "Can't connect; rebooting" exactly 60 s after boot +with no station joined, no reboot for minutes while one is, then a reboot within a second of the last +disconnect. The BW15 restarts its AP on every STA retry and cannot be tested this way. + +## DHCP lease cache (#17767) + +Cycle 20 s awake, `deep_sleep.enter 30s`, wake, 20 s, `App.safe_reboot()`, logging `millis()` at the +`wifi.connected` condition. An RTS hardware reset must fall back to DHCP; a software reboot and a deep +sleep wake must apply the cache. `esphome run --no-logs` silently skipped the upload on one board; use +`upload` then `logs`. + +## Wi-Fi Easy Connect (DPP) enrollee (#17934) + +Only an Android phone configures: Settings, network details, Add device, Share Wi-Fi (the Wi-Fi list's +QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG. +`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the +supplicant state machine. diff --git a/.agents/skills/hardware-test/reference/recipes/zephyr.md b/.agents/skills/hardware-test/reference/recipes/zephyr.md new file mode 100644 index 00000000000..e10b2010230 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/zephyr.md @@ -0,0 +1,10 @@ +# zephyr + +## Zephyr preferences on the nRF52840 dongle (#20810) + +`templates/nrf52-preferences-dut.yaml`: logger on USB_CDC at VERBOSE, `flash_write_interval: 5s`, one +`globals` with `restore_value` incremented every 15 s. Give each build its own global id so each first boot +sees a never-saved key (settings survive a reflash). Flow per build: flash through the log port, 40 s capture +of the first boot, then start a second capture and reboot with a 2001 baud touch on the dongle's other CDC +port (the dfu rate callback is global, and the capture holds the log port); `serial_capture.py` reopens the +log port after the USB re-enumeration and gets the buffered boot lines (`load setting`, `Loaded N settings`). diff --git a/.agents/skills/hardware-test/reference/recipes/zephyr_coredump.md b/.agents/skills/hardware-test/reference/recipes/zephyr_coredump.md new file mode 100644 index 00000000000..80f2db8574d --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/zephyr_coredump.md @@ -0,0 +1,17 @@ +# zephyr_coredump + +## zephyr_coredump crash and decode on nRF52 boards (#13122) + +`templates/nrf52-coredump-crash-dut.yaml` (dongle; change `board:` to `adafruit_itsybitsy_nrf52840` for the +ItsyBitsy): `zephyr_coredump:`, `dfu: true`, a `restore_value` global keyed by the `run` substitution +(settings survive a reflash, so bump `run` for every new crash), and an `on_boot` sequence that sets the +flag, yields 3 s so the globals loop saves it (a blocking lambda never gets there and the board crash-loops), +syncs preferences, then writes to 0xDEADBEEF. Flash, keep every port closed for 80 s, then +`scripts/zephyr_coredump/flow.sh LABEL PORT ELF [OUTDIR]` does the three opens (3 s cut, 100 s full print, +20 s reopen) and calls `decode.sh`, which keeps the first complete `#CD:BEGIN#..END#` block, runs Zephyr's +`coredump_serial_log_parser.py` and `coredump_gdbserver.py` from the sdk-nrf ESPHome installed and prints +`bt` and `info threads` with its `arm-zephyr-eabi-gdb`. The ELF is `.pioenvs//zephyr/zephyr/zephyr.elf`. +Expect about 9 KB dumps, 140 to 150 `#CD:` lines, a 673 ms loop warning for the erase, and `Has stored +dump: 0` on the reopen. Check `compiled on` in every capture: another session flashing the same board +mid-test looks exactly like a failed test otherwise. Boards without a network cannot exercise the API and +MQTT delivery paths of the component; say so in the results. diff --git a/.agents/skills/hardware-test/reference/recipes/zwave_proxy.md b/.agents/skills/hardware-test/reference/recipes/zwave_proxy.md new file mode 100644 index 00000000000..b9e4ca99018 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/zwave_proxy.md @@ -0,0 +1,23 @@ +# zwave_proxy + +## zwave_proxy frame checksums on a live proxy (#20922) + +A Z-Wave proxy (ESP32-S3 with the module on `usb_uart`) is a live device: flash it over OTA and read logs +over the API, there is no serial console because the native USB is the host port for the module, so the +boot-time `GET_NETWORK_IDS` exchange is never captured. Home Assistant's Z-Wave JS holds the single API +subscription (it polls `0x3B` every 30 s and resubscribes after every reboot), so do not subscribe from a +script, and keep the device `name:` so it stays on Z-Wave JS; give each build its own scratch directory +instead. + +1. `logger: level: VERY_VERBOSE` with `logs: zwave_proxy: VERY_VERBOSE` and DEBUG for the rest: the + `Sending:`, `Received frame:` and `CHECKSUM Received ... Calculated` lines are `ESP_LOGVV`, which needs + the compile-time level. +2. `md5` the two `firmware.ota.bin` files to be sure the A and B builds differ, then `esphome upload + --device .local` and a `timeout 330 esphome logs --device .local` capture started right after + the OTA to catch the resubscribe traffic. +3. `scripts/zwave_proxy/verify_checksums.py ` recomputes the XOR (init 0xFF, LENGTH..payload) over + every logged frame and counts bad checksums and ACK/NAK sent and received; `scripts/zwave_proxy/devinfo.py + .local` prints the firmware version, compile time and the Home ID from the API (a Home ID proves the + device-built `GET_NETWORK_IDS` frame was accepted at boot). +4. Pre-existing: one `zwave_proxy took a long time for an operation` warning on the first frame after the + log client connects, caused by VERY_VERBOSE logging; it appears on dev too. diff --git a/.agents/skills/hardware-test/scripts/cse7766/observe.py b/.agents/skills/hardware-test/scripts/cse7766/observe.py new file mode 100644 index 00000000000..e88e1da1b2e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/cse7766/observe.py @@ -0,0 +1,71 @@ +"""Subscribe to states and logs of a device over the API for N seconds and summarize its float sensors. + +usage: observe.py IP SECONDS OUTFILE [NAME_FILTER] + +Every float sensor state whose entity name contains NAME_FILTER (default: all) is appended to OUTFILE +with a relative timestamp, log lines are appended with a LOG prefix, and a one-line summary per sensor +(count, min, max, last) plus the warning and error count is printed at the end. +""" + +import asyncio +import pathlib +import re +import sys +import time + +from aioesphomeapi import APIClient, LogLevel + +IP, SECS, OUT = sys.argv[1], float(sys.argv[2]), sys.argv[3] +FILTER = sys.argv[4] if len(sys.argv) > 4 else "" +# ESPHome LOG_SECRET span; one cut off before its end marker is secret to the end of the line +SECRET = re.compile(r"\x1b\[8m.*?(?:\x1b\[28m|$)", re.DOTALL) +ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +async def main() -> None: + c = APIClient(IP, 6053, None) + await c.connect(login=True) + di = await c.device_info() + ents, _ = await c.list_entities_services() + names = {e.key: e.name for e in ents} + readings: dict[str, list[float]] = {} + warnings: list[str] = [] + t0 = time.monotonic() + with pathlib.Path(OUT).open("w", encoding="utf-8") as out: + out.write( + f"device {di.name} version {di.esphome_version} compiled {di.compilation_time}\n" + ) + + def on_state(st) -> None: + n = names.get(st.key, str(st.key)) + if FILTER in n and hasattr(st, "state") and isinstance(st.state, float): + readings.setdefault(n, []).append(st.state) + out.write(f"[{time.monotonic() - t0:6.1f}] {n}: {st.state}\n") + out.flush() + + def on_log(msg) -> None: + line = ( + msg.message.decode(errors="replace") + if isinstance(msg.message, bytes) + else msg.message + ) + line = ANSI.sub("", SECRET.sub("", line)) + out.write(f"[{time.monotonic() - t0:6.1f}] LOG {line}\n") + out.flush() + if "[W]" in line or "[E]" in line or "Invalid" in line: + warnings.append(line) + + c.subscribe_states(on_state) + c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE) + await asyncio.sleep(SECS) + await c.disconnect() + summary = "; ".join( + f"{k.split()[-1]} n={len(v)} min={min(v):.3f} max={max(v):.3f} last={v[-1]:.3f}" + for k, v in sorted(readings.items()) + ) + print(f"{di.compilation_time}: {summary}; warnings/errors {len(warnings)}") + for w in warnings[:5]: + print(" ", w) + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/cse7766/run_obs.sh b/.agents/skills/hardware-test/scripts/cse7766/run_obs.sh new file mode 100755 index 00000000000..e9e9083f978 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/cse7766/run_obs.sh @@ -0,0 +1,27 @@ +#!/bin/zsh +# usage: run_obs.sh IP SECONDS LABEL [OUTDIR] [NAME_FILTER] +# Observes states and logs over the API with a concurrent ping and prints a summary: the device-side +# "frames parsed" counter per 10 s, gaps over 5 s between voltage updates seen by the API, and warnings. +# Needs a python with aioesphomeapi (PYTHON, default python3). +D=${0:A:h} +IP=$1; SECS=$2; L=$3; S=${4:-.}; F=${5:-} +PY=${PYTHON:-python3} +setopt pipefail +ping -i 1 -c $SECS $IP > $S/ping-$L.txt 2>&1 & +P=$! +if ! timeout $((SECS + 40)) $PY $D/observe.py $IP $SECS $S/obs-$L.txt "$F" 2>&1 | sed "s/$IP//g"; then + echo "observe.py failed or timed out, no summary"; wait $P; exit 1 +fi +wait $P +echo "ping: $(tail -2 $S/ping-$L.txt | head -1 | sed "s/$IP//g") $(tail -1 $S/ping-$L.txt)" +python3 - "$S/obs-$L.txt" <<'PY' +import re, sys +txt = open(sys.argv[1]).read() +fr = [int(m.group(1)) for m in re.finditer(r"frames parsed: (\d+) in 10 s", txt)] +print(f"device frame counter per 10 s: n={len(fr)} min={min(fr) if fr else None} max={max(fr) if fr else None} mean={sum(fr)/len(fr) if fr else 0:.1f}") +ts = [float(m.group(1)) for m in re.finditer(r"^\[\s*([0-9.]+)\] .*Voltage: ", txt, re.M)] +gaps = [round(b - a, 1) for a, b in zip(ts, ts[1:]) if b - a > 5] +print(f"API voltage updates {len(ts)}, gaps over 5 s: {gaps}") +warn = [l for l in txt.splitlines() if "LOG" in l and ("[W]" in l or "[E]" in l or "Invalid" in l)] +print(f"warnings/errors in log: {len(warn)}", *warn[:3], sep="\n ") +PY diff --git a/.agents/skills/hardware-test/scripts/libretiny_crash/bkprobe.hpp b/.agents/skills/hardware-test/scripts/libretiny_crash/bkprobe.hpp new file mode 100644 index 00000000000..b36739c362b --- /dev/null +++ b/.agents/skills/hardware-test/scripts/libretiny_crash/bkprobe.hpp @@ -0,0 +1,116 @@ +// BK72xx crash probe: forces one crash per step and shows what the SDK and the bootloader leave behind. +// Steps: 0 undefined instruction, 1 call into unmapped memory, 2 unmapped and unaligned accesses (no fault), +// 3 lt_reboot(). The step lives in a flash preference saved and synced before the crash. +#pragma once +#include +#include +#include +#include +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" + +extern "C" unsigned bk_misc_get_start_type(void); +extern "C" char _bss_end[]; + +static constexpr uint32_t PROBE_CANARY_LOW = 0x004000F8; // free word in the SDK-reserved low SRAM +static constexpr uint32_t PROBE_CANARY_ITCM = 0x003F3FF0; // top of ITCM +static char bkprobe_report[112]; +static constexpr uint32_t PROBE_MARKS[] = {0x40A1B0, 0x40B000, 0x40C000, 0x40E000, 0x410000, 0x414000, 0x418000, + 0x41C000, 0x420000, 0x428000, 0x430000, 0x438000, 0x43C000, 0x43E000, + 0x43F000, 0x43F800, 0x43FC00, 0x43FE00, 0x43FFF0}; +static constexpr size_t PROBE_MARK_COUNT = sizeof(PROBE_MARKS) / sizeof(PROBE_MARKS[0]); + +static inline uint32_t rd(uint32_t a) { return *(volatile uint32_t *) a; } + +inline esphome::ESPPreferenceObject bkprobe_pref(uint32_t run) { + return esphome::global_preferences->make_preference(0xBC000000u + run); +} + +inline int bkprobe_boot(uint32_t run) { + using namespace esphome; + int step = 0; + bkprobe_pref(run).load(&step); + unsigned st = bk_misc_get_start_type(); + snprintf(bkprobe_report, sizeof(bkprobe_report), "step=%d reason=%s(%d) start_type=%u low=%08lx itcm=%08lx top=%08lx", + step, lt_get_reboot_reason_name(lt_get_reboot_reason()), (int) lt_get_reboot_reason(), st, + (unsigned long) rd(PROBE_CANARY_LOW), (unsigned long) rd(PROBE_CANARY_ITCM), + (unsigned long) rd(0x0043FFF0u)); + ESP_LOGI("probe", "%s", bkprobe_report); + for (uint32_t a = 0x400000; a < 0x400100; a += 32) { + ESP_LOGI("probe", "%06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a, + (unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12), + (unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24), + (unsigned long) rd(a + 28)); + } + for (size_t i = 0; i < PROBE_MARK_COUNT; i += 7) { + char line[120]; + int n = 0; + for (size_t j = i; j < i + 7 && j < PROBE_MARK_COUNT; j++) + n += snprintf(line + n, sizeof(line) - n, " %06lx=%08lx", (unsigned long) PROBE_MARKS[j], + (unsigned long) rd(PROBE_MARKS[j])); + ESP_LOGI("probe", "marks%s", line); + } + uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u; + for (uint32_t a = rec; a < rec + 64; a += 32) { + ESP_LOGI("probe", "rec %06lx: %08lx %08lx %08lx %08lx %08lx %08lx %08lx %08lx", (unsigned long) a, + (unsigned long) rd(a), (unsigned long) rd(a + 4), (unsigned long) rd(a + 8), (unsigned long) rd(a + 12), + (unsigned long) rd(a + 16), (unsigned long) rd(a + 20), (unsigned long) rd(a + 24), + (unsigned long) rd(a + 28)); + } + *(volatile uint32_t *) PROBE_CANARY_LOW = 0xC0DE0000u | (uint32_t) step; + *(volatile uint32_t *) PROBE_CANARY_ITCM = 0x17C40000u | (uint32_t) step; + return step; +} + +__attribute__((noinline)) void bkprobe_data_abort() { + using namespace esphome; + volatile uint32_t v = rd(0xFFFFFFF0); + ESP_LOGI("probe", "read 0xFFFFFFF0 -> %08lx (no fault)", (unsigned long) v); + uint32_t r; + __asm__ volatile("ldr %0, [%1]" : "=r"(r) : "r"(0x00400101) : "memory"); + ESP_LOGI("probe", "unaligned ldr 0x400101 -> %08lx (no fault)", (unsigned long) r); + *(volatile uint32_t *) 0xFFFFFFF0 = 1; + ESP_LOGI("probe", "store 0xFFFFFFF0 (no fault)"); +} + +// act is the step number before it was bumped +inline void bkprobe_act(uint32_t run, int act) { + using namespace esphome; + int next = act + 1; + bkprobe_pref(run).save(&next); + global_preferences->sync(); + ESP_LOGI("probe", "acting on step %d (saved %d)", act, next); + // mark candidate storage areas so the next boot shows what survives + uint32_t rec = ((uint32_t) _bss_end + 7) & ~7u; + for (uint32_t i = 0; i < 8; i++) + *(volatile uint32_t *) (rec + 32 + i * 4) = 0xA5A50000u | i; // second half of the record area + for (size_t i = 0; i < PROBE_MARK_COUNT; i++) + *(volatile uint32_t *) PROBE_MARKS[i] = 0x70700000u | (uint32_t) i; + ESP_LOGI("probe", "ramtop before: %08lx", (unsigned long) rd(0x0043FFF0u)); + vTaskDelay(pdMS_TO_TICKS(50)); + switch (act) { + case 0: + __builtin_trap(); // undefined instruction + break; + case 1: { + void (*fn)() = (void (*)()) 0xFFFFFFF1; // prefetch abort (unmapped, Thumb) + fn(); + ESP_LOGI("probe", "call 0xFFFFFFF1 returned (no fault)"); + break; + } + case 2: + bkprobe_data_abort(); + break; + case 3: + lt_reboot(); + break; + default: + break; + } +} + +inline void bkprobe_alive(int step) { + using namespace esphome; + ESP_LOGI("probe", "alive step=%d uptime=%lus boot: %s", step, (unsigned long) (millis() / 1000), bkprobe_report); +} diff --git a/.agents/skills/hardware-test/scripts/libretiny_crash/decode_capture.py b/.agents/skills/hardware-test/scripts/libretiny_crash/decode_capture.py new file mode 100644 index 00000000000..366dbe65d47 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/libretiny_crash/decode_capture.py @@ -0,0 +1,25 @@ +"""Feed a serial capture through the CLI's stacktrace decoder for a build. + +usage: decode_capture.py CONFIG_YAML CAPTURE + +Run from the directory that holds the config (its .esphome build is used), with the esphome checkout that +built it on PYTHONPATH. Lines of the crash report are echoed, decoded frames follow them. +""" + +import logging +from pathlib import Path +import sys + +from esphome.config import read_config +from esphome.core import CORE +from esphome.stacktrace import LogLineProcessor + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") +yaml, capture = Path(sys.argv[1]), Path(sys.argv[2]) +CORE.config_path = yaml +config = read_config({}) +proc = LogLineProcessor(config, CORE.target_platform) +for line in capture.read_text(encoding="utf-8", errors="replace").splitlines(): + if "libretiny.crash" in line: + print(line[:120]) + proc.process_line(line) diff --git a/.agents/skills/hardware-test/scripts/libretiny_prefs/ltprefs.hpp b/.agents/skills/hardware-test/scripts/libretiny_prefs/ltprefs.hpp new file mode 100644 index 00000000000..295ba7a9814 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/libretiny_prefs/ltprefs.hpp @@ -0,0 +1,155 @@ +#pragma once +// BK7238 bench harness for esphome#21007: one rtc and one flash preference, a step counter in flash, +// and an exit action per step so the board drives its own soft reboot, crash and watchdog reboot. +#include +#include +#include +#include +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" + +using namespace esphome; + +// Boot report kept for the alive line, so a boot the host could not capture is still visible +static char ltprefs_report[128]; +// Last boots' reports, kept in a flash preference so boots the host never saw stay readable +struct LtprefsHistory { + char line[8][128]; +}; +static LtprefsHistory ltprefs_hist; + +inline ESPPreferenceObject ltprefs_step_pref(uint32_t run) { + return global_preferences->make_preference(0x3B210070u ^ run, true); +} + +// Loads the step and both preferences, logs them, saves this step's value. +// Returns the exit action: 0 idle, 1 lt_reboot, 2 crash, 3 watchdog. +inline int ltprefs_boot(int &step, uint32_t run) { + auto reason = lt_get_reboot_reason(); + step = 0; + ltprefs_step_pref(run).load(&step); + auto rtcp = global_preferences->make_preference(0x1B210070u ^ run, false); + auto flp = global_preferences->make_preference(0x2B210070u ^ run, true); + uint32_t rv = 0, fv = 0; + // a second pair sharing one key as rtc and as flash, and a third rtc entry, so a hard reset drops three + auto rtcp2 = global_preferences->make_preference(0x5B210070u ^ run, false); + auto flp2 = global_preferences->make_preference(0x5B210070u ^ run, true); + auto rtcp3 = global_preferences->make_preference(0x6B210070u ^ run, false); + uint32_t rv2 = 0, fv2 = 0, rv3 = 0; + bool rok = rtcp.load(&rv); + bool fok = flp.load(&fv); + bool rok2 = rtcp2.load(&rv2); + bool fok2 = flp2.load(&fv2); + bool rok3 = rtcp3.load(&rv3); + snprintf(ltprefs_report, sizeof(ltprefs_report), + "step=%d reason=%s(%d) rtc=%s%u flash=%s%u sharedkey rtc=%s%u flash=%s%u rtc3=%s%u", step, + lt_get_reboot_reason_name(reason), (int) reason, rok ? "" : "none ", rv, fok ? "" : "none ", fv, + rok2 ? "" : "none ", rv2, fok2 ? "" : "none ", fv2, rok3 ? "" : "none ", rv3); + ESP_LOGI("lttest", "%s", ltprefs_report); + auto histp = global_preferences->make_preference(0x4B210070u ^ run, true); + if (!histp.load(<prefs_hist)) + memset(<prefs_hist, 0, sizeof(ltprefs_hist)); + memmove(<prefs_hist.line[1], <prefs_hist.line[0], sizeof(ltprefs_hist.line) - sizeof(ltprefs_hist.line[0])); + strncpy(ltprefs_hist.line[0], ltprefs_report, sizeof(ltprefs_hist.line[0]) - 1); + ltprefs_hist.line[0][sizeof(ltprefs_hist.line[0]) - 1] = 0; + if (!histp.save(<prefs_hist)) { + ESP_LOGW("lttest", "history save failed"); + } + int act = 0; + bool save = true; + switch (step) { + case 0: + act = 1; + break; // soft reboot + case 1: + act = 2; + break; // crash + case 2: + act = 3; + break; // watchdog + case 3: + act = 0; + break; // idle: chip-enable reset from the host + case 4: + act = 1; + break; // soft reboot after the hard reset + case 5: + act = 0; + break; // idle: chip-enable reset from the host + case 6: + act = 1; + save = false; + break; // no save, soft reboot: deleted, not skipped? + case 7: + act = 0; + break; // idle + case 8: + act = 2; + break; // crash + case 9: + act = 0; + break; // idle + case 10: + act = 0; + break; // idle: power cut by the user + default: + act = 0; + save = false; + break; + } + if (save) { + uint32_t v = 100 + step, v2r = 200 + step, v2f = 300 + step, v3 = 400 + step; + bool srtc = rtcp.save(&v); + bool sfl = flp.save(&v); + bool srtc2 = rtcp2.save(&v2r); + bool sfl2 = flp2.save(&v2f); + bool srtc3 = rtcp3.save(&v3); + bool synced = global_preferences->sync(); + ESP_LOGI("lttest", "save %u/%u/%u/%u: rtc=%d flash=%d sharedkey rtc=%d flash=%d rtc3=%d sync=%d", v, v2r, v2f, v3, + srtc, sfl, srtc2, sfl2, srtc3, synced); + } + ESP_LOGI("lttest", "exit action=%d", act); + return act; +} + +inline void ltprefs_exit(int &step, uint32_t run, int act) { + step++; + bool saved = ltprefs_step_pref(run).save(&step); + bool synced = global_preferences->sync(); + ESP_LOGI("lttest", "step %d save=%d sync=%d", step, saved, synced); + if (!saved || !synced) { + ESP_LOGE("lttest", "step not stored, staying idle instead of repeating the step"); + return; + } + vTaskDelay(pdMS_TO_TICKS(200)); + switch (act) { + case 1: + ESP_LOGW("lttest", "soft reboot now"); + vTaskDelay(pdMS_TO_TICKS(50)); + lt_reboot(); + break; + case 2: + ESP_LOGW("lttest", "crashing now"); + vTaskDelay(pdMS_TO_TICKS(50)); + __builtin_trap(); // undefined instruction: a plain bad store does not fault on the ARM968 + break; + case 3: + ESP_LOGW("lttest", "watchdog reboot now"); + vTaskDelay(pdMS_TO_TICKS(50)); + lt_reboot_wdt(); + break; + default: + ESP_LOGW("lttest", "idle, waiting for an external reset"); + break; + } +} + +inline void ltprefs_alive(int step, int act) { + ESP_LOGI("lttest", "alive step=%d act=%d boot: %s", step, act, ltprefs_report); + for (int i = 7; i >= 0; i--) { + if (ltprefs_hist.line[i][0] != 0) { + ESP_LOGI("lttest", " history[-%d]: %s", i, ltprefs_hist.line[i]); + } + } +} diff --git a/.agents/skills/hardware-test/scripts/online_image/drive.py b/.agents/skills/hardware-test/scripts/online_image/drive.py new file mode 100644 index 00000000000..2fbf3c47b56 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/online_image/drive.py @@ -0,0 +1,179 @@ +"""Drive the online_image cases over the native API and judge each from the serial capture.""" + +import asyncio +import pathlib +import re +import sys +import time + +from aioesphomeapi import ( + APIClient, + APIConnectionError, + InvalidEncryptionKeyAPIError, + RequiresEncryptionAPIError, +) + +HOST, KEY, CAPTURE, BASE, MODE = sys.argv[1:6] +CAP_S = 45 + +CASES = [ + # (label, action, path, expected outcome) + ("ok bmp", "fetch", "ok.bmp", "finished"), + ("ok png", "fetch", "ok.png", "finished"), + ("ok jpg", "fetch", "ok.jpg", "finished"), + ("ok qoi", "fetch", "ok.qoi", "finished"), + ("truncated bmp", "fetch", "truncated.bmp", "error"), + ("ok bmp after error", "fetch", "ok.bmp", "finished"), + ("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"), + ("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"), + ("empty body", "fetch", "empty.bmp", "error"), + ("stalled connection", "fetch", "stall.bmp", "error"), + ("closed early", "fetch", "closed_early.bmp", "error"), + ("slow but alive", "fetch", "slow.bmp", "finished"), + ("chunked bmp", "fetch", "chunked.bmp", "finished"), + ("chunked png", "fetch", "chunked.png", "finished"), + ("chunked qoi", "fetch", "chunked.qoi", "finished"), + ("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"), + ("chunked png cut", "fetch", "chunked_cut.png", "any"), + ("no content-length", "fetch", "noclen.bmp", "any"), + ("huge jpeg claim", "fetch", "huge.jpg", "error"), + ("ok jpg after huge", "fetch", "ok.jpg", "finished"), + ("ok bmp again", "fetch", "ok.bmp", "finished"), + ("ok bmp refetch: 304", "refetch", "", "finished"), + ("ok png", "fetch", "ok.png", "finished"), + ("ok png refetch: 304", "refetch", "", "finished"), + ("chunked bmp", "fetch", "chunked.bmp", "finished"), + ("chunked bmp refetch: 304", "refetch", "", "finished"), + ("chunked png", "fetch", "chunked.png", "finished"), + ("chunked png refetch: not cached", "refetch", "", "finished"), + ("chunked png cut", "fetch", "chunked_cut.png", "any"), + ("chunked png cut refetch: not cached", "refetch", "", "any"), +] +if MODE == "base": + # Each stuck case is followed by a fetch that shows the symptom, then a reboot. + CASES = [ + ("ok bmp", "fetch", "ok.bmp", "finished"), + ("truncated bmp", "fetch", "truncated.bmp", "error"), + ("ok bmp after truncated", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("large header, 256 byte buffer", "fetch_small", "large_header.bmp", "error"), + ("ok bmp, 256 byte buffer", "fetch_small", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("empty body", "fetch", "empty.bmp", "error"), + ("ok bmp after empty", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("stalled connection", "fetch", "stall.bmp", "error"), + ("ok bmp after stall", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("closed early", "fetch", "closed_early.bmp", "error"), + ("ok bmp after closed early", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("chunked png", "fetch", "chunked.png", "finished"), + ("ok bmp after chunked png", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("chunked bmp cut", "fetch", "chunked_cut.bmp", "error"), + ("ok bmp after chunked cut", "fetch", "ok.bmp", "finished"), + ("REBOOT", "", "", ""), + ("huge jpeg claim", "fetch", "huge.jpg", "error"), + ("ok jpg after huge", "fetch", "ok.jpg", "finished"), + ] + +OUTCOME = re.compile(r"download (finished|error): (img_small|img)") +INTEREST = re.compile( + r"\[(E|W)\]\[online_image|Image already being updated|download (finished|error)|Downloading image|Updating image|Image fully downloaded|HTTP 304|allocat|Panic|abort\(\)|Guru|Backtrace|rst:0x", + re.IGNORECASE, +) + + +def tail(path: str, offset: int) -> tuple[list[str], int]: + with pathlib.Path(path).open("rb") as f: + f.seek(offset) + data = f.read() + return data.decode("utf-8", "replace").splitlines(), offset + len(data) + + +async def connect() -> tuple[APIClient, dict, int]: + for _ in range(60): + try: + c = APIClient(HOST, 6053, None, noise_psk=KEY) + await c.connect(login=True) + ents, services = await c.list_entities_services() + except (InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError) as exc: + raise SystemExit(f"API key problem, not retrying: {exc}") from exc + except (APIConnectionError, OSError) as exc: + # only connection trouble is retried; a wrong key or a missing button must fail at once + print(f"connect: {exc}", flush=True) + await asyncio.sleep(3) + continue + btn = next( + (e.key for e in ents if getattr(e, "object_id", "") == "reboot"), None + ) + if btn is None: + raise SystemExit("the config has no reboot button") + return c, {s.name: s for s in services}, btn + raise SystemExit("API never connected") + + +async def main() -> None: + client, actions, btn = await connect() + with pathlib.Path(CAPTURE).open("rb") as f: + f.seek(0, 2) + offset = f.tell() + rows = [] + for label, action, path, expect in CASES: + if label == "REBOOT": + client.button_command(btn) + await asyncio.sleep(1) + await client.disconnect() + await asyncio.sleep(8) + client, actions, btn = await connect() + _, offset = tail(CAPTURE, offset) + print("--- rebooted", flush=True) + continue + img = "img_small" if action.endswith("_small") else "img" + t0 = time.monotonic() + args = {} if action.startswith("refetch") else {"url": f"{BASE}/{path}"} + await client.execute_service(actions[action], args) + outcome, notes = "no outcome", [] + while time.monotonic() - t0 < CAP_S: + await asyncio.sleep(0.5) + lines, offset = tail(CAPTURE, offset) + done = False + for line in lines: + if INTEREST.search(line): + notes.append(line.strip()) + m = OUTCOME.search(line) + if m and m.group(2) == img: + outcome = m.group(1) + done = True + if done: + break + took = time.monotonic() - t0 + if outcome == "no outcome": + took = CAP_S + # drain a little more so the "already being updated" line lands + await asyncio.sleep(1) + lines, offset = tail(CAPTURE, offset) + notes += [line.strip() for line in lines if INTEREST.search(line)] + ok = outcome == expect or (expect == "any" and outcome != "no outcome") + rows.append((label, path, expect, outcome, took, ok, notes)) + print( + f"{'PASS' if ok else 'FAIL'} {label:32s} {path:18s} expected {expect:9s} got {outcome:10s} {took:5.1f}s", + flush=True, + ) + for n in notes: + print(f" {n}", flush=True) + await asyncio.sleep(1.5) + await client.disconnect() + print( + "\nSUMMARY", + "all expectations met" + if all(r[5] for r in rows) + else "MISMATCHES: " + ", ".join(r[0] for r in rows if not r[5]), + flush=True, + ) + if not all(r[5] for r in rows): + sys.exit(1) + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/online_image/make_images.py b/.agents/skills/hardware-test/scripts/online_image/make_images.py new file mode 100644 index 00000000000..a7e3451d291 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/online_image/make_images.py @@ -0,0 +1,38 @@ +"""Build the small test images the HTTP server serves.""" + +from pathlib import Path +import struct +import sys + +from PIL import Image + +out = Path(sys.argv[1]) +out.mkdir(exist_ok=True) +w = h = 16 +im = Image.new("RGB", (w, h)) +px = im.load() +for y in range(h): + for x in range(w): + px[x, y] = (x * 16, y * 16, (x + y) * 8) +im.save(out / "ok.bmp", "BMP") +im.save(out / "ok.png", "PNG") +im.save(out / "ok.jpg", "JPEG", quality=90) + +# QOI with only QOI_OP_RGB codes: valid and trivially encoded. +qoi = b"qoif" + struct.pack(">IIBB", w, h, 3, 0) +for y in range(h): + for x in range(w): + qoi += bytes([0xFE, *px[x, y]]) +qoi += bytes(7) + b"\x01" +(out / "ok.qoi").write_bytes(qoi) + +# 8x8 8-bit palette BMP: 1078 byte header, pixels start beyond a 256 byte buffer. +pw = ph = 8 +data_offset = 14 + 40 + 256 * 4 +file_size = data_offset + pw * ph +hdr = b"BM" + struct.pack(" None: + LOG.write(f"{time.strftime('%H:%M:%S')} {msg}\n") + + +def headers(ctype: str, extra: list[bytes]) -> bytes: + lines = [ + b"HTTP/1.1 200 OK", + b"Content-Type: " + ctype.encode(), + b"Connection: close", + *extra, + b"", + b"", + ] + return b"\r\n".join(lines) + + +def etag(path: str) -> bytes: + return b'"' + path.strip("/").encode() + b'-v1"' + + +def chunked(body: bytes, size: int) -> bytes: + out = b"" + for i in range(0, len(body), size): + part = body[i : i + size] + out += b"%x\r\n" % len(part) + part + b"\r\n" + return out + b"0\r\n\r\n" + + +async def respond(path: str, req: bytes, w: asyncio.StreamWriter) -> str: + name, _, ext = path.lstrip("/").rpartition(".") + ctype = MIME.get(ext, "application/octet-stream") + body = IMG.get(ext, BMP) + tag = [b"ETag: " + etag(path)] + if b"if-none-match: " + etag(path).lower() in req.lower(): + w.write( + b"HTTP/1.1 304 Not Modified\r\nETag: " + + etag(path) + + b"\r\nConnection: close\r\n\r\n" + ) + return "304 not modified" + if name == "ok": + w.write(headers(ctype, [b"Content-Length: %d" % len(body), *tag]) + body) + return f"full {len(body)} bytes" + if name == "truncated": # cut one byte into the sixth pixel, Content-Length matches + cut = BMP[: BMP_HDR + 5 * 3 + 1] + w.write(headers(ctype, [b"Content-Length: %d" % len(cut)]) + cut) + return f"truncated {len(cut)} bytes" + if name == "large_header": + w.write( + headers(ctype, [b"Content-Length: %d" % len(LARGE_HEADER)]) + LARGE_HEADER + ) + return f"large header {len(LARGE_HEADER)} bytes" + if name == "empty": + w.write(headers(ctype, [b"Content-Length: 0"])) + return "empty body" + if ( + name == "stall" + ): # claims the full length, sends part, then holds the socket open + part = body[: BMP_HDR + 15] + w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part) + await w.drain() + await asyncio.sleep(40) + return f"stalled after {len(part)} bytes, closed after 40 s" + if name == "closed_early": # claims the full length, sends part, closes at once + part = body[: BMP_HDR + 15] + w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + part) + return f"closed after {len(part)} of {len(body)} bytes" + if name == "slow": # pauses under the timeout, then completes + w.write(headers(ctype, [b"Content-Length: %d" % len(body)]) + body[:BMP_HDR]) + await w.drain() + await asyncio.sleep(2.5) + w.write(body[BMP_HDR:]) + return f"full {len(body)} bytes with a 2.5 s pause" + if name == "chunked": + w.write( + headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(body, 64) + ) + return f"chunked full {len(body)} bytes" + if ( + name == "chunked_cut" + ): # chunked, ends cleanly at a pixel boundary (or half a png) + cut = body[: BMP_HDR + 5 * 3] if ext == "bmp" else body[: len(body) // 2] + w.write( + headers(ctype, [b"Transfer-Encoding: chunked", *tag]) + chunked(cut, 64) + ) + return f"chunked cut {len(cut)} of {len(body)} bytes" + if name == "noclen": # body delimited by connection close + w.write(headers(ctype, []) + body) + return f"no content-length, full {len(body)} bytes" + if name == "huge": # claims 20 MB so the JPEG buffer resize fails + w.write(headers(ctype, [b"Content-Length: 20000000"]) + body) + return f"claims 20000000, sent {len(body)} bytes" + w.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + return "404" + + +async def handle(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None: + peer = w.get_extra_info("peername") + try: + async with asyncio.timeout(5): + line = await r.readuntil(b"\r\n") + req = await r.readuntil(b"\r\n\r\n") + path = line.split(b" ")[1].decode() + log( + f"GET {path} from {peer}" + + (" (If-None-Match)" if b"if-none-match" in req.lower() else "") + ) + what = await respond(path, req, w) + await w.drain() + log(f" -> {path}: {what}") + except Exception as exc: # noqa: BLE001 + log(f" !! {peer}: {exc!r}") + finally: + w.close() + + +async def main() -> None: + server = await asyncio.start_server(handle, "0.0.0.0", PORT) + log(f"listening on {PORT}") + async with server: + await server.serve_forever() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/cam_api.py b/.agents/skills/hardware-test/scripts/rtsp_server/cam_api.py new file mode 100644 index 00000000000..53c9996b3ba --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/cam_api.py @@ -0,0 +1,33 @@ +"""Request one camera image over the native API and report its size (direct esp32_camera check).""" + +import asyncio +import sys + +from aioesphomeapi import APIClient + + +async def main(): + c = APIClient(sys.argv[1], 6053, None) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + cams = [e for e in ents if type(e).__name__ == "CameraInfo"] + print("camera entities:", [e.name for e in cams]) + got = asyncio.get_event_loop().create_future() + + def on_state(state): + if type(state).__name__ == "CameraState" and not got.done(): + got.set_result(len(state.data)) + + c.subscribe_states(on_state) + c.request_single_image() + try: + n = await asyncio.wait_for(got, 10) + print("single image bytes:", n) + except TimeoutError: + print("no image within 10 s") + await c.disconnect() + sys.exit(1) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/measure.sh b/.agents/skills/hardware-test/scripts/rtsp_server/measure.sh new file mode 100755 index 00000000000..b8b4d30326f --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/measure.sh @@ -0,0 +1,32 @@ +#!/bin/zsh +# usage: measure.sh IP LABEL LOGFILE [OUTDIR] +# Runs the fixed RTSP measurement set against one build and appends a result line to OUTDIR/results.txt: +# ping RTT, two 12 s ffmpeg runs, two concurrent ffmpeg clients, and the raw receive rate with the default +# and a 1 MiB socket buffer. LOGFILE is the device capture, used to count "stalled" drops during the run. +# A measurement that produced nothing is written as ERR and the script exits 1, so a broken run never looks +# like a measured one. Needs ffmpeg and a python with the rtsp scripts' dependencies (PYTHON, default python3). +D=${0:A:h} +IP=$1; LABEL=$2; LOG=$3; S=${4:-.} +PY=${PYTHON:-python3} +[ -f "$LOG" ] || { echo "device capture not found: $LOG"; exit 1; } +rtt=$(ping -c 10 -i 0.2 $IP 2>&1 | tail -1 | awk -F/ '{print $5}') +stall0=$(grep -a -c "stalled" $LOG) +frames() { # OUTFILE RC: the frame count of a run that finished on its own, nothing for a failed or killed one + [ "$2" = 0 ] && grep -o 'frame= *[0-9]*' $1 | tail -1 | tr -dc 0-9 +} +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f1.txt 2>&1; f1=$(frames $S/m-f1.txt $?); sleep 2 +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-f2.txt 2>&1; f2=$(frames $S/m-f2.txt $?); sleep 2 +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoA.txt 2>&1 & A=$!; sleep 1 +timeout 40 ffmpeg -hide_banner -rtsp_transport tcp -i rtsp://$IP:554/ -t 12 -f null - > $S/m-twoB.txt 2>&1 & B=$! +wait $A; ra=$?; wait $B; rb=$? +fa=$(frames $S/m-twoA.txt $ra); fb=$(frames $S/m-twoB.txt $rb); sleep 2 +timeout 40 $PY $D/rawrate2.py $IP quiet > $S/m-raw.txt 2>&1 && raw=$(grep -o "[0-9.]* KiB/s" $S/m-raw.txt | head -1); sleep 2 +timeout 40 $PY $D/rawrate2.py $IP bigbuf > $S/m-raw2.txt 2>&1 && raw2=$(grep -o "[0-9.]* KiB/s" $S/m-raw2.txt | head -1); sleep 1 +stall1=$(grep -a -c "stalled" $LOG) +bad=0 +for v in rtt f1 f2 fa fb raw raw2; do + if [ -z "${(P)v}" ]; then eval "$v=ERR"; bad=1; fi +done +line="$LABEL | rtt avg ${rtt} ms | ffmpeg single 12 s: ${f1}, ${f2} frames | two clients: ${fa} + ${fb} | python raw: ${raw} (1 MiB rcvbuf ${raw2}) | stalls logged: $((stall1 - stall0))" +echo "$line" | tee -a $S/results.txt +if [ $bad = 1 ]; then echo "one or more measurements failed (ERR above)"; exit 1; fi diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/rawrate2.py b/.agents/skills/hardware-test/scripts/rtsp_server/rawrate2.py new file mode 100644 index 00000000000..f65329c4025 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/rawrate2.py @@ -0,0 +1,83 @@ +import contextlib +import socket +import sys +import time + +IP = sys.argv[1] +mode = sys.argv[2] +secs = 8 +s = socket.create_connection((IP, 554), timeout=5) +if mode == "bigbuf": + s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1 << 20) +cseq = 0 + + +buf = b"" + + +def read_response() -> bytes: + """One complete RTSP response: headers through the blank line plus a Content-Length body.""" + global buf # noqa: PLW0603 + while (end := buf.find(b"\r\n\r\n")) < 0: + chunk = s.recv(4096) + if not chunk: + raise ConnectionError("closed") + buf += chunk + head = buf[:end].decode(errors="replace") + body_len = 0 + for line in head.split("\r\n"): + if line.lower().startswith("content-length:"): + body_len = int(line.split(":", 1)[1]) + while len(buf) < end + 4 + body_len: + chunk = s.recv(4096) + if not chunk: + raise ConnectionError("closed") + buf += chunk + resp, buf = buf[: end + 4 + body_len], buf[end + 4 + body_len :] + return resp + + +def req(m, extra="", wait=True): + global cseq # noqa: PLW0603 + cseq += 1 + url = f"rtsp://{IP}:554/trackID=0" if m == "SETUP" else f"rtsp://{IP}:554/" + s.sendall(f"{m} {url} RTSP/1.0\r\nCSeq: {cseq}\r\n{extra}\r\n".encode()) + return read_response() if wait else b"" + + +req("OPTIONS") +req("DESCRIBE", "Accept: application/sdp\r\n") +r = req("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n") +sess = ( + [x for x in r.decode(errors="replace").split("\r\n") if x.startswith("Session")][0] + .split()[1] + .split(";")[0] +) +req("PLAY", f"Session: {sess}\r\n") +t0 = time.monotonic() +total = 0 +chunks = 0 +last_chat = t0 +s.settimeout(2) +while time.monotonic() - t0 < secs: + now = time.monotonic() + if mode == "chatty" and now - last_chat > 0.2: + req("GET_PARAMETER", f"Session: {sess}\r\n", wait=False) + last_chat = now + if mode == "rtcp" and now - last_chat > 0.2: + s.sendall(b"$\x01\x00\x08" + b"\x80\xc9\x00\x01\x00\x00\x00\x01") + last_chat = now + try: + d = s.recv(65536) + except TimeoutError: + continue + if not d: + break + total += len(d) + chunks += 1 +print( + f"{mode:7s}: {total / 1024 / secs:5.1f} KiB/s, {chunks} recvs, avg {total // max(chunks, 1)} B/recv" +) +with contextlib.suppress(OSError): + req("TEARDOWN", f"Session: {sess}\r\n") +s.close() diff --git a/.agents/skills/hardware-test/scripts/rtsp_server/rtsp_client.py b/.agents/skills/hardware-test/scripts/rtsp_server/rtsp_client.py new file mode 100644 index 00000000000..6cec93b3a4e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/rtsp_server/rtsp_client.py @@ -0,0 +1,345 @@ +"""Raw RTSP/TCP client for the esphome rtsp_server hardware test. + +usage: rtsp_client.py HOST [case ...] +cases: play, udp, pipeline, third, stall, teardown, getparam, badreq, replay, timeout +""" + +import socket +import struct +import sys +import time + +HOST = sys.argv[1] +PORT = 554 +cases = sys.argv[2:] or [ + "play", + "udp", + "pipeline", + "badreq", + "replay", + "getparam", + "teardown", + "third", + "stall", +] +results = [] + + +def say(case, ok, detail): + results.append((case, ok, detail)) + print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True) + + +class Client: + def __init__(self, timeout=5): + self.s = socket.create_connection((HOST, PORT), timeout=timeout) + self.cseq = 0 + self.session = None + self.buf = b"" + + def send(self, method, extra="", url=None): + self.cseq += 1 + url = ( + url or f"rtsp://{HOST}:{PORT}/trackID=0" + if method == "SETUP" + else (url or f"rtsp://{HOST}:{PORT}/") + ) + req = f"{method} {url} RTSP/1.0\r\nCSeq: {self.cseq}\r\n" + if self.session: + req += f"Session: {self.session}\r\n" + req += extra + "\r\n" + self.s.sendall(req.encode()) + return self.cseq + + def _recv(self): + chunk = self.s.recv(4096) + if not chunk: + raise ConnectionError("closed") + self.buf += chunk + + def read_response(self): + # Read until a full RTSP response (headers + optional body); skip any interleaved data in between + while True: + if self.buf.startswith(b"$"): + if len(self.buf) < 4: + self._recv() + continue + ln = struct.unpack(">H", self.buf[2:4])[0] + if len(self.buf) < 4 + ln: + self._recv() + continue + self.buf = self.buf[4 + ln :] + continue + end = self.buf.find(b"\r\n\r\n") + if end < 0: + self._recv() + continue + head = self.buf[:end].decode(errors="replace") + body_len = 0 + for line in head.split("\r\n"): + if line.lower().startswith("content-length:"): + body_len = int(line.split(":", 1)[1]) + while len(self.buf) < end + 4 + body_len: + self._recv() + body = self.buf[end + 4 : end + 4 + body_len] + self.buf = self.buf[end + 4 + body_len :] + status = int(head.split(" ", 2)[1]) + for line in head.split("\r\n"): + if line.lower().startswith("session:"): + self.session = line.split(":", 1)[1].strip().split(";")[0] + return status, head, body + + def read_frames(self, seconds): + """Collect interleaved RTP packets on channel 0 for `seconds`; return (frames, packets, seq_gaps, sizes).""" + deadline = time.monotonic() + seconds + frames = 0 + packets = 0 + gaps = 0 + last_seq = None + cur = 0 + sizes = [] + self.s.settimeout(1.0) + while time.monotonic() < deadline: + try: + chunk = self.s.recv(65536) + except TimeoutError: + continue + if not chunk: + break + self.buf += chunk + while True: + if len(self.buf) < 4 or self.buf[0:1] != b"$": + if self.buf and self.buf[0:1] != b"$": + end = self.buf.find(b"\r\n\r\n") + if end < 0: + break + self.buf = self.buf[end + 4 :] + continue + break + ch = self.buf[1] + ln = struct.unpack(">H", self.buf[2:4])[0] + if len(self.buf) < 4 + ln: + break + pkt = self.buf[4 : 4 + ln] + self.buf = self.buf[4 + ln :] + if ch != 0 or len(pkt) < 12: + continue + packets += 1 + seq = struct.unpack(">H", pkt[2:4])[0] + marker = pkt[1] & 0x80 + if last_seq is not None and seq != ((last_seq + 1) & 0xFFFF): + gaps += 1 + last_seq = seq + cur += len(pkt) - 12 + if marker: + frames += 1 + sizes.append(cur) + cur = 0 + return frames, packets, gaps, sizes + + def close(self): + self.s.close() + + +def setup_play(c): + c.send("OPTIONS") + st, head, _ = c.read_response() + assert st == 200, head + c.send("DESCRIBE", "Accept: application/sdp\r\n") + st, head, body = c.read_response() + assert st == 200, head + c.send("SETUP", "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n") + st, head, _ = c.read_response() + assert st == 200, head + c.send("PLAY") + st, head, _ = c.read_response() + assert st == 200, head + return body + + +for case in cases: + try: + if case == "play": + c = Client() + sdp = setup_play(c) + frames, packets, gaps, sizes = c.read_frames(10) + c.send("TEARDOWN") + c.read_response() + c.close() + ok = frames >= 50 and gaps == 0 + say( + case, + ok, + f"{frames} frames in 10 s ({frames / 10:.1f} fps), {packets} RTP packets, {gaps} seq gaps, frame size min {min(sizes) if sizes else 0} max {max(sizes) if sizes else 0}; sdp has m=video {'m=video' in sdp.decode()}", + ) + elif case == "udp": + c = Client() + c.send("OPTIONS") + c.read_response() + c.send("SETUP", "Transport: RTP/AVP;unicast;client_port=5000-5001\r\n") + st, head, _ = c.read_response() + c.close() + say(case, st == 461, f"UDP SETUP answered {st}") + elif case == "pipeline": + c = Client() + c.s.sendall( + f"OPTIONS rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 1\r\n\r\nDESCRIBE rtsp://{HOST}/ RTSP/1.0\r\nCSeq: 2\r\nAccept: application/sdp\r\n\r\n".encode() + ) + c.cseq = 2 + st1, h1, _ = c.read_response() + st2, h2, b2 = c.read_response() + c.close() + cs = [x for x in (h1 + h2).split("\r\n") if x.startswith("CSeq")] + say( + case, + st1 == 200 and st2 == 200 and cs == ["CSeq: 1", "CSeq: 2"], + f"two pipelined requests answered {st1}, {st2} with {cs}", + ) + elif case == "badreq": + c = Client() + c.s.sendall(b"BOGUS rtsp://x/ RTSP/1.0\r\nCSeq: 7\r\n\r\n") + st, head, _ = c.read_response() + c.close() + say( + case, + st in (400, 405, 501) and "CSeq: 7" in head, + f"unknown method answered {st}, CSeq echoed {'CSeq: 7' in head}", + ) + elif case == "replay": + c = Client() + setup_play(c) + f1, _, _, _ = c.read_frames(3) + c.send("PLAY") + st, head, _ = c.read_response() + f2, _, gaps, _ = c.read_frames(3) + c.send("TEARDOWN") + c.read_response() + c.close() + say( + case, + st == 200 and f2 >= 10 and gaps == 0, + f"second PLAY answered {st}, {f1} then {f2} frames, {gaps} seq gaps after repeat PLAY", + ) + elif case == "getparam": + c = Client() + setup_play(c) + c.read_frames(1) + c.send("GET_PARAMETER") + st, head, _ = c.read_response() + f, _, _, _ = c.read_frames(2) + c.send("TEARDOWN") + c.read_response() + c.close() + say( + case, + st == 200 and f > 5, + f"GET_PARAMETER while playing answered {st}, stream continued with {f} frames in 2 s", + ) + elif case == "teardown": + c = Client() + setup_play(c) + c.read_frames(2) + c.send("TEARDOWN") + st, head, _ = c.read_response() + c.s.settimeout(3) + time.sleep(1.5) + try: + rest = c.s.recv(65536) + closed = rest == b"" + except TimeoutError: + rest = b"x" + closed = False + except ConnectionError: + closed = True + c.close() + say( + case, + st == 200, + f"TEARDOWN answered {st}; connection closed by server afterwards: {closed}", + ) + elif case == "third": + a = Client() + setup_play(a) + b = Client() + setup_play(b) + fa, _, ga, _ = a.read_frames(4) + fb, _, gb, _ = b.read_frames(4) + try: + d = Client(timeout=3) + d.send("OPTIONS") + st, _, _ = d.read_response() + third = f"third got {st}" + d.close() + except ConnectionError as e: + # refused, reset or closed by the server; a timeout or a bug propagates and fails the case + third = f"third rejected ({type(e).__name__})" + a.send("TEARDOWN") + a.read_response() + a.close() + b.send("TEARDOWN") + b.read_response() + b.close() + say( + case, + fa > 10 and fb > 10 and ga == 0 and gb == 0 and "rejected" in third, + f"two clients: {fa} and {fb} frames in 4 s each, gaps {ga}/{gb}; {third}", + ) + elif case == "stall": + a = Client() + setup_play(a) + b = Client() + setup_play(b) + # stop reading on a for 4 s while b keeps playing + a.s.settimeout(None) + fb1, _, gb1, _ = b.read_frames(4) + a.s.settimeout(1.0) + fa, _, ga, _ = a.read_frames(4) + fb2, _, gb2, _ = b.read_frames(3) + a.send("TEARDOWN") + a.read_response() + a.close() + b.send("TEARDOWN") + b.read_response() + b.close() + say( + case, + fb1 > 10 and fb2 > 10 and fa > 5, + f"b kept {fb1} then {fb2} frames while a stalled 4 s; a recovered {fa} frames after resuming (gaps {ga}); b gaps {gb1}/{gb2}; device log must show stalled/dropping frame", + ) + elif case == "timeout": + c = Client(timeout=70) + setup_play(c) + c.s.settimeout( + 2 + ) # a server that stops sending but never closes must not hang the run + t0 = time.monotonic() + dead = False + try: + while time.monotonic() - t0 < 68: + try: + d = c.s.recv(65536) + except TimeoutError: + continue + if not d: + dead = True + break + except ConnectionError: + dead = True + elapsed = time.monotonic() - t0 + say( + case, + dead and 50 <= elapsed <= 68, + f"connection closed by server after idle: {dead} at {elapsed:.0f} s (expect the 60 s session timeout when the client sends nothing)", + ) + except Exception as e: # noqa: BLE001 + say(case, False, f"exception {type(e).__name__}: {e}") + time.sleep(1) + +print( + "SUMMARY", + sum(1 for r in results if r[1]), + "pass", + sum(1 for r in results if not r[1]), + "fail", +) +sys.exit(1 if any(not r[1] for r in results) else 0) diff --git a/.agents/skills/hardware-test/scripts/usb_cdc_acm/cdc_probe.hpp b/.agents/skills/hardware-test/scripts/usb_cdc_acm/cdc_probe.hpp new file mode 100644 index 00000000000..3fa93631a0f --- /dev/null +++ b/.agents/skills/hardware-test/scripts/usb_cdc_acm/cdc_probe.hpp @@ -0,0 +1,13 @@ +#pragma once +#include +#include +#include "esphome/core/log.h" + +// Direct look at the Zephyr cdc_acm_uart1 driver state, bypassing the usb_cdc_acm component +inline void cdc_probe() { + const device *d = DEVICE_DT_GET(DT_NODELABEL(cdc_acm_uart1)); + uint8_t tmp[32]; + int rr = uart_irq_rx_ready(d); + int n = uart_fifo_read(d, tmp, sizeof(tmp)); + ESP_LOGI("probe", "driver rx_ready=%d fifo_read=%d first=0x%02X", rr, n, n > 0 ? tmp[0] : 0); +} diff --git a/.agents/skills/hardware-test/scripts/usb_cdc_acm/drive.py b/.agents/skills/hardware-test/scripts/usb_cdc_acm/drive.py new file mode 100644 index 00000000000..dbd8e4f9753 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/usb_cdc_acm/drive.py @@ -0,0 +1,203 @@ +"""Drive the usb_cdc_acm echo firmware on the nRF52840 dongle from the Mac. + +usage: drive.py DATA_PORT [case ...] +Cases: short, bulk1k, bulk16k, bulk64k, burst, flush, peek, linecoding, dtr, stream, counters +""" + +import random +import sys +import time + +import serial + +port = sys.argv[1] +cases = sys.argv[2:] or [ + "short", + "bulk1k", + "bulk16k", + "bulk64k", + "burst", + "flush", + "peek", + "linecoding", + "dtr", + "stream", + "counters", +] +results = [] + + +def say(case, ok, detail): + results.append((case, ok, detail)) + print(f"{'PASS' if ok else 'FAIL'} {case}: {detail}", flush=True) + + +def info(case, detail): + """A case whose result lives in the device log; printed, not counted.""" + print(f"INFO {case}: {detail}", flush=True) + + +def open_port(baud=115200): + s = serial.Serial(port, baudrate=baud, timeout=1) + time.sleep(0.3) # let the 100 ms line-state poll see DTR and the echo loop settle + return s + + +def read_exact(s, n, timeout): + buf = bytearray() + deadline = time.monotonic() + timeout + while len(buf) < n and time.monotonic() < deadline: + chunk = s.read(min(4096, n - len(buf))) + if chunk: + buf += chunk + return bytes(buf) + + +def payload(n): + # No 0x01 anywhere: the device treats a chunk starting with 0x01 as a command + rnd = random.Random(n) + return bytes(rnd.choice(range(2, 256)) for _ in range(n)) + + +def cmd(s, c, extra=b""): + s.write(b"\x01" + c + extra) + s.flush() + + +def echo_case(name, n, chunk): + s = open_port() + s.reset_input_buffer() + data = payload(n) + t0 = time.monotonic() + for i in range(0, n, chunk): + s.write(data[i : i + chunk]) + got = read_exact(s, n, timeout=max(5, n / 2000)) + dt = time.monotonic() - t0 + s.close() + ok = got == data + first_bad = next((i for i in range(min(len(got), n)) if got[i] != data[i]), None) + say( + name, + ok, + f"{len(got)}/{n} bytes echoed in {dt:.2f}s ({n / dt / 1024:.1f} KiB/s), first mismatch {first_bad}", + ) + + +for case in cases: + if case == "short": + s = open_port() + s.reset_input_buffer() + ok = True + lat = [] + for i in range(20): + msg = f"hello {i}\n".encode() + t0 = time.monotonic() + s.write(msg) + got = read_exact(s, len(msg), 2) + lat.append(time.monotonic() - t0) + ok = ok and got == msg + s.close() + say( + case, + ok, + f"20 lines, latency min {min(lat) * 1000:.0f} ms max {max(lat) * 1000:.0f} ms", + ) + elif case == "bulk1k": + echo_case(case, 1024, 1024) + elif case == "bulk16k": + echo_case(case, 16 * 1024, 4096) + elif case == "bulk64k": + echo_case(case, 64 * 1024, 16384) + elif case == "burst": + s = open_port() + s.reset_input_buffer() + cmd(s, b"B") + got = read_exact(s, 1000, 3) + s.close() + expect = bytes((ord("A") + (i % 26)) for i in range(1000)) + prefix_ok = got == expect[: len(got)] + say( + case, + prefix_ok and 0 < len(got) < 1000, + f"{len(got)} of 1000 bytes arrived (256 byte TX ring, rest must be dropped and warned), prefix intact {prefix_ok}", + ) + elif case == "flush": + s = open_port() + s.reset_input_buffer() + cmd(s, b"F") + got = read_exact(s, 200, 3) + s.close() + expect = bytes((ord("a") + (i % 26)) for i in range(200)) + say(case, got == expect, f"{len(got)}/200 bytes, flush result in device log") + time.sleep(0.5) + elif case == "peek": + s = open_port() + s.reset_input_buffer() + cmd(s, b"K", b"Z") + time.sleep(0.5) + s.close() + info( + case, + "peek/read result is in the device log: expect `peek ok=1 0x5A read ok=1 0x5A`", + ) + elif case == "linecoding": + for baud in (9600, 115200, 921600): + s = open_port(baud) + s.reset_input_buffer() + cmd(s, b"L") + time.sleep(0.4) + s.close() + time.sleep(0.3) + info(case, "opened at 9600, 115200, 921600; the device log must show each baud") + elif case == "dtr": + for _i in range(5): + s = open_port() + time.sleep(0.3) + s.close() + time.sleep(0.3) + info( + case, "5 open/close cycles; the device log must show 5 Opened and 5 Closed" + ) + elif case == "stream": + # device streams 100 x 100 byte lines over 10 s; close the port for 3 s in the middle + s = open_port() + s.reset_input_buffer() + cmd(s, b"S") + part1 = bytearray() + t0 = time.monotonic() + while time.monotonic() - t0 < 2.5: + part1 += s.read(4096) + s.close() + time.sleep(3.0) + s = open_port() + part2 = bytearray() + t0 = time.monotonic() + while time.monotonic() - t0 < 6.0: + part2 += s.read(4096) + s.close() + lines1 = [x for x in bytes(part1).split(b"\n") if x.startswith(b"STREAM")] + lines2 = [x for x in bytes(part2).split(b"\n") if x.startswith(b"STREAM")] + n1 = [int(x[7:10]) for x in lines1] + n2 = [int(x[7:10]) for x in lines2] + resumed = bool(n2) and max(n2) >= 90 + say( + case, + resumed, + f"before close: lines {min(n1) if n1 else None}-{max(n1) if n1 else None} ({len(n1)}), after reopen: {min(n2) if n2 else None}-{max(n2) if n2 else None} ({len(n2)}); streaming resumed after reopen {resumed}", + ) + elif case == "counters": + s = open_port() + cmd(s, b"C") + time.sleep(0.3) + s.close() + info(case, "counters are in the device log") + time.sleep(0.5) + +print( + "SUMMARY", + sum(1 for r in results if r[1]), + "pass", + sum(1 for r in results if not r[1]), + "fail", +) +sys.exit(1 if any(not r[1] for r in results) else 0) diff --git a/.agents/skills/hardware-test/scripts/zephyr_coredump/decode.sh b/.agents/skills/hardware-test/scripts/zephyr_coredump/decode.sh new file mode 100755 index 00000000000..11476fcddf9 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zephyr_coredump/decode.sh @@ -0,0 +1,39 @@ +#!/bin/zsh +# usage: decode.sh CAPTURE ELF OUTPREFIX +# Parses the #CD: lines of a serial capture with Zephyr's coredump tools from the sdk-nrf that ESPHome +# installed (SDK_NRF, default: the sdk-nrf directory under the ESPHome cache), serves the dump to GDB on a +# random port and prints the backtrace and the thread list. The ELF is .pioenvs//zephyr/zephyr/zephyr.elf. +# Every step is checked: a missing tool, a capture without a complete dump, an empty .bin or a gdbserver +# that died all stop the script with exit 1, and the exit status is GDB's otherwise. +CAP=$1; ELF=$2; OUT=$3 +SDK_NRF=${SDK_NRF:-$(ls -d ~/Library/Caches/esphome/sdk-nrf ~/.cache/esphome/sdk-nrf 2>/dev/null | head -1)} +[ -n "$SDK_NRF" ] || { echo "sdk-nrf not found, set SDK_NRF"; exit 1; } +F=$(ls -d $SDK_NRF/frameworks/v*/zephyr/scripts/coredump 2>/dev/null | tail -1) +PY=${PYTHON:-$(ls $SDK_NRF/penvs/*/bin/python 2>/dev/null | head -1)} +# SDK 0.x keeps the tools under arm-zephyr-eabi/, SDK 1.0+ under gnu/arm-zephyr-eabi/ +GDB=$(ls $SDK_NRF/toolchains/*/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb $SDK_NRF/toolchains/*/gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb 2>/dev/null | tail -1) +for v in F PY GDB; do + [ -n "${(P)v}" ] || { echo "$v not found under $SDK_NRF"; exit 1; } +done +[ -f "$ELF" ] || { echo "ELF not found: $ELF"; exit 1; } +# strip capture timestamps and ANSI so the parser sees the raw log lines +sed -E 's/^\[[0-9:.]+\]//; s/\x1b\[[0-9;]*m//g' "$CAP" > $OUT-all.log +# keep only the first complete #CD:BEGIN# .. #CD:END# block +awk '/#CD:BEGIN#/{if(!done){p=1;buf=""}} p{buf=buf $0 "\n"} /#CD:END#/{if(p){printf "%s", buf; done=1; p=0}}' $OUT-all.log > $OUT-raw.log +echo "CD lines in capture: $(grep -c '#CD:' $OUT-all.log) (BEGIN $(grep -c '#CD:BEGIN#' $OUT-all.log), END $(grep -c '#CD:END#' $OUT-all.log)); first complete block: $(grep -c '#CD:' $OUT-raw.log) lines" +[ -s $OUT-raw.log ] || { echo "no complete #CD:BEGIN# .. #CD:END# block in the capture"; exit 1; } +if ! $PY $F/coredump_serial_log_parser.py $OUT-raw.log $OUT.bin > $OUT-parser.log 2>&1; then + echo "parser failed:"; tail -5 $OUT-parser.log; exit 1 +fi +tail -3 $OUT-parser.log +[ -s $OUT.bin ] || { echo "parser wrote an empty $OUT.bin"; exit 1; } +ls -la $OUT.bin | awk '{print "bin bytes", $5}' +PORT=$((20000 + RANDOM % 10000)) +$PY $F/coredump_gdbserver.py --port $PORT "$ELF" $OUT.bin > $OUT-gdbserver.log 2>&1 & +G=$! +sleep 3 +if ! kill -0 $G 2>/dev/null; then echo "gdbserver exited:"; tail -5 $OUT-gdbserver.log; exit 1; fi +$GDB -q -batch -ex "target remote :$PORT" -ex "bt" -ex "info threads" "$ELF" 2>&1 | grep -v "^warning\|Reading symbols\|^$" | head -40 +rc=$pipestatus[1] +kill $G 2>/dev/null +exit $rc diff --git a/.agents/skills/hardware-test/scripts/zephyr_coredump/flow.sh b/.agents/skills/hardware-test/scripts/zephyr_coredump/flow.sh new file mode 100755 index 00000000000..262951ba5ff --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zephyr_coredump/flow.sh @@ -0,0 +1,29 @@ +#!/bin/zsh +# usage: flow.sh LABEL PORT ELF [OUTDIR] +# Run at least 60 s after the board rebooted from its crash, with the port closed. Proves the stored +# coredump waits for a reader and survives a reader that goes away: +# 1) open the port for 3 s and close it (a reader that leaves mid-print) +# 2) open it for 100 s: the whole dump must print again, then be erased +# 3) open it again for 20 s: dump_config must report no stored dump +# then decode.sh parses the first complete dump and prints the GDB backtrace. +# Needs a python with pyserial (PYTHON, default python3). +D=${0:A:h} +L=$1; P=$2; ELF=$3; S=${4:-.} +PY=${PYTHON:-python3} +CAP=$D/../serial_capture.py +capture() { # SECONDS OUTFILE: stop the flow when the capture fails (busy or missing port) + if ! $PY $CAP $P $1 $2 > $2.err 2>&1; then + echo "capture of $2 failed:"; cat $2.err; exit 1 + fi +} +capture 3 $S/$L-brief.txt +echo "$L brief 3 s open: $(wc -l < $S/$L-brief.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-brief.txt), END $(grep -a -c '#CD:END#' $S/$L-brief.txt)" +sleep 5 +capture 100 $S/$L-full.txt +echo "$L full 100 s open: $(wc -l < $S/$L-full.txt) lines, CD lines $(grep -a -c '#CD:' $S/$L-full.txt), BEGIN $(grep -a -c '#CD:BEGIN#' $S/$L-full.txt), END $(grep -a -c '#CD:END#' $S/$L-full.txt)" +grep -a "crashtest\|zephyr_coredump\|Reset reason\|Has stored\| Size:\| Error:\|took a long\|compiled on" $S/$L-full.txt | grep -a -v "#CD:[0-9A-F]" | head -16 +sleep 3 +capture 20 $S/$L-after.txt +echo "$L reopen 20 s: CD lines $(grep -a -c '#CD:' $S/$L-after.txt); $(grep -a 'Has stored\| Size:' $S/$L-after.txt | sed -E 's/^\[[^]]*\]//' | tr '\n' ' ')" +echo "== decode" +$D/decode.sh $S/$L-full.txt $ELF $S/$L diff --git a/.agents/skills/hardware-test/scripts/zwave_proxy/devinfo.py b/.agents/skills/hardware-test/scripts/zwave_proxy/devinfo.py new file mode 100644 index 00000000000..e6d73ee60cb --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zwave_proxy/devinfo.py @@ -0,0 +1,24 @@ +import asyncio +import sys + +from aioesphomeapi import APIClient + + +async def main(): + c = APIClient(sys.argv[1], 6053, None) + await c.connect(login=True) + di = await c.device_info() + print( + "version", + di.esphome_version, + "compiled", + di.compilation_time, + "home_id", + hex(di.zwave_home_id), + "flags", + di.zwave_proxy_feature_flags, + ) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/zwave_proxy/verify_checksums.py b/.agents/skills/hardware-test/scripts/zwave_proxy/verify_checksums.py new file mode 100644 index 00000000000..6b12146dd1e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/zwave_proxy/verify_checksums.py @@ -0,0 +1,45 @@ +"""Recompute the Z-Wave checksum (XOR of LENGTH..payload, initial 0xFF) over every frame the proxy logged.""" + +from functools import reduce +import pathlib +import re +import sys + + +def zw_checksum(frame: bytes) -> int: + return reduce(lambda a, b: a ^ b, frame[1:-1], 0xFF) + + +def complete(frame: bytes) -> bool: + """A data frame is SOF, LENGTH, then LENGTH bytes; the proxy logs at most 168 bytes, so longer ones are cut.""" + return len(frame) >= 3 and frame[0] == 0x01 and len(frame) == frame[1] + 2 + + +for path in sys.argv[1:]: + text = pathlib.Path(path).read_text(encoding="utf-8", errors="replace") + sent = re.findall(r"Sending: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text) + recv = re.findall(r"Received frame: ((?:[0-9A-F]{2}:)+[0-9A-F]{2})", text) + calc = re.findall( + r"CHECKSUM Received: 0x([0-9A-F]{2}) - Calculated: 0x([0-9A-F]{2})", text + ) + bad = len(re.findall(r"Bad checksum", text)) + acks_sent = len(re.findall(r"Sending ACK", text)) + naks_sent = len(re.findall(r"Sending NAK/CAN", text)) + acks_recv = len(re.findall(r"Received ACK", text)) + naks_recv = len(re.findall(r"Received NAK", text)) + reboots = len(re.findall(r"Boot count|Reset reason|rst:0x", text)) + long_ops = len(re.findall(r"zwave_proxy took a long time", text)) + rx_frames = [bytes.fromhex(f.replace(":", "")) for f in recv] + tx_frames = [bytes.fromhex(f.replace(":", "")) for f in sent] + rx_cut = sum(1 for b in rx_frames if not complete(b)) + tx_cut = sum(1 for b in tx_frames if len(b) > 1 and not complete(b)) + rx_ok = sum(1 for b in rx_frames if complete(b) and zw_checksum(b) == b[-1]) + tx_ok = sum(1 for b in tx_frames if complete(b) and zw_checksum(b) == b[-1]) + calc_ok = sum(1 for r, c in calc if r == c) + cmds = sorted({f.split(":")[3] for f in recv}) + print( + f"{path.rsplit('/', 1)[-1]}: tx frames {len(sent)} (checksum ok {tx_ok}, truncated in log {tx_cut}), " + f"rx frames {len(recv)} (recomputed ok {rx_ok}, truncated in log {rx_cut}), " + f"device calc==received {calc_ok}/{len(calc)}, bad checksum warnings {bad}, ACK sent {acks_sent}, NAK sent {naks_sent}, " + f"ACK received {acks_recv}, NAK received {naks_recv}, long ops {long_ops}, rx cmd ids {cmds}" + ) diff --git a/.agents/skills/hardware-test/templates/bk72xx-crash-probe-dut.yaml b/.agents/skills/hardware-test/templates/bk72xx-crash-probe-dut.yaml new file mode 100644 index 00000000000..b43b850c99a --- /dev/null +++ b/.agents/skills/hardware-test/templates/bk72xx-crash-probe-dut.yaml @@ -0,0 +1,44 @@ +substitutions: + run: "1" + +esphome: + name: bk72xx-crash-probe + includes: [../scripts/libretiny_crash/bkprobe.hpp] + # step 0 waits 30 s so a capture can be opened (and a reset pulsed) after the flash; later steps 5 s + on_boot: + priority: -100 + then: + - lambda: id(step) = bkprobe_boot(${run}); + - delay: !lambda 'return id(step) == 0 ? 30000 : 5000;' + - lambda: bkprobe_act(${run}, id(step)); + +bk72xx: + board: generic-bk7238-tuya + +logger: + hardware_uart: UART1 + level: DEBUG + +preferences: + flash_write_interval: 1s + +globals: + - id: step + type: int + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: bkprobe_alive(id(step)); + +# Wi-Fi matters: a crashed BK72xx with Wi-Fi up never reboots on its own without the crash handler fix +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + reboot_timeout: 0s + +ota: + - platform: esphome diff --git a/.agents/skills/hardware-test/templates/bk72xx-rtc-prefs-dut.yaml b/.agents/skills/hardware-test/templates/bk72xx-rtc-prefs-dut.yaml new file mode 100644 index 00000000000..0e8bacdc48b --- /dev/null +++ b/.agents/skills/hardware-test/templates/bk72xx-rtc-prefs-dut.yaml @@ -0,0 +1,38 @@ +substitutions: + run: "1" + +esphome: + name: lt21007e + includes: [../scripts/libretiny_prefs/ltprefs.hpp] + platformio_options: + build_flags: ["-DUSE_LIBRETINY_RTC_PREFERENCES"] + on_boot: + priority: -100 + then: + - lambda: id(act) = ltprefs_boot(id(step), ${run}); + # the first boot waits so the host can open the port and pulse a reset before the sequence starts + - delay: !lambda 'return (id(step) == 0 || id(step) == 8) ? 30000 : 3000;' + - lambda: ltprefs_exit(id(step), ${run}, id(act)); + +bk72xx: + board: generic-bk7238-tuya + +logger: + hardware_uart: UART1 + level: DEBUG + +preferences: + flash_write_interval: 1s + +globals: + - id: step + type: int + initial_value: "0" + - id: act + type: int + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: ltprefs_alive(id(step), id(act)); diff --git a/.agents/skills/hardware-test/templates/dut-ap-only-wipe-once-bk72xx.yaml b/.agents/skills/hardware-test/templates/dut-ap-only-wipe-once-bk72xx.yaml new file mode 100644 index 00000000000..c4e42ce4c2f --- /dev/null +++ b/.agents/skills/hardware-test/templates/dut-ap-only-wipe-once-bk72xx.yaml @@ -0,0 +1,34 @@ +esphome: + name: i19430-dut3 + on_boot: + priority: 800 + then: + - lambda: |- + auto marker = global_preferences->make_preference(0x19430001, true); + uint32_t v = 0; + if (!marker.load(&v)) { + ESP_LOGW("test", "first boot: wiping preferences"); + global_preferences->reset(); + static_cast(global_preferences)->open(); + v = 1; + marker.save(&v); + global_preferences->sync(); + } + +bk72xx: + board: generic-bk7238 + +logger: + hardware_uart: UART1 + level: VERBOSE + +wifi: + ap: + ssid: i19430-ap + +captive_portal: + +interval: + - interval: 5s + then: + - lambda: ESP_LOGI("test", "alive %u s", (unsigned) (millis() / 1000)); diff --git a/.agents/skills/hardware-test/templates/nrf52-coredump-crash-dut.yaml b/.agents/skills/hardware-test/templates/nrf52-coredump-crash-dut.yaml new file mode 100644 index 00000000000..7e163db752d --- /dev/null +++ b/.agents/skills/hardware-test/templates/nrf52-coredump-crash-dut.yaml @@ -0,0 +1,50 @@ +substitutions: + run: "3" + +esphome: + name: cd13122d + on_boot: + priority: -100 + then: + - lambda: |- + ESP_LOGI("crashtest", "boot, crashed_before=%d run=%s", id(crashed_${run}), "${run}"); + - delay: 20s + - if: + condition: + lambda: 'return id(crashed_${run}) == 0;' + then: + - lambda: |- + id(crashed_${run}) = 1; + ESP_LOGW("crashtest", "flag set, letting the loop save it"); + - delay: 3s + - lambda: |- + global_preferences->sync(); + delay(500); + ESP_LOGW("crashtest", "writing to 0xDEADBEEF now"); + delay(100); + *reinterpret_cast(0xDEADBEEF) = 1; + else: + - lambda: |- + ESP_LOGI("crashtest", "already crashed once, running normally"); + +nrf52: + board: nrf52840dongle + dfu: true + +logger: + hardware_uart: USB_CDC + level: DEBUG + +zephyr_coredump: + +globals: + - id: crashed_${run} + type: int + restore_value: true + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: |- + ESP_LOGI("crashtest", "alive"); diff --git a/.agents/skills/hardware-test/templates/nrf52-preferences-dut.yaml b/.agents/skills/hardware-test/templates/nrf52-preferences-dut.yaml new file mode 100644 index 00000000000..f7f588f5767 --- /dev/null +++ b/.agents/skills/hardware-test/templates/nrf52-preferences-dut.yaml @@ -0,0 +1,31 @@ +esphome: + name: pref20810 + on_boot: + priority: -100 + then: + - lambda: |- + ESP_LOGI("test", "boot: restored counter_pr=%d", id(counter_pr)); + +nrf52: + board: nrf52840dongle + dfu: true + +logger: + hardware_uart: USB_CDC + level: VERBOSE + +preferences: + flash_write_interval: 5s + +globals: + - id: counter_pr + type: int + restore_value: true + initial_value: "0" + +interval: + - interval: 15s + then: + - lambda: |- + id(counter_pr) += 1; + ESP_LOGI("test", "counter_pr now %d", id(counter_pr)); diff --git a/.agents/skills/hardware-test/templates/online-image-dut.yaml b/.agents/skills/hardware-test/templates/online-image-dut.yaml new file mode 100644 index 00000000000..f95c6ab7de6 --- /dev/null +++ b/.agents/skills/hardware-test/templates/online-image-dut.yaml @@ -0,0 +1,84 @@ +esphome: + name: online-image-dut + friendly_name: online-image-dut + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + level: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + actions: + - action: fetch + variables: + url: string + then: + - online_image.set_url: + id: img + url: !lambda 'return url;' + update: false + - component.update: img + - action: refetch + then: + - component.update: img + - action: fetch_small + variables: + url: string + then: + - online_image.set_url: + id: img_small + url: !lambda 'return url;' + update: false + - component.update: img_small + - action: refetch_small + then: + - component.update: img_small + +http_request: + timeout: 4.5s + verify_ssl: false + +display: + +image: + - platform: online_image + id: img + url: http://127.0.0.1/none.bmp + format: AUTO + type: RGB565 + update_interval: never + on_download_finished: + logger.log: + format: "download finished: img cached=%d" + args: [cached] + on_error: + logger.log: "download error: img" + - platform: online_image + id: img_small + url: http://127.0.0.1/none.bmp + format: AUTO + type: RGB565 + buffer_size: 256 + update_interval: never + on_download_finished: + logger.log: + format: "download finished: img_small cached=%d" + args: [cached] + on_error: + logger.log: "download error: img_small" + +button: + - platform: restart + id: reboot_btn + name: Reboot diff --git a/.agents/skills/hardware-test/templates/rtsp-waveshare-s3-eth-dut.yaml b/.agents/skills/hardware-test/templates/rtsp-waveshare-s3-eth-dut.yaml new file mode 100644 index 00000000000..8bab3df9abe --- /dev/null +++ b/.agents/skills/hardware-test/templates/rtsp-waveshare-s3-eth-dut.yaml @@ -0,0 +1,105 @@ +esphome: + name: rtsp17990 + on_boot: + # GPIO8 low enables the camera's power supply on the Waveshare ESP32-S3-ETH; give the sensor + # time to come up before esp32_camera (setup_priority 200) probes it + priority: 700 + then: + - switch.turn_off: cam_power + - lambda: |- + delay(1200); + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + +psram: + mode: octal + speed: 80MHz + +logger: + level: VERBOSE + hardware_uart: USB_SERIAL_JTAG + logs: + rtsp_server: VERBOSE + esp32_camera: DEBUG + camera: DEBUG + wifi: DEBUG + api: DEBUG + api.connection: DEBUG + api.socket: DEBUG + socket: DEBUG + scheduler: DEBUG + component: DEBUG + app: DEBUG + mdns: DEBUG + ota: DEBUG + logger: DEBUG + psram: DEBUG + esp32: DEBUG + network: DEBUG + i2c: DEBUG + i2c.idf: DEBUG + preferences: DEBUG + debug: DEBUG + sensor: DEBUG + wifi_esp32: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + power_save_mode: none + +api: + reboot_timeout: 0s + +ota: + - platform: esphome + +debug: + update_interval: 10s + +sensor: + - platform: debug + free: + name: "Heap Free" + block: + name: "Heap Largest Block" + psram: + name: "PSRAM Free" + +# Waveshare ESP32-S3-ETH camera header (Waveshare wiki, WIFI_Web_CAM table) +i2c: + - id: cam_i2c + sda: GPIO48 + scl: GPIO47 + frequency: 400kHz + +switch: + - platform: gpio + id: cam_power + name: Camera Power + pin: GPIO8 + restore_mode: ALWAYS_OFF + setup_priority: 800 + +esp32_camera: + name: cam + setup_priority: 200 + data_pins: [GPIO41, GPIO45, GPIO46, GPIO42, GPIO40, GPIO38, GPIO15, GPIO18] + vsync_pin: GPIO1 + href_pin: GPIO2 + pixel_clock_pin: GPIO39 + external_clock: + pin: GPIO3 + frequency: 20MHz + i2c_id: cam_i2c + resolution: 640X480 + max_framerate: 10fps + +rtsp_server: + port: 554 + max_connections: 2 diff --git a/.agents/skills/hardware-test/templates/s31-cse7766-dut.yaml b/.agents/skills/hardware-test/templates/s31-cse7766-dut.yaml new file mode 100644 index 00000000000..bcbe505c71d --- /dev/null +++ b/.agents/skills/hardware-test/templates/s31-cse7766-dut.yaml @@ -0,0 +1,107 @@ +# Basic Config + +esphome: + name: s31-cse7766 + +esp8266: + board: esp01_1m + toolchain: arduino + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +logger: + baud_rate: 0 # (UART logging interferes with cse7766) + level: VERBOSE + logs: + cse7766: VERBOSE + api: DEBUG + api.connection: DEBUG + api.socket: DEBUG + socket: DEBUG + scheduler: DEBUG + component: DEBUG + app: DEBUG + wifi: DEBUG + mdns: DEBUG + ota: DEBUG + logger: DEBUG + sensor: DEBUG + switch: DEBUG + binary_sensor: DEBUG + status_led: DEBUG + uart: DEBUG + gpio: DEBUG + preferences: DEBUG + esp8266: DEBUG + wifi_esp8266: DEBUG +api: +ota: + platform: esphome + +# Device Specific Config + +uart: + rx_pin: RX + baud_rate: 4800 + parity: EVEN + +binary_sensor: + - platform: gpio + pin: + number: GPIO0 + mode: INPUT_PULLUP + inverted: True + name: "S31 Button" + on_press: + - switch.toggle: relay + - platform: status + name: "S31 Status" + +sensor: + - platform: wifi_signal + name: "S31 WiFi Signal" + update_interval: 60s + - platform: cse7766 + current: + accuracy_decimals: 3 + name: "S31 Current" + filters: + - throttle: 2s + voltage: + accuracy_decimals: 1 + name: "S31 Voltage" + filters: + - throttle: 2s + on_raw_value: + then: + - lambda: |- + id(frames) += 1; + power: + accuracy_decimals: 1 + name: "S31 Power" + filters: + - throttle: 2s +switch: + - platform: gpio + name: "S31 Relay" + pin: GPIO12 + id: relay + restore_mode: RESTORE_DEFAULT_ON + +status_led: + pin: GPIO13 + +globals: + - id: frames + type: uint32_t + initial_value: "0" + +interval: + - interval: 10s + then: + - lambda: |- + static uint32_t last = 0; + ESP_LOGI("frames", "cse7766 frames parsed: %u in 10 s (total %u)", (unsigned) (id(frames) - last), (unsigned) id(frames)); + last = id(frames); diff --git a/.agents/skills/hardware-test/templates/usb-cdc-acm-dongle-dut.yaml b/.agents/skills/hardware-test/templates/usb-cdc-acm-dongle-dut.yaml new file mode 100644 index 00000000000..0dc9fa4a96a --- /dev/null +++ b/.agents/skills/hardware-test/templates/usb-cdc-acm-dongle-dut.yaml @@ -0,0 +1,112 @@ +esphome: + name: cdc11634 + +nrf52: + board: nrf52840dongle + dfu: true + +logger: + hardware_uart: USB_CDC + level: VERBOSE + logs: + usb_cdc_acm: VERBOSE + scheduler: DEBUG + component: DEBUG + app: DEBUG + preferences: DEBUG + logger: DEBUG + nrf52: DEBUG + dfu: DEBUG + +usb_cdc_acm: + interfaces: + - disabled: true + - id: cdc1 + +globals: + - id: rx_total + type: uint32_t + initial_value: "0" + - id: tx_attempted + type: uint32_t + initial_value: "0" + - id: stream_left + type: int32_t + initial_value: "0" + +interval: + - interval: 20ms + then: + - lambda: |- + uint8_t buf[64]; + while (id(cdc1).available() > 0) { + // a command is exactly two bytes, so what the host sends after it stays in the driver for peek/read + uint8_t first = 0; + id(cdc1).peek_byte(&first); + size_t n = std::min(id(cdc1).available(), sizeof(buf)); + if (first == 0x01 && n >= 2) + n = 2; + if (!id(cdc1).read_array(buf, n)) { + ESP_LOGE("test", "read_array(%u) failed", (unsigned) n); + break; + } + id(rx_total) += n; + if (buf[0] == 0x01 && n >= 2) { + const char cmd = buf[1]; + if (cmd == 'B') { + // one oversized write: the 256 byte TX ring must drop the rest and warn once + static uint8_t big[1000]; + for (size_t i = 0; i < sizeof(big); i++) big[i] = 'A' + (i % 26); + id(cdc1).write_array(big, sizeof(big)); + id(tx_attempted) += sizeof(big); + ESP_LOGI("test", "burst 1000 written"); + } else if (cmd == 'S') { + id(stream_left) = 100; + ESP_LOGI("test", "stream started"); + } else if (cmd == 'L') { + ESP_LOGI("test", "line coding baud=%u data=%u stop=%u parity=%u", (unsigned) id(cdc1).get_baud_rate(), + id(cdc1).get_data_bits(), id(cdc1).get_stop_bits(), (unsigned) id(cdc1).get_parity()); + } else if (cmd == 'K') { + // the host sends "\x01KZ" in one write; the Z must still be in the driver here + uint8_t pb = 0, rb = 0; + bool okp = id(cdc1).peek_byte(&pb); + bool okr = id(cdc1).read_byte(&rb); + ESP_LOGI("test", "peek ok=%d 0x%02X read ok=%d 0x%02X (expect ok=1 0x5A for both)", okp, pb, okr, rb); + if (okr) id(rx_total) += 1; + } else if (cmd == 'F') { + static uint8_t pat[200]; + for (size_t i = 0; i < sizeof(pat); i++) pat[i] = 'a' + (i % 26); + id(cdc1).write_array(pat, sizeof(pat)); + id(tx_attempted) += sizeof(pat); + const uint32_t t0 = millis(); + auto res = id(cdc1).flush(); + ESP_LOGI("test", "flush result=%d took %u ms", (int) res, (unsigned) (millis() - t0)); + } else if (cmd == 'C') { + ESP_LOGI("test", "counters rx=%u tx_attempted=%u (write_array drops silently when the ring is full)", (unsigned) id(rx_total), (unsigned) id(tx_attempted)); + } else { + ESP_LOGW("test", "unknown cmd 0x%02X", cmd); + } + continue; + } + id(cdc1).write_array(buf, n); + id(tx_attempted) += n; + } + - interval: 100ms + then: + - lambda: |- + if (id(stream_left) > 0) { + char line[101]; + int len = snprintf(line, sizeof(line), "STREAM %03d ", 100 - id(stream_left)); + for (int i = len; i < 99; i++) line[i] = 'x'; + line[99] = '\n'; + id(cdc1).write_array(reinterpret_cast(line), 100); + id(tx_attempted) += 100; + id(stream_left) -= 1; + if (id(stream_left) == 0) { + ESP_LOGI("test", "stream done"); + } + } + - interval: 10s + then: + - lambda: |- + ESP_LOGI("test", "alive rx=%u tx_attempted=%u", (unsigned) id(rx_total), (unsigned) id(tx_attempted)); diff --git a/.agents/skills/hardware-test/templates/waveshare-s3-eth-camdiag.yaml b/.agents/skills/hardware-test/templates/waveshare-s3-eth-camdiag.yaml new file mode 100644 index 00000000000..b7c8fc2a16e --- /dev/null +++ b/.agents/skills/hardware-test/templates/waveshare-s3-eth-camdiag.yaml @@ -0,0 +1,83 @@ +esphome: + name: camdiag + on_boot: + priority: -100 + then: + - output.set_level: + id: xclk + level: 50% + - delay: 200ms + - lambda: |- + auto *bus = id(cam_i2c); + auto scan = [&](const char *what) { + int found = 0; + for (uint8_t a = 0x08; a < 0x78; a++) { + if (bus->write(a, nullptr, 0) == i2c::ERROR_OK) { + ESP_LOGI("camdiag", " ACK at 0x%02X", a); + found++; + } + } + ESP_LOGI("camdiag", "scan %s: %d devices", what, found); + }; + auto rd16 = [&](uint16_t reg) { + uint8_t r[2] = {(uint8_t) (reg >> 8), (uint8_t) reg}; + uint8_t v = 0xEE; + auto err = bus->write_readv(0x3C, r, 2, &v, 1); + ESP_LOGI("camdiag", " 0x3C [0x%04X] err=%d -> 0x%02X", reg, (int) err, v); + return v; + }; + auto rd8 = [&](uint8_t addr, uint8_t reg) { + uint8_t v = 0xEE; + auto err = bus->write_readv(addr, ®, 1, &v, 1); + ESP_LOGI("camdiag", " 0x%02X [0x%02X] (8 bit) err=%d -> 0x%02X", addr, reg, (int) err, v); + }; + id(g8).turn_off(); delay(1500); + id(xclk).set_level(0.0f); delay(300); + scan("GPIO8 LOW, XCLK OFF"); + id(xclk).set_level(0.5f); delay(300); + scan("GPIO8 LOW, XCLK ON"); + ESP_LOGI("camdiag", "OV5640 style 16 bit reads (expect 300A=0x56 300B=0x40, 302A=0xB0..B2):"); + rd16(0x300A); rd16(0x300B); rd16(0x302A); rd16(0x3008); rd16(0x3100); rd16(0x3103); rd16(0x4300); + ESP_LOGI("camdiag", "8 bit reads at 0x3C (GC2145 expects F0=0x21 F1=0x45):"); + rd8(0x3C, 0xF0); rd8(0x3C, 0xF1); rd8(0x3C, 0x0A); rd8(0x3C, 0x0B); + ESP_LOGI("camdiag", "write 0x3103=0x11 then read back, then soft reset 0x3008=0x82 and re-read IDs:"); + uint8_t w[3] = {0x31, 0x03, 0x11}; + auto werr = bus->write(0x3C, w, 3); + ESP_LOGI("camdiag", " write err=%d", (int) werr); + rd16(0x3103); + uint8_t rst[3] = {0x30, 0x08, 0x82}; + bus->write(0x3C, rst, 3); delay(100); + rd16(0x300A); rd16(0x300B); + id(g8).turn_on(); delay(500); scan("GPIO8 HIGH (camera off)"); + id(g8).turn_off(); delay(1500); scan("GPIO8 LOW again"); + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + +psram: + mode: octal + speed: 80MHz + +logger: + level: DEBUG + hardware_uart: USB_SERIAL_JTAG + +i2c: + - id: cam_i2c + sda: GPIO48 + scl: GPIO47 + frequency: 100kHz + scan: true + +output: + - platform: gpio + id: g8 + pin: GPIO8 + - platform: ledc + pin: GPIO3 + id: xclk + frequency: 20000000Hz From b53b3968b41538bd11c899a5d1d1693042414a42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:10:02 -1000 Subject: [PATCH 157/179] [light] Build light.toggle with register_apply_action (#20833) --- esphome/components/light/automation.h | 23 ------------------- esphome/components/light/automation.py | 18 +++------------ esphome/components/light/types.py | 1 - .../fixtures/light_toggle_action.yaml | 4 ++-- tests/integration/test_light_toggle_action.py | 6 +---- 5 files changed, 6 insertions(+), 46 deletions(-) diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 2e963479f76..fb4a156f7be 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -8,29 +8,6 @@ namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; -template class ToggleAction final : public Action { - public: - explicit ToggleAction(LightState *state) : state_(state) {} - - template void set_transition_length(V value) requires(HasTransitionLength) { - this->transition_length_ = value; - } - - void play(const Ts &...x) override { - auto call = this->state_->toggle(); - if constexpr (HasTransitionLength) { - call.set_transition_length(this->transition_length_.optional_value(x...)); - } - call.perform(); - } - - protected: - LightState *state_; - struct NoTransition {}; - [[no_unique_address]] std::conditional_t, NoTransition> - transition_length_{}; -}; - template class DimRelativeAction final : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index a616d26571d..df2c4c16445 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -46,7 +46,6 @@ from .types import ( DimRelativeAction, LightEffectCycleAction, LightState, - ToggleAction, ) _LOGGER = logging.getLogger(__name__) @@ -76,9 +75,8 @@ def validate_light_state(value: Any) -> Any: ) from err -@automation.register_action( +automation.register_apply_action( "light.toggle", - ToggleAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(LightState), @@ -87,19 +85,9 @@ def validate_light_state(value: Any) -> Any: ), } ), - synchronous=True, + automation.ApplyField(CONF_TRANSITION_LENGTH, "set_transition_length", cg.uint32), + call="toggle", ) -async def light_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - has_transition_length = CONF_TRANSITION_LENGTH in config - toggle_template_arg = cg.TemplateArguments(has_transition_length, *template_arg) - var = cg.new_Pvariable(action_id, toggle_template_arg, paren) - if has_transition_length: - template_ = await cg.templatable( - config[CONF_TRANSITION_LENGTH], args, cg.uint32 - ) - cg.add(var.set_transition_length(template_)) - return var class LightStateField(NamedTuple): diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 23d1d7e00ea..a6cc2c7efed 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -38,7 +38,6 @@ LimitMode = light_ns.enum("LimitMode", is_class=True) LIMIT_MODES = LimitMode.options("CLAMP", "DO_NOTHING") # Actions -ToggleAction = light_ns.class_("ToggleAction", automation.Action) LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) diff --git a/tests/integration/fixtures/light_toggle_action.yaml b/tests/integration/fixtures/light_toggle_action.yaml index 265d8ba1acb..6044f524234 100644 --- a/tests/integration/fixtures/light_toggle_action.yaml +++ b/tests/integration/fixtures/light_toggle_action.yaml @@ -20,14 +20,14 @@ light: default_transition_length: 0s button: - # Test 1: light.toggle without transition_length (HasTransitionLength=false) + # Test 1: light.toggle without transition_length - platform: template id: btn_toggle name: "Toggle" on_press: - light.toggle: test_light - # Test 2: light.toggle with transition_length (HasTransitionLength=true) + # Test 2: light.toggle with transition_length - platform: template id: btn_toggle_with_trans name: "Toggle With Trans" diff --git a/tests/integration/test_light_toggle_action.py b/tests/integration/test_light_toggle_action.py index ffbadabb5bd..e4dbc98075c 100644 --- a/tests/integration/test_light_toggle_action.py +++ b/tests/integration/test_light_toggle_action.py @@ -1,8 +1,4 @@ -"""Integration test for light::ToggleAction. - -Tests both ToggleAction and -ToggleAction instantiations. -""" +"""Integration test for the light.toggle action, with and without transition_length.""" from __future__ import annotations From 257a636099b88317e4df203e89818f7f9f9444a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:17:24 -1000 Subject: [PATCH 158/179] [rx8025t][rx8130][rx8111][ufm01] Use the shared BCD helpers (#20861) --- esphome/components/rx8025t/rx8025t.cpp | 25 ++---------------------- esphome/components/rx8111/rx8111.cpp | 27 +++----------------------- esphome/components/rx8130/rx8130.cpp | 22 ++------------------- esphome/components/ufm01/ufm01.cpp | 2 +- 4 files changed, 8 insertions(+), 68 deletions(-) diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index f33d39b8d0c..33df13a970e 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -13,9 +13,6 @@ static constexpr uint8_t RX8025T_FLAG_VLF = 0x02; ESPHOME_LOG_TAG(TAG, "rx8025t"); -constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } -constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } - void RX8025TComponent::setup() { uint8_t flags; if (!this->read_flags_(&flags)) { @@ -62,19 +59,7 @@ void RX8025TComponent::read_time() { return; } - ESPTime rtc_time{ - .second = bcd2dec(date[0] & 0x7f), - .minute = bcd2dec(date[1] & 0x7f), - .hour = bcd2dec(date[2] & 0x3f), - .day_of_week = static_cast((date[3] & 0x7f) ? __builtin_ctz(date[3] & 0x7f) + 1 : 1), - .day_of_month = bcd2dec(date[4] & 0x3f), - .day_of_year = 1, - .month = bcd2dec(date[5] & 0x1f), - .year = static_cast(bcd2dec(date[6]) + 2000), - .is_dst = false, - .timestamp = 0, - }; - rtc_time.recalc_timestamp_utc(false); + ESPTime rtc_time = time::decode_bcd_calendar(date); if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; @@ -92,13 +77,7 @@ void RX8025TComponent::write_time() { } uint8_t buff[7]; - buff[0] = dec2bcd(now.second); - buff[1] = dec2bcd(now.minute); - buff[2] = dec2bcd(now.hour); - buff[3] = 1 << (now.day_of_week - 1); - buff[4] = dec2bcd(now.day_of_month); - buff[5] = dec2bcd(now.month); - buff[6] = dec2bcd(now.year % 100); + time::encode_bcd_calendar(now, buff); if (!this->write_bytes(RX8025T_REG_SEC, buff, sizeof(buff))) { ESP_LOGE(TAG, "Can't write I2C data."); this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp index 531c4233e47..1806b0c8fd0 100644 --- a/esphome/components/rx8111/rx8111.cpp +++ b/esphome/components/rx8111/rx8111.cpp @@ -15,9 +15,6 @@ static constexpr uint8_t RX8111_CTRL_STOP = 0x01; // FLAG bits are write-zero-to-clear; keep UF/TF/AF/EVF even if they latch during the write. static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C; -static constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } -static constexpr uint8_t uint8_to_bcd(uint8_t val) { return ((val / 10) << 4) | (val % 10); } - void RX8111Component::setup() { this->read_time(); } void RX8111Component::update() { this->read_time(); } @@ -63,25 +60,13 @@ void RX8111Component::read_time() { this->status_set_warning(LOG_STR("Invalid RTC time")); return; } - const uint8_t weekday = static_cast(__builtin_ctz(onehot) + 1); - ESPTime rtc_time{ - .second = bcd_to_uint8(buf[0]), - .minute = bcd_to_uint8(buf[1]), - .hour = bcd_to_uint8(buf[2]), - .day_of_week = weekday, - .day_of_month = bcd_to_uint8(buf[4]), - .month = bcd_to_uint8(buf[5]), - .year = static_cast(bcd_to_uint8(buf[6]) + 2000), - .is_dst = false, - .timestamp = 0, - }; - rtc_time.recalc_timestamp_utc(false); + ESPTime rtc_time = time::decode_bcd_calendar(buf); if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) { ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock"); this->status_set_warning(LOG_STR("Invalid RTC time")); return; } - if (!this->status_comm_result(ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == weekday, + if (!this->status_comm_result(ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == rtc_time.day_of_week, LOG_STR("Invalid RTC time"))) { ESP_LOGW(TAG, "RTC weekday does not match calendar date"); return; @@ -103,13 +88,7 @@ void RX8111Component::write_time() { } uint8_t buf[7]; - buf[0] = uint8_to_bcd(now.second); - buf[1] = uint8_to_bcd(now.minute); - buf[2] = uint8_to_bcd(now.hour); - buf[3] = 1 << (now.day_of_week - 1); - buf[4] = uint8_to_bcd(now.day_of_month); - buf[5] = uint8_to_bcd(now.month); - buf[6] = uint8_to_bcd(static_cast(now.year - 2000)); + time::encode_bcd_calendar(now, buf); const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf)); // Always try to restart the counter, even after a failed calendar write. const bool restarted = this->set_stop_bit_(false); diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 63153c5db3f..a5b1243c263 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -23,9 +23,6 @@ static const uint8_t RX8130_CLEAR_FLAGS = 0x00; ESPHOME_LOG_TAG(TAG, "rx8130"); -constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } -constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } - void RX8130Component::setup() { // Set digital offset to disabled with no offset if (this->write_register(RX8130_REG_DIG_OFFSET, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) { @@ -70,16 +67,7 @@ void RX8130Component::read_time() { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - ESPTime rtc_time{ - .second = bcd2dec(date[0] & 0x7f), - .minute = bcd2dec(date[1] & 0x7f), - .hour = bcd2dec(date[2] & 0x3f), - .day_of_week = static_cast((date[3] & 0x7f) ? __builtin_ctz(date[3] & 0x7f) + 1 : 1), - .day_of_month = bcd2dec(date[4] & 0x3f), - .month = bcd2dec(date[5] & 0x1f), - .year = static_cast(bcd2dec(date[6]) + 2000), - }; - rtc_time.recalc_timestamp_utc(false); + ESPTime rtc_time = time::decode_bcd_calendar(date); if (!rtc_time.is_valid(/*check_day_of_week=*/true, /*check_day_of_year=*/false)) { ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); return; @@ -96,13 +84,7 @@ void RX8130Component::write_time() { return; } uint8_t buff[7]; - buff[0] = dec2bcd(now.second); - buff[1] = dec2bcd(now.minute); - buff[2] = dec2bcd(now.hour); - buff[3] = 1 << (now.day_of_week - 1); - buff[4] = dec2bcd(now.day_of_month); - buff[5] = dec2bcd(now.month); - buff[6] = dec2bcd(now.year % 100); + time::encode_bcd_calendar(now, buff); this->stop_(true); if (this->write_register(RX8130_REG_SEC, buff, 7) != i2c::ERROR_OK) { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 15d645ce1a9..2d07fa892ac 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -81,7 +81,7 @@ static_assert(SOFTWARE_VERSION_RESPONSE_SIZE <= PASSIVE_FRAME_MAX_SIZE, "softwar static constexpr size_t SOFTWARE_VERSION_CHECKSUM_INDEX = 5; static constexpr size_t SOFTWARE_VERSION_STOP_INDEX = 6; -static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } +static float to_float(uint8_t data) { return bcd_to_uint8(data); } static bool bcd_to_digits(const uint8_t *data, size_t len, char *out) { bool all_zero = true; From 8d6b40f735a0068d5e084244a18dac3d757f7e9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:19:53 -1000 Subject: [PATCH 159/179] [components] Use Component::status_comm_result for communication failures (#20864) --- esphome/components/apds9930/apds9930.cpp | 6 +-- esphome/components/bh1745/bh1745.cpp | 5 +- esphome/components/bq27220/bq27220.cpp | 6 +-- esphome/components/bq76972/bq76972.cpp | 6 +-- .../es9038q2m_katana/es9038q2m_katana.cpp | 9 +--- esphome/components/gdk101/gdk101.cpp | 6 +-- esphome/components/hdc2080/hdc2080.cpp | 7 +-- esphome/components/max17048/max17048.cpp | 6 +-- esphome/components/pca9505/pca9505.cpp | 24 +++------ .../components/pi4ioe5v6408/pi4ioe5v6408.cpp | 18 +++---- esphome/components/rx8025t/rx8025t.cpp | 6 +-- esphome/components/rx8111/rx8111.cpp | 5 +- esphome/components/shtcx/shtcx.cpp | 3 +- .../spd2010/spd2010_touchscreen.cpp | 7 +-- esphome/components/tas58xx/tas58xx.cpp | 8 ++- esphome/components/tca9555/tca9555.cpp | 29 +++-------- .../components/tof10120/tof10120_sensor.cpp | 6 +-- .../waveshare_io_ch32v003.cpp | 50 +++++-------------- 18 files changed, 53 insertions(+), 154 deletions(-) diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp index eec741d5938..3df24a47537 100644 --- a/esphome/components/apds9930/apds9930.cpp +++ b/esphome/components/apds9930/apds9930.cpp @@ -104,11 +104,9 @@ void APDS9930Component::dump_config() { void APDS9930Component::update() { // STATUS, CH0DATAL/H, CH1DATAL/H, PDATAL/H uint8_t raw[7]; - if (!this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw))) { - this->status_set_warning(LOG_STR("Reading data failed")); + if (!this->status_comm_result(this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw)), + LOG_STR("Reading data failed"))) return; - } - this->status_clear_warning(); const uint8_t status = raw[0]; if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) { diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp index c34c2493310..bc89a17351e 100644 --- a/esphome/components/bh1745/bh1745.cpp +++ b/esphome/components/bh1745/bh1745.cpp @@ -134,11 +134,8 @@ void BH1745Component::update() { return; uint16_t buffer[BH1745_CHANNELS]; - if (!this->read_bytes_16_le(REG_RED_DATA_LSB, buffer, BH1745_CHANNELS)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->status_comm_result(this->read_bytes_16_le(REG_RED_DATA_LSB, buffer, BH1745_CHANNELS))) return; - } - this->status_clear_warning(); const uint16_t red = buffer[0]; const uint16_t green = buffer[1]; diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp index 3c78893437d..a7caca62233 100644 --- a/esphome/components/bq27220/bq27220.cpp +++ b/esphome/components/bq27220/bq27220.cpp @@ -57,11 +57,7 @@ void BQ27220Component::update() { [](uint16_t raw) { return raw == TIME_TO_EMPTY_NOT_APPLICABLE ? NAN : float(raw); }); this->publish_(this->state_of_health_sensor_, REG_STATE_OF_HEALTH, ok, [](uint16_t raw) { return float(raw); }); - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(LOG_STR("Failed to read one or more registers from BQ27220")); - } + this->status_comm_result(ok, LOG_STR("Failed to read one or more registers from BQ27220")); } void BQ27220Component::dump_config() { diff --git a/esphome/components/bq76972/bq76972.cpp b/esphome/components/bq76972/bq76972.cpp index 59d18878370..c27b56dc50d 100644 --- a/esphome/components/bq76972/bq76972.cpp +++ b/esphome/components/bq76972/bq76972.cpp @@ -292,11 +292,7 @@ void BQ76972Component::update() { ok = false; } } - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - } + this->status_comm_result(ok); } #else void BQ76972Component::update() {} diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp index e3cd3820d7e..3eb1ad27253 100644 --- a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -119,14 +119,7 @@ bool ES9038Q2MKatana::write_volume_() { } // Records the outcome of a runtime write; the cached state only changes on success. -bool ES9038Q2MKatana::check_write_(bool ok) { - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - } - return ok; -} +bool ES9038Q2MKatana::check_write_(bool ok) { return this->status_comm_result(ok); } bool ES9038Q2MKatana::set_volume(float volume) { if (this->is_failed()) diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index a84d5ba06fb..222ae5b349d 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -30,11 +30,7 @@ void GDK101Component::update() { return; } - if (!this->read_measurement_duration_(data)) { - this->status_set_warning(LOG_STR("Failed to read measurement duration")); - return; - } - this->status_clear_warning(); + this->status_comm_result(this->read_measurement_duration_(data), LOG_STR("Failed to read measurement duration")); } void GDK101Component::setup() { diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index 8672dc497f5..3386fe4d540 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -44,17 +44,14 @@ void HDC2080Component::update() { data = MEAS_TRIG | MEAS_CONF_HUM; } if (this->write_register(REG_MEASUREMENT_CONFIGURATION, &data, 1) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } // wait for conversion to complete 2ms should be enough, more is fine this->set_timeout(5, [this]() { uint8_t raw_data[4]; - if (this->read_register(REG_TEMPERATURE_LOW, raw_data, 4) != i2c::ERROR_OK) { - this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + if (!this->status_comm_result(this->read_register(REG_TEMPERATURE_LOW, raw_data, 4) == i2c::ERROR_OK)) return; - } - this->status_clear_warning(); if (this->temperature_sensor_ != nullptr) { float temp = encode_value_le(raw_data) * (165.0f / 65536.0f) - 40.5f; this->temperature_sensor_->publish_state(temp); diff --git a/esphome/components/max17048/max17048.cpp b/esphome/components/max17048/max17048.cpp index 1caf8ab308b..6160506484d 100644 --- a/esphome/components/max17048/max17048.cpp +++ b/esphome/components/max17048/max17048.cpp @@ -55,11 +55,7 @@ void MAX17048Component::update() { ok = false; } } - if (ok) { - this->status_clear_warning(); - } else { - this->status_set_warning(LOG_STR("Register read failed")); - } + this->status_comm_result(ok, LOG_STR("Register read failed")); } void MAX17048Component::dump_config() { diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp index 6ac50d3b1bc..15c26a078f1 100644 --- a/esphome/components/pca9505/pca9505.cpp +++ b/esphome/components/pca9505/pca9505.cpp @@ -109,24 +109,16 @@ bool PCA9505Component::read_bank_(uint8_t bank) { if (this->is_failed()) return false; - if (this->read_register(INPUT_REG + bank, &this->input_[bank], 1) != i2c::ERROR_OK) { - this->status_set_warning(LOG_STR("Failed to read input register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->read_register(INPUT_REG + bank, &this->input_[bank], 1) == i2c::ERROR_OK, + LOG_STR("Failed to read input register")); } bool PCA9505Component::write_bank_(uint8_t reg, uint8_t bank, uint8_t value) { if (this->is_failed()) return false; - if (this->write_register(reg + bank, &value, 1) != i2c::ERROR_OK) { - this->status_set_warning(LOG_STR("Failed to write register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_register(reg + bank, &value, 1) == i2c::ERROR_OK, + LOG_STR("Failed to write register")); } bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { @@ -137,12 +129,8 @@ bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { for (uint8_t &bank : values) { bank = value; } - if (this->write_register(reg | AUTO_INCREMENT, values, sizeof(values)) != i2c::ERROR_OK) { - this->status_set_warning(LOG_STR("Failed to write register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_register(reg | AUTO_INCREMENT, values, sizeof(values)) == i2c::ERROR_OK, + LOG_STR("Failed to write register")); } float PCA9505Component::get_setup_priority() const { return setup_priority::IO; } diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index 28f98325ea4..e6a423de7cf 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -90,13 +90,11 @@ bool PI4IOE5V6408Component::read_gpio_outputs_() { if (this->is_failed()) return false; - uint8_t data; - if (!this->read_byte(PI4IOE5V6408_REGISTER_OUT_SET, &data)) { - this->status_set_warning(LOG_STR("Failed to read output register")); + uint8_t data{0}; + if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_OUT_SET, &data), + LOG_STR("Failed to read output register"))) return false; - } this->output_mask_ = data; - this->status_clear_warning(); return true; } @@ -104,7 +102,7 @@ bool PI4IOE5V6408Component::read_gpio_modes_() { if (this->is_failed()) return false; - uint8_t data; + uint8_t data{0}; if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_IO_DIR, &data), LOG_STR("Failed to read GPIO modes"))) return false; @@ -119,13 +117,11 @@ bool PI4IOE5V6408Component::digital_read_hw(uint8_t pin) { if (this->is_failed()) return false; - uint8_t data; - if (!this->read_byte(PI4IOE5V6408_REGISTER_IN_STATE, &data)) { - this->status_set_warning(LOG_STR("Failed to read GPIO state")); + uint8_t data{0}; + if (!this->status_comm_result(this->read_byte(PI4IOE5V6408_REGISTER_IN_STATE, &data), + LOG_STR("Failed to read GPIO state"))) return false; - } this->input_mask_ = data; - this->status_clear_warning(); return true; } diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index 33df13a970e..4d7e9b4e694 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -92,11 +92,7 @@ void RX8025TComponent::write_time() { return; } flags &= ~(RX8025T_FLAG_VLF | RX8025T_FLAG_VDET); - if (!this->write_byte(RX8025T_REG_FLAG, flags)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return; - } - this->status_clear_warning(); + this->status_comm_result(this->write_byte(RX8025T_REG_FLAG, flags)); } } // namespace esphome::rx8025t diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp index 1806b0c8fd0..62923482c7e 100644 --- a/esphome/components/rx8111/rx8111.cpp +++ b/esphome/components/rx8111/rx8111.cpp @@ -96,11 +96,8 @@ void RX8111Component::write_time() { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->status_comm_result(this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS))) return; - } - this->status_clear_warning(); ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, now.minute, now.second); } diff --git a/esphome/components/shtcx/shtcx.cpp b/esphome/components/shtcx/shtcx.cpp index 814e09162ec..7ce343a3a9e 100644 --- a/esphome/components/shtcx/shtcx.cpp +++ b/esphome/components/shtcx/shtcx.cpp @@ -81,8 +81,7 @@ void SHTCXComponent::update() { float humidity = NAN; uint16_t raw_data[2]; if (!this->read_data(raw_data, 2)) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } else { temperature = 175.0f * float(raw_data[0]) / 65536.0f - 45.0f; humidity = 100.0f * float(raw_data[1]) / 65536.0f; diff --git a/esphome/components/spd2010/spd2010_touchscreen.cpp b/esphome/components/spd2010/spd2010_touchscreen.cpp index b70fe1acad9..db0fb1f510b 100644 --- a/esphome/components/spd2010/spd2010_touchscreen.cpp +++ b/esphome/components/spd2010/spd2010_touchscreen.cpp @@ -139,12 +139,7 @@ bool SPD2010Touchscreen::read_data_() { return false; } if ((status_high & STATUS_RUNNING) && length == 0) { - if (!this->clear_interrupt_()) { - this->status_set_warning(LOG_STR("Failed to clear interrupt")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->clear_interrupt_(), LOG_STR("Failed to clear interrupt")); } if (!(status_low & (STATUS_POINT | STATUS_GESTURE))) { const bool needs_clear = (status_high & STATUS_RUNNING) && (status_low & STATUS_AUX); diff --git a/esphome/components/tas58xx/tas58xx.cpp b/esphome/components/tas58xx/tas58xx.cpp index 3addbfda00d..28d0ce22bdb 100644 --- a/esphome/components/tas58xx/tas58xx.cpp +++ b/esphome/components/tas58xx/tas58xx.cpp @@ -222,12 +222,10 @@ void TAS58xx::on_audio_started() { } void TAS58xx::update() { - uint8_t power_state; - if (!this->read_faults_() || !this->read_byte(TAS58XX_POWER_STATE, &power_state)) { - this->status_set_warning(LOG_STR("Failed to read status")); + uint8_t power_state{0}; + if (!this->status_comm_result(this->read_faults_() && this->read_byte(TAS58XX_POWER_STATE, &power_state), + LOG_STR("Failed to read status"))) return; - } - this->status_clear_warning(); power_state &= TAS58XX_CTRL_STATE_MASK; if (power_state != this->power_state_) { diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 15272e614fb..448836e6609 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -65,12 +65,10 @@ bool TCA9555Component::read_gpio_outputs_() { if (this->is_failed()) return false; uint16_t value; - if (!this->read_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, &value)) { - this->status_set_warning(LOG_STR("Failed to read output register")); + if (!this->status_comm_result(this->read_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, &value), + LOG_STR("Failed to read output register"))) return false; - } this->output_mask_ = value; - this->status_clear_warning(); return true; } @@ -78,13 +76,10 @@ bool TCA9555Component::read_gpio_modes_() { if (this->is_failed()) return false; uint16_t value; - if (!this->read_byte_16_le(TCA9555_CONFIGURATION_PORT_0, &value)) { - this->status_set_warning(LOG_STR("Failed to read mode register")); + if (!this->status_comm_result(this->read_byte_16_le(TCA9555_CONFIGURATION_PORT_0, &value), + LOG_STR("Failed to read mode register"))) return false; - } this->mode_mask_ = value; - - this->status_clear_warning(); return true; } bool TCA9555Component::digital_read_hw(uint8_t pin) { @@ -119,23 +114,15 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) { this->output_mask_ &= ~(1 << pin); } - if (!this->write_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, this->output_mask_)) { - this->status_set_warning(LOG_STR("Failed to write output register")); - return; - } - - this->status_clear_warning(); + this->status_comm_result(this->write_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, this->output_mask_), + LOG_STR("Failed to write output register")); } bool TCA9555Component::write_gpio_modes_() { if (this->is_failed()) return false; - if (!this->write_byte_16_le(TCA9555_CONFIGURATION_PORT_0, this->mode_mask_)) { - this->status_set_warning(LOG_STR("Failed to write mode register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_byte_16_le(TCA9555_CONFIGURATION_PORT_0, this->mode_mask_), + LOG_STR("Failed to write mode register")); } bool TCA9555Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } diff --git a/esphome/components/tof10120/tof10120_sensor.cpp b/esphome/components/tof10120/tof10120_sensor.cpp index 3943b37150c..76a061cadf7 100644 --- a/esphome/components/tof10120/tof10120_sensor.cpp +++ b/esphome/components/tof10120/tof10120_sensor.cpp @@ -26,8 +26,7 @@ void TOF10120Sensor::setup() {} void TOF10120Sensor::update() { if (!this->write_bytes(TOF10120_DISTANCE_REGISTER, TOF10120_READ_DISTANCE_CMD, sizeof(TOF10120_READ_DISTANCE_CMD))) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } @@ -38,8 +37,7 @@ void TOF10120Sensor::update() { } delay(TOF10120_DEFAULT_DELAY); if (this->read(data, 2) != i2c::ERROR_OK) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->status_set_warning(); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 5e16004dc2c..9ff7eb7f382 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -52,11 +52,9 @@ uint16_t WaveshareIOCH32V003Component::get_adc_value() { return 0; uint16_t adc_value; - if (!this->read_byte_16_le(IO_EXTENSION_ADC_ADDR, &adc_value)) { - this->status_set_warning(LOG_STR("Failed to read ADC register")); + if (!this->status_comm_result(this->read_byte_16_le(IO_EXTENSION_ADC_ADDR, &adc_value), + LOG_STR("Failed to read ADC register"))) return 0; - } - this->status_clear_warning(); return adc_value; } @@ -65,11 +63,9 @@ uint8_t WaveshareIOCH32V003Component::get_rtc_interrupt_status() { return 0; uint8_t data = 0; - if (!this->read_bytes(IO_EXTENSION_RTC_INT_ADDR, &data, 1)) { - this->status_set_warning(LOG_STR("Failed to read RTC interrupt register")); + if (!this->status_comm_result(this->read_bytes(IO_EXTENSION_RTC_INT_ADDR, &data, 1), + LOG_STR("Failed to read RTC interrupt register"))) return 0; - } - this->status_clear_warning(); return data; } @@ -81,34 +77,21 @@ void WaveshareIOCH32V003Component::set_pwm_value(uint8_t value) { // based on circuit schematic requirements. This follows the pattern from the // original Waveshare IO library function "void IO_EXTENSION_Pwm_Output(uint8_t Value)". - if (!this->write_byte(IO_EXTENSION_PWM_ADDR, value)) { - this->status_set_warning(LOG_STR("Failed to set PWM duty cycle")); - return; - } - - this->status_clear_warning(); + this->status_comm_result(this->write_byte(IO_EXTENSION_PWM_ADDR, value), LOG_STR("Failed to set PWM duty cycle")); } bool WaveshareIOCH32V003Component::write_gpio_modes_() { if (this->is_failed()) return false; - if (!this->write_byte(IO_EXTENSION_DIRECTION, this->mode_mask_)) { - this->status_set_warning(LOG_STR("Failed to write mode register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_byte(IO_EXTENSION_DIRECTION, this->mode_mask_), + LOG_STR("Failed to write mode register")); } bool WaveshareIOCH32V003Component::write_gpio_outputs_() { if (this->is_failed()) return false; - if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, this->output_mask_)) { - this->status_set_warning(LOG_STR("Failed to write output register")); - return false; - } - this->status_clear_warning(); - return true; + return this->status_comm_result(this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, this->output_mask_), + LOG_STR("Failed to write output register")); } bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { @@ -116,13 +99,10 @@ bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { return false; uint8_t data = 0; - if (!this->read_bytes(IO_EXTENSION_IO_INPUT_ADDR, &data, 1)) { - this->status_set_warning(LOG_STR("Failed to read input register")); + if (!this->status_comm_result(this->read_bytes(IO_EXTENSION_IO_INPUT_ADDR, &data, 1), + LOG_STR("Failed to read input register"))) return false; - } this->input_mask_ = data; - - this->status_clear_warning(); return true; } @@ -137,12 +117,8 @@ void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) { } uint8_t data = this->output_mask_; - if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, data)) { - this->status_set_warning(LOG_STR("Failed to write output register")); - return; - } - - this->status_clear_warning(); + this->status_comm_result(this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, data), + LOG_STR("Failed to write output register")); } bool WaveshareIOCH32V003Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } From 52390ebbe41b9964ed0c30c43f152feff93ea978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:22:23 -1000 Subject: [PATCH 160/179] [ethernet] Build the ethernet automations with the apply helpers (#20845) --- esphome/components/ethernet/__init__.py | 34 +++++++++++++----------- esphome/components/ethernet/automation.h | 30 --------------------- 2 files changed, 18 insertions(+), 46 deletions(-) delete mode 100644 esphome/components/ethernet/automation.h diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 1a50dbebfc3..a4b18942026 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -1,7 +1,6 @@ from dataclasses import dataclass from esphome import automation, pins -from esphome.automation import Condition import esphome.codegen as cg from esphome.components import spi from esphome.components.network import ( @@ -238,10 +237,6 @@ MANUAL_IP_SCHEMA = cv.Schema( EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) ManualIP = ethernet_ns.struct("ManualIP") -EthernetConnectedCondition = ethernet_ns.class_("EthernetConnectedCondition", Condition) -EthernetEnabledCondition = ethernet_ns.class_("EthernetEnabledCondition", Condition) -EthernetEnableAction = ethernet_ns.class_("EthernetEnableAction", automation.Action) -EthernetDisableAction = ethernet_ns.class_("EthernetDisableAction", automation.Action) def _is_framework_spi_polling_mode_supported() -> bool: @@ -932,15 +927,22 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files -automation.register_bare_condition( - "ethernet.connected", EthernetConnectedCondition, cv.Schema({}) -) -automation.register_bare_condition( - "ethernet.enabled", EthernetEnabledCondition, cv.Schema({}) -) -automation.register_bare_action( - "ethernet.enable", EthernetEnableAction, cv.Schema({}), synchronous=True -) -automation.register_bare_action( - "ethernet.disable", EthernetDisableAction, cv.Schema({}), synchronous=True +ETHERNET_AUTOMATION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(EthernetComponent), + } ) + +for _name, _check in ( + ("ethernet.connected", "is_connected()"), + ("ethernet.enabled", "is_enabled()"), +): + automation.register_apply_condition(_name, ETHERNET_AUTOMATION_SCHEMA, _check) + +for _name, _call in ( + ("ethernet.enable", "enable()"), + ("ethernet.disable", "disable()"), +): + automation.register_apply_action( + _name, ETHERNET_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h deleted file mode 100644 index f975f52bffd..00000000000 --- a/esphome/components/ethernet/automation.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" -#ifdef USE_ETHERNET -#include "ethernet_component.h" - -namespace esphome::ethernet { - -template class EthernetConnectedCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_eth_component->is_connected(); } -}; - -template class EthernetEnabledCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } -}; - -template class EthernetEnableAction final : public Action { - public: - void play(const Ts &...x) override { global_eth_component->enable(); } -}; - -template class EthernetDisableAction final : public Action { - public: - void play(const Ts &...x) override { global_eth_component->disable(); } -}; - -} // namespace esphome::ethernet -#endif From ab656acbf1c6989a9e97761560d5ad35ef26ff6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:23:19 -1000 Subject: [PATCH 161/179] [api] Build api.connected with register_apply_condition (#20836) --- esphome/components/api/__init__.py | 20 ++++++-------------- esphome/components/api/api_server.h | 14 +++----------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 44d8b443676..dcca7ed1494 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -4,7 +4,6 @@ import re from typing import Any from esphome import automation -from esphome.automation import Condition import esphome.codegen as cg from esphome.components.const import CONF_DESCRIPTION, CONF_HOST from esphome.components.logger import request_log_listener @@ -99,7 +98,6 @@ ActionResponse = api_ns.class_("ActionResponse") HomeAssistantActionResponseTrigger = api_ns.class_( "HomeAssistantActionResponseTrigger", automation.Trigger ) -APIConnectedCondition = api_ns.class_("APIConnectedCondition", Condition) APIRespondAction = api_ns.class_("APIRespondAction", automation.Action) APIUnregisterServiceCallAction = api_ns.class_( "APIUnregisterServiceCallAction", automation.Action @@ -1057,19 +1055,13 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema( ) -@automation.register_condition( - "api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA +automation.register_apply_condition( + "api.connected", + API_CONNECTED_CONDITION_SCHEMA, + automation.ApplyCall( + "is_connected({})", ((CONF_STATE_SUBSCRIPTION_ONLY, cg.bool_),) + ), ) -async def api_connected_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(condition_id, template_arg) - templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_) - cg.add(var.set_state_subscription_only(templ)) - return var # user_services.cpp is only needed when user defined actions exist; the diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 09b08de4e3f..52310747fcf 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -213,6 +213,9 @@ class APIServer final : public Component bool is_connected() const { return this->api_connection_count_ != 0; } bool is_connected_with_state_subscription() const; + bool is_connected(bool state_subscription_only) const { + return state_subscription_only ? this->is_connected_with_state_subscription() : this->is_connected(); + } // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect // to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the @@ -407,16 +410,5 @@ class APIServer final : public Component extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class APIConnectedCondition final : public Condition { - TEMPLATABLE_VALUE(bool, state_subscription_only) - public: - bool check(const Ts &...x) override { - if (this->state_subscription_only_.value(x...)) { - return global_api_server->is_connected_with_state_subscription(); - } - return global_api_server->is_connected(); - } -}; - } // namespace esphome::api #endif From ded5ad6cb133fea0718d950feb9f748f1cdb059a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 15:23:31 -1000 Subject: [PATCH 162/179] [wifi] Build the wifi actions and conditions with the apply helpers (#20842) --- esphome/components/wifi/__init__.py | 65 ++++++++-------------------- esphome/components/wifi/automation.h | 30 ------------- 2 files changed, 18 insertions(+), 77 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5660b1189b8..886b7ef9990 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -3,7 +3,6 @@ import math from typing import Any from esphome import automation, preferences -from esphome.automation import Condition import esphome.codegen as cg from esphome.components.const import CONF_ENABLED, CONF_USE_PSRAM from esphome.components.esp32 import ( @@ -241,12 +240,6 @@ def _validate_dns_servers(value: list) -> list: return value -WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) -WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) -WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) -WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action) -WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action) -WiFiRoamAction = wifi_ns.class_("WiFiRoamAction", automation.Action) WiFiConfigureAction = wifi_ns.class_( "WiFiConfigureAction", automation.Action, cg.Component ) @@ -1042,49 +1035,27 @@ async def to_code(config): CORE.add_job(final_step) -automation.register_bare_condition( - "wifi.connected", - WiFiConnectedCondition, - cv.Schema({}), +WIFI_AUTOMATION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(WiFiComponent), + } ) +for _name, _check in ( + ("wifi.connected", "is_connected()"), + ("wifi.enabled", "is_disabled() == false"), + ("wifi.ap_active", "is_ap_active()"), +): + automation.register_apply_condition(_name, WIFI_AUTOMATION_SCHEMA, _check) -automation.register_bare_condition( - "wifi.enabled", - WiFiEnabledCondition, - cv.Schema({}), -) - - -automation.register_bare_condition( - "wifi.ap_active", - WiFiAPActiveCondition, - cv.Schema({}), -) - - -automation.register_bare_action( - "wifi.enable", - WiFiEnableAction, - cv.Schema({}), - synchronous=True, -) - - -automation.register_bare_action( - "wifi.disable", - WiFiDisableAction, - cv.Schema({}), - synchronous=True, -) - - -automation.register_bare_action( - "wifi.roam", - WiFiRoamAction, - cv.Schema({}), - synchronous=True, -) +for _name, _call in ( + ("wifi.enable", "enable()"), + ("wifi.disable", "disable()"), + ("wifi.roam", "force_roam_check()"), +): + automation.register_apply_action( + _name, WIFI_AUTOMATION_SCHEMA, automation.ApplyCall(_call) + ) KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index eb3d7471dc1..052fb11661d 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -6,36 +6,6 @@ namespace esphome::wifi { -template class WiFiConnectedCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_wifi_component->is_connected(); } -}; - -template class WiFiEnabledCondition final : public Condition { - public: - bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } -}; - -template class WiFiAPActiveCondition final : public Condition { - public: - bool check(const Ts &...x) override { return global_wifi_component->is_ap_active(); } -}; - -template class WiFiEnableAction final : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->enable(); } -}; - -template class WiFiDisableAction final : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->disable(); } -}; - -template class WiFiRoamAction final : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->force_roam_check(); } -}; - template class WiFiConfigureAction final : public Action, public Component { public: TEMPLATABLE_VALUE(std::string, ssid) From 28e43235fab54be9bc3c9ed801a360291cd1eb3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Oct 2026 16:27:01 -1000 Subject: [PATCH 163/179] [gpio_expander][pcf8574][mpr121][ch422g][ch423][pcm5122][weikai] Move the GPIO expanders and pin providers onto the shared pin helpers (#20868) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/ch422g/__init__.py | 15 +++---- esphome/components/ch422g/ch422g.cpp | 14 +----- esphome/components/ch422g/ch422g.h | 25 ++--------- esphome/components/ch423/__init__.py | 15 +++---- esphome/components/ch423/ch423.cpp | 14 +----- esphome/components/ch423/ch423.h | 25 ++--------- esphome/components/max6956/__init__.py | 34 ++------------ esphome/components/max6956/max6956.cpp | 13 +----- esphome/components/max6956/max6956.h | 25 ++--------- esphome/components/mcp23016/__init__.py | 31 ++----------- esphome/components/mcp23016/mcp23016.cpp | 10 +---- esphome/components/mcp23016/mcp23016.h | 25 ++--------- esphome/components/mcp23xxx_base/__init__.py | 28 ++---------- esphome/components/mpr121/__init__.py | 26 +++-------- esphome/components/mpr121/mpr121.cpp | 31 +++---------- esphome/components/mpr121/mpr121.h | 33 ++++---------- esphome/components/pca6416a/__init__.py | 42 +++-------------- esphome/components/pca6416a/pca6416a.cpp | 10 +---- esphome/components/pca6416a/pca6416a.h | 26 ++--------- esphome/components/pca9505/__init__.py | 28 ++---------- esphome/components/pca9505/pca9505.cpp | 14 +----- esphome/components/pca9505/pca9505.h | 23 ++-------- esphome/components/pca9554/__init__.py | 23 +--------- esphome/components/pca9554/pca9554.cpp | 10 +---- esphome/components/pca9554/pca9554.h | 29 ++---------- esphome/components/pcf8574/__init__.py | 28 ++++-------- esphome/components/pcf8574/pcf8574.cpp | 15 +------ esphome/components/pcf8574/pcf8574.h | 30 ++----------- esphome/components/pcm5122/audio_dac.py | 20 +-------- esphome/components/pi4ioe5v6408/__init__.py | 21 +-------- .../components/pi4ioe5v6408/pi4ioe5v6408.cpp | 12 +---- .../components/pi4ioe5v6408/pi4ioe5v6408.h | 23 ++-------- esphome/components/sx1509/__init__.py | 41 ++++------------- esphome/components/sx1509/sx1509.cpp | 2 + esphome/components/sx1509/sx1509.h | 6 ++- esphome/components/sx1509/sx1509_gpio_pin.cpp | 18 -------- esphome/components/sx1509/sx1509_gpio_pin.h | 31 ------------- esphome/components/tca9555/__init__.py | 28 ++---------- esphome/components/tca9555/tca9555.cpp | 10 +---- esphome/components/tca9555/tca9555.h | 24 ++-------- .../waveshare_io_ch32v003/__init__.py | 30 ++----------- .../waveshare_io_ch32v003.cpp | 14 +----- .../waveshare_io_ch32v003.h | 26 ++--------- esphome/components/weikai/__init__.py | 6 +-- esphome/components/wk2168_i2c/__init__.py | 11 +---- esphome/components/wk2168_spi/__init__.py | 11 +---- esphome/components/wk2212_i2c/__init__.py | 11 +---- esphome/components/wk2212_spi/__init__.py | 11 +---- esphome/components/xl9535/__init__.py | 45 ++++--------------- esphome/components/xl9535/xl9535.cpp | 12 +---- esphome/components/xl9535/xl9535.h | 26 ++--------- tests/components/pcf8574/test_pcf8574.cpp | 6 +-- tests/unit_tests/components/test_pcf8574.py | 39 +++++++++++++++- 53 files changed, 220 insertions(+), 906 deletions(-) delete mode 100644 esphome/components/sx1509/sx1509_gpio_pin.cpp delete mode 100644 esphome/components/sx1509/sx1509_gpio_pin.h diff --git a/esphome/components/ch422g/__init__.py b/esphome/components/ch422g/__init__.py index 60801e0c8dd..107a7bb871d 100644 --- a/esphome/components/ch422g/__init__.py +++ b/esphome/components/ch422g/__init__.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_I2C_ID, CONF_ID, CONF_INPUT, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OPEN_DRAIN, @@ -18,6 +17,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesterret", "@clydebarrow"] DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["gpio_expander"] DOMAIN = "ch422g" MULTI_CONF = True ch422g_ns = cg.esphome_ns.namespace("ch422g") @@ -66,13 +66,10 @@ CH422G_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_CH422G, CH422G_PIN_SCHEMA, pin_mode_check) async def ch422g_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) + var = await pins.new_parented_pin(config, CONF_CH422G) + # The parent collects every pin's mode before its setup() writes the mode register parent = await cg.get_variable(config[CONF_CH422G]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + cg.add( + parent.pin_mode(config[CONF_NUMBER], pins.gpio_flags_expr(config[CONF_MODE])) + ) return var diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index e76801ff77a..b6496ffa2c7 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -120,16 +120,6 @@ bool CH422GComponent::write_outputs_() { float CH422GComponent::get_setup_priority() const { return setup_priority::IO; } -void CH422GGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool CH422GGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } - -void CH422GGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value ^ this->inverted_); } -size_t CH422GGPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "EXIO%u via CH422G", this->pin_); -} -void CH422GGPIOPin::set_flags(gpio::Flags flags) { - flags_ = flags; - this->parent_->pin_mode(this->pin_, flags); -} - } // namespace esphome::ch422g + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::ch422g::CH422GComponent, "EXIO%u via CH422G") diff --git a/esphome/components/ch422g/ch422g.h b/esphome/components/ch422g/ch422g.h index a8729225e80..53b0d035e1a 100644 --- a/esphome/components/ch422g/ch422g.h +++ b/esphome/components/ch422g/ch422g.h @@ -1,5 +1,6 @@ #pragma once +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" @@ -42,26 +43,8 @@ class CH422GComponent final : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH422G pin as a GPIO pin. -class CH422GGPIOPin final : public GPIOPin { - public: - void setup() override{}; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(CH422GComponent *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags); - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - CH422GComponent *parent_{}; - uint8_t pin_{}; - bool inverted_{}; - gpio::Flags flags_{}; -}; +using CH422GGPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::ch422g + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::ch422g::CH422GComponent); diff --git a/esphome/components/ch423/__init__.py b/esphome/components/ch423/__init__.py index 42dc8bb2841..2e51d18d075 100644 --- a/esphome/components/ch423/__init__.py +++ b/esphome/components/ch423/__init__.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_I2C_ID, CONF_ID, CONF_INPUT, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OPEN_DRAIN, @@ -19,6 +18,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@dwmw2"] DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["gpio_expander"] DOMAIN = "ch423" MULTI_CONF = True ch423_ns = cg.esphome_ns.namespace("ch423") @@ -93,13 +93,10 @@ CH423_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_CH423, CH423_PIN_SCHEMA, pin_mode_check) async def ch423_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) + var = await pins.new_parented_pin(config, CONF_CH423) + # The parent collects every pin's mode before its setup() writes the mode register parent = await cg.get_variable(config[CONF_CH423]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + cg.add( + parent.pin_mode(config[CONF_NUMBER], pins.gpio_flags_expr(config[CONF_MODE])) + ) return var diff --git a/esphome/components/ch423/ch423.cpp b/esphome/components/ch423/ch423.cpp index 3a57f646d4e..2ad859f9b37 100644 --- a/esphome/components/ch423/ch423.cpp +++ b/esphome/components/ch423/ch423.cpp @@ -123,16 +123,6 @@ bool CH423Component::write_outputs_() { float CH423Component::get_setup_priority() const { return setup_priority::IO; } -void CH423GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool CH423GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } - -void CH423GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value ^ this->inverted_); } -size_t CH423GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "EXIO%u via CH423", this->pin_); -} -void CH423GPIOPin::set_flags(gpio::Flags flags) { - flags_ = flags; - this->parent_->pin_mode(this->pin_, flags); -} - } // namespace esphome::ch423 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::ch423::CH423Component, "EXIO%u via CH423") diff --git a/esphome/components/ch423/ch423.h b/esphome/components/ch423/ch423.h index fbfffb521d2..38fcb1ed495 100644 --- a/esphome/components/ch423/ch423.h +++ b/esphome/components/ch423/ch423.h @@ -1,5 +1,6 @@ #pragma once +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" @@ -41,26 +42,8 @@ class CH423Component final : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH423 pin as a GPIO pin. -class CH423GPIOPin final : public GPIOPin { - public: - void setup() override{}; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(CH423Component *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags); - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - CH423Component *parent_{}; - uint8_t pin_{}; - bool inverted_{}; - gpio::Flags flags_{}; -}; +using CH423GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::ch423 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::ch423::CH423Component); diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 6dd9dc0b09f..c2e5f71d4f5 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -2,21 +2,14 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, - CONF_PULLUP, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_OUTPUT, CONF_PULLUP from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@looping40"] DOMAIN = "max6956" +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -56,23 +49,13 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_brightness_global(config[CONF_BRIGHTNESS_GLOBAL])) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") - return value - - CONF_MAX6956 = "max6956" MAX6956_PIN_SCHEMA = pins.gpio_base_schema( MAX6956GPIOPin, cv.int_range(min=4, max=31), modes=[CONF_INPUT, CONF_PULLUP, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output_pullup, ).extend( { cv.Required(CONF_MAX6956): pins.use_id_or_address(MAX6956), @@ -82,16 +65,7 @@ MAX6956_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MAX6956, MAX6956_PIN_SCHEMA) async def max6956_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_MAX6956]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_MAX6956) automation.register_apply_action( diff --git a/esphome/components/max6956/max6956.cpp b/esphome/components/max6956/max6956.cpp index 7060a8c76b7..05814dcc46d 100644 --- a/esphome/components/max6956/max6956.cpp +++ b/esphome/components/max6956/max6956.cpp @@ -155,15 +155,6 @@ void MAX6956::dump_config() { } } -/************************************** - * MAX6956GPIOPin * - **************************************/ -void MAX6956GPIOPin::setup() { pin_mode(flags_); } -void MAX6956GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool MAX6956GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void MAX6956GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t MAX6956GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via Max6956", this->pin_); -} - } // namespace esphome::max6956 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::max6956::MAX6956, "%u via Max6956") diff --git a/esphome/components/max6956/max6956.h b/esphome/components/max6956/max6956.h index 4dbee165284..956a2bdbcfc 100644 --- a/esphome/components/max6956/max6956.h +++ b/esphome/components/max6956/max6956.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::max6956 { @@ -69,26 +70,8 @@ class MAX6956 final : public Component, public i2c::I2CDevice { int8_t prev_bright_[28] = {0}; }; -class MAX6956GPIOPin final : public GPIOPin { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(MAX6956 *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - MAX6956 *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using MAX6956GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::max6956 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::max6956::MAX6956); diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 67b302d1c51..d33b1e43ae4 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -2,15 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import gpio_expander, i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -41,20 +33,12 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - CONF_MCP23016 = "mcp23016" MCP23016_PIN_SCHEMA = pins.gpio_base_schema( MCP23016GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -65,13 +49,4 @@ MCP23016_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23016, MCP23016_PIN_SCHEMA) async def mcp23016_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_MCP23016]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_MCP23016) diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 13d930fd470..d5a16f9964c 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -92,12 +92,6 @@ void MCP23016::update_reg_(uint8_t pin, bool pin_value, uint8_t reg_addr) { } } -void MCP23016GPIOPin::setup() { pin_mode(flags_); } -void MCP23016GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool MCP23016GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void MCP23016GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t MCP23016GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via MCP23016", this->pin_); -} - } // namespace esphome::mcp23016 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::mcp23016::MCP23016, "%u via MCP23016") diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 14c0c9a2fc4..a55d4dc6187 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::mcp23016 { @@ -56,26 +57,8 @@ class MCP23016 final : public Component, public i2c::I2CDevice, public gpio_expa InternalGPIOPin *interrupt_pin_{nullptr}; }; -class MCP23016GPIOPin final : public GPIOPin { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(MCP23016 *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - MCP23016 *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using MCP23016GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::mcp23016 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::mcp23016::MCP23016); diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index c7aae9d10ff..5f018412b66 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -7,9 +7,6 @@ from esphome.const import ( CONF_INPUT, CONF_INTERRUPT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_OPEN_DRAIN_INTERRUPT, CONF_OUTPUT, CONF_PULLUP, @@ -51,23 +48,13 @@ async def register_mcp23xxx(config: ConfigType, num_pins: int) -> MockObj: return var -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") - return value - - CONF_MCP23XXX = "mcp23xxx" MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( MCP23XXXGPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT, CONF_PULLUP], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output_pullup, invertible=True, ).extend( { @@ -82,16 +69,9 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23XXX, MCP23XXX_PIN_SCHEMA) async def mcp23xxx_pin_to_code(config: ConfigType) -> MockObj: parent_id: ID = config[CONF_MCP23XXX] - parent = await cg.get_variable(parent_id) - + # Wait for the hub so its to_code has recorded the pin count + await cg.get_variable(parent_id) num_pins = cg.TemplateArguments(CORE.data[CONF_MCP23XXX][parent_id.id]) - - var = cg.new_Pvariable(config[CONF_ID], num_pins) - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + var = await pins.new_parented_pin(config, CONF_MCP23XXX, num_pins) cg.add(var.set_interrupt_mode(config[CONF_INTERRUPT])) return var diff --git a/esphome/components/mpr121/__init__.py b/esphome/components/mpr121/__init__.py index 1764ff9c054..3d75451ef82 100644 --- a/esphome/components/mpr121/__init__.py +++ b/esphome/components/mpr121/__init__.py @@ -7,8 +7,6 @@ from esphome.const import ( CONF_CHANNEL, CONF_ID, CONF_INPUT, - CONF_INVERTED, - CONF_MODE, CONF_NUMBER, CONF_OUTPUT, ) @@ -24,12 +22,15 @@ CONF_MAX_TOUCH_CHANNEL = "max_touch_channel" CONF_MPR121 = "mpr121" CONF_MPR121_ID = "mpr121_id" +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] DOMAIN = "mpr121" mpr121_ns = cg.esphome_ns.namespace("mpr121") MPR121Component = mpr121_ns.class_("MPR121Component", cg.Component, i2c.I2CDevice) -MPR121GPIOPin = mpr121_ns.class_("MPR121GPIOPin", cg.GPIOPin) +MPR121GPIOPin = mpr121_ns.class_( + "MPR121GPIOPin", cg.GPIOPin, cg.Parented.template(MPR121Component) +) MULTI_CONF = True CONFIG_SCHEMA = ( @@ -85,12 +86,6 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) -def validate_mode(value: ConfigType) -> ConfigType: - if bool(value[CONF_INPUT]) == bool(value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - return value - - # https://www.nxp.com/docs/en/data-sheet/MPR121.pdf, page 4 # # Among the 12 electrode inputs, 8 inputs are designed as multifunctional pins. When these pins are @@ -100,7 +95,7 @@ MPR121_GPIO_PIN_SCHEMA = pins.gpio_base_schema( MPR121GPIOPin, cv.int_range(min=4, max=11), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_MPR121): cv.use_id(MPR121Component), @@ -121,13 +116,4 @@ def mpr121_pin_final_validate( CONF_MPR121, MPR121_GPIO_PIN_SCHEMA, mpr121_pin_final_validate ) async def mpr121_gpio_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_MPR121]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_MPR121) diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index 781028e7f5c..2dd8614a323 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -80,9 +80,10 @@ void MPR121Component::loop() { this->read_byte(MPR121_GPIODATA, &this->gpio_input_); } -bool MPR121Component::digital_read(uint8_t ionum) { return (this->gpio_input_ & (1 << ionum)) != 0; } +bool MPR121Component::digital_read(uint8_t pin) { return (this->gpio_input_ & (1 << (pin - 4))) != 0; } -void MPR121Component::digital_write(uint8_t ionum, bool value) { +void MPR121Component::digital_write(uint8_t pin, bool value) { + const uint8_t ionum = pin - 4; if (value) { this->gpio_output_ |= (1 << ionum); } else { @@ -91,7 +92,8 @@ void MPR121Component::digital_write(uint8_t ionum, bool value) { this->flush_gpio_(); } -void MPR121Component::pin_mode(uint8_t ionum, gpio::Flags flags) { +void MPR121Component::pin_mode(uint8_t pin, gpio::Flags flags) { + const uint8_t ionum = pin - 4; this->gpio_enable_ |= (1 << ionum); if (flags & gpio::FLAG_INPUT) { this->gpio_direction_ &= ~(1 << ionum); @@ -115,25 +117,6 @@ bool MPR121Component::flush_gpio_() { return this->status_comm_result(this->write_byte(MPR121_GPIODATA, this->gpio_output_)); } -void MPR121GPIOPin::setup() { this->pin_mode(this->flags_); } - -void MPR121GPIOPin::pin_mode(gpio::Flags flags) { - assert(this->pin_ >= 4); - this->parent_->pin_mode(this->pin_ - 4, flags); -} - -bool MPR121GPIOPin::digital_read() { - assert(this->pin_ >= 4); - return this->parent_->digital_read(this->pin_ - 4) != this->inverted_; -} - -void MPR121GPIOPin::digital_write(bool value) { - assert(this->pin_ >= 4); - this->parent_->digital_write(this->pin_ - 4, value != this->inverted_); -} - -size_t MPR121GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "ELE%u on MPR121", this->pin_); -} - } // namespace esphome::mpr121 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::mpr121::MPR121Component, "ELE%u on MPR121") diff --git a/esphome/components/mpr121/mpr121.h b/esphome/components/mpr121/mpr121.h index 4d945ded872..f998f015831 100644 --- a/esphome/components/mpr121/mpr121.h +++ b/esphome/components/mpr121/mpr121.h @@ -4,6 +4,7 @@ #include "esphome/core/defines.h" #include "esphome/core/hal.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" #include @@ -73,10 +74,10 @@ class MPR121Component final : public Component, public i2c::I2CDevice { void set_max_touch_channel(uint8_t max_touch_channel) { this->max_touch_channel_ = max_touch_channel; } - // GPIO helper functions. - bool digital_read(uint8_t ionum); - void digital_write(uint8_t ionum, bool value); - void pin_mode(uint8_t ionum, gpio::Flags flags); + // GPIO helper functions; `pin` is the electrode number, 4 to 11, of the shared GPIO pins. + bool digital_read(uint8_t pin); + void digital_write(uint8_t pin, bool value); + void pin_mode(uint8_t pin, gpio::Flags flags); protected: std::vector channels_{}; @@ -98,26 +99,8 @@ class MPR121Component final : public Component, public i2c::I2CDevice { }; /// Helper class to expose a MPR121 pin as an internal input GPIO pin. -class MPR121GPIOPin final : public GPIOPin { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(MPR121Component *parent) { this->parent_ = parent; } - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - MPR121Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using MPR121GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::mpr121 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::mpr121::MPR121Component); diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 144088d48f1..92fb8f8eeed 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -6,9 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_OUTPUT, CONF_PULLUP, ) @@ -45,43 +42,18 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") - return value - - -PCA6416A_PIN_SCHEMA = cv.All( +PCA6416A_PIN_SCHEMA = pins.gpio_base_schema( + PCA6416AGPIOPin, + cv.int_range(min=0, max=15), + modes=[CONF_INPUT, CONF_PULLUP, CONF_OUTPUT], + mode_validator=pins.gpio_validate_input_xor_output_pullup, +).extend( { - cv.GenerateID(): cv.declare_id(PCA6416AGPIOPin), cv.Required(CONF_PCA6416A): pins.use_id_or_address(PCA6416AComponent), - cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), - cv.Optional(CONF_MODE, default={}): cv.All( - { - cv.Optional(CONF_INPUT, default=False): cv.boolean, - cv.Optional(CONF_PULLUP, default=False): cv.boolean, - cv.Optional(CONF_OUTPUT, default=False): cv.boolean, - }, - validate_mode, - ), - cv.Optional(CONF_INVERTED, default=False): cv.boolean, } ) @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA6416A, PCA6416A_PIN_SCHEMA) async def pca6416a_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_PCA6416A]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PCA6416A) diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index 5f5b4ea4a10..1b643c6241d 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -187,12 +187,6 @@ void PCA6416AComponent::update_register_(uint8_t pin, bool pin_value, uint8_t re float PCA6416AComponent::get_setup_priority() const { return setup_priority::IO; } -void PCA6416AGPIOPin::setup() { pin_mode(flags_); } -void PCA6416AGPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PCA6416AGPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PCA6416AGPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t PCA6416AGPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PCA6416A", this->pin_); -} - } // namespace esphome::pca6416a + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca6416a::PCA6416AComponent, "%u via PCA6416A") diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 39011d53ab7..61b65501cfa 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pca6416a { @@ -48,27 +49,8 @@ class PCA6416AComponent final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a PCA6416A pin as an internal input GPIO pin. -class PCA6416AGPIOPin final : public GPIOPin { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(PCA6416AComponent *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - PCA6416AComponent *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using PCA6416AGPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pca6416a + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pca6416a::PCA6416AComponent); diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py index 91521820b30..f1c34b1eaad 100644 --- a/esphome/components/pca9505/__init__.py +++ b/esphome/components/pca9505/__init__.py @@ -2,15 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import gpio_expander, i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -45,19 +37,11 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - PCA9505_PIN_SCHEMA = pins.gpio_base_schema( PCA9505GPIOPin, cv.int_range(min=0, max=39), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PCA9505): cv.use_id(PCA9505Component), @@ -67,10 +51,4 @@ PCA9505_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA9505, PCA9505_PIN_SCHEMA) async def pca9505_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_parented(var, config[CONF_PCA9505]) - - cg.add(var.set_pin(config[CONF_NUMBER])) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PCA9505) diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp index 15c26a078f1..567b480b1b4 100644 --- a/esphome/components/pca9505/pca9505.cpp +++ b/esphome/components/pca9505/pca9505.cpp @@ -135,16 +135,6 @@ bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { float PCA9505Component::get_setup_priority() const { return setup_priority::IO; } -void PCA9505GPIOPin::setup() { this->pin_mode(this->flags_); } - -void PCA9505GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } - -bool PCA9505GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } - -void PCA9505GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } - -size_t PCA9505GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PCA9505", this->pin_); -} - } // namespace esphome::pca9505 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca9505::PCA9505Component, "%u via PCA9505") diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h index 29c4f18361c..a8ce6f1a324 100644 --- a/esphome/components/pca9505/pca9505.h +++ b/esphome/components/pca9505/pca9505.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" namespace esphome::pca9505 { @@ -49,24 +50,8 @@ class PCA9505Component final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -class PCA9505GPIOPin final : public GPIOPin, public Parented { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - uint8_t pin_{0}; - bool inverted_{false}; - gpio::Flags flags_{}; -}; +using PCA9505GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pca9505 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pca9505::PCA9505Component); diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 6e9478b1fd4..4e30c47bdae 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -6,8 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, CONF_NUMBER, CONF_OUTPUT, ) @@ -51,19 +49,11 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - PCA9554_PIN_SCHEMA = pins.gpio_base_schema( PCA9554GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PCA9554): pins.use_id_or_address(PCA9554Component), @@ -83,13 +73,4 @@ def pca9554_pin_final_validate( CONF_PCA9554, PCA9554_PIN_SCHEMA, pca9554_pin_final_validate ) async def pca9554_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_PCA9554]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PCA9554) diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index 4f44143c547..add9c8d0109 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -135,12 +135,6 @@ bool PCA9554Component::write_register_(uint8_t reg, uint16_t value) { float PCA9554Component::get_setup_priority() const { return setup_priority::IO; } -void PCA9554GPIOPin::setup() { pin_mode(flags_); } -void PCA9554GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PCA9554GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PCA9554GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t PCA9554GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PCA9554", this->pin_); -} - } // namespace esphome::pca9554 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pca9554::PCA9554Component, "%u via PCA9554") diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index cc95f147ac5..b8d34e328eb 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pca9554 { @@ -52,30 +53,8 @@ class PCA9554Component final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a PCA9554 pin as an internal input GPIO pin. -class PCA9554GPIOPin final : public GPIOPin { - public: - // User provided, not "= default": `new(p) PCA9554GPIOPin()` would zero-fill .bss that is already zero. - PCA9554GPIOPin() {} - - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(PCA9554Component *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - PCA9554Component *parent_{nullptr}; - uint8_t pin_{0}; - bool inverted_{false}; - gpio::Flags flags_{}; -}; +using PCA9554GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pca9554 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pca9554::PCA9554Component); diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index b491dae5047..21e9e7b007e 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -6,7 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OUTPUT, @@ -23,7 +22,9 @@ MULTI_CONF = True pcf8574_ns = cg.esphome_ns.namespace("pcf8574") PCF8574Component = pcf8574_ns.class_("PCF8574Component", cg.Component, i2c.I2CDevice) -PCF8574GPIOPin = pcf8574_ns.class_("PCF8574GPIOPin", cg.GPIOPin) +PCF8574GPIOPin = pcf8574_ns.class_( + "PCF8574GPIOPin", cg.GPIOPin, cg.Parented.template(PCF8574Component) +) CONF_PCF8574 = "pcf8574" CONF_PCF8575 = "pcf8575" @@ -51,19 +52,11 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - PCF8574_PIN_SCHEMA = pins.gpio_base_schema( PCF8574GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -74,13 +67,8 @@ PCF8574_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCF8574, PCF8574_PIN_SCHEMA) async def pcf8574_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_PCF8574]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + var = await pins.new_parented_pin(config, CONF_PCF8574) + if config[CONF_MODE][CONF_INPUT]: + parent = await cg.get_variable(config[CONF_PCF8574]) + cg.add(parent.register_input_pin(config[CONF_NUMBER])) return var diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 5674b6a53e8..ddf1985db45 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -109,17 +109,6 @@ bool PCF8574Component::write_gpio_() { } float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } -void PCF8574GPIOPin::setup() { pin_mode(flags_); } -void PCF8574GPIOPin::set_flags(gpio::Flags flags) { - this->flags_ = flags; - if (flags & gpio::FLAG_INPUT) - this->parent_->register_input_pin(this->pin_); -} -void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t PCF8574GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PCF8574", this->pin_); -} - } // namespace esphome::pcf8574 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pcf8574::PCF8574Component, "%u via PCF8574") diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 465bbd7a918..4bc19d204fa 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -4,6 +4,7 @@ #include "esphome/core/hal.h" #include "esphome/components/i2c/i2c.h" #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::pcf8574 { @@ -56,31 +57,8 @@ class PCF8574Component final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a PCF8574 pin as an internal input GPIO pin. -class PCF8574GPIOPin final : public GPIOPin { - public: - // User provided, not "= default": `new(p) PCF8574GPIOPin()` would zero-fill .bss that is already zero. - PCF8574GPIOPin() {} - - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(PCF8574Component *parent) { parent_ = parent; } - void set_pin(uint8_t pin) { pin_ = pin; } - void set_inverted(bool inverted) { inverted_ = inverted; } - // Call after set_parent() and set_pin(): input flags register the pin with the parent. - void set_flags(gpio::Flags flags); - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - PCF8574Component *parent_{nullptr}; - uint8_t pin_{0}; - bool inverted_{false}; - gpio::Flags flags_{}; -}; +using PCF8574GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pcf8574 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pcf8574::PCF8574Component); diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index fbfc998bb9f..6b7bfd47c3a 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -6,9 +6,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, CONF_ENABLE_PIN, - CONF_ID, CONF_INPUT, - CONF_INVERTED, CONF_MODE, CONF_NUMBER, CONF_OUTPUT, @@ -92,14 +90,6 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_pin_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output, not both") - return value - - def _validate_pin(value: ConfigType) -> ConfigType: if value[CONF_MODE][CONF_INPUT] and value[CONF_NUMBER] == 6: raise cv.Invalid("GPIO6 cannot be used as input on the PCM5122") @@ -111,7 +101,7 @@ PIN_SCHEMA = cv.All( PCM5122GPIOPin, cv.int_range(min=3, max=6), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=_validate_pin_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PCM5122): cv.use_id(PCM5122), @@ -123,13 +113,7 @@ PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PCM5122, PIN_SCHEMA) async def pcm5122_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_parented(var, config[CONF_PCM5122]) - - cg.add(var.set_pin(config[CONF_NUMBER])) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PCM5122) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index 458ea1f2728..d9d1fd31f51 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -6,9 +6,6 @@ from esphome.const import ( CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_OUTPUT, CONF_PULLDOWN, CONF_PULLUP, @@ -52,14 +49,6 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( PI4IOE5V6408GPIOPin, cv.int_range(min=0, max=7), @@ -69,7 +58,7 @@ PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( CONF_PULLUP, CONF_PULLDOWN, ], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, ).extend( { cv.Required(CONF_PI4IOE5V6408): pins.use_id_or_address(PI4IOE5V6408Component), @@ -79,10 +68,4 @@ PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_PI4IOE5V6408, PI4IOE5V6408_PIN_SCHEMA) async def pi4ioe5v6408_pin_schema(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_parented(var, config[CONF_PI4IOE5V6408]) - - cg.add(var.set_pin(config[CONF_NUMBER])) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_PI4IOE5V6408) diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index e6a423de7cf..63fa1c249b4 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -182,14 +182,6 @@ bool PI4IOE5V6408Component::digital_read_cache(uint8_t pin) { return (this->inpu float PI4IOE5V6408Component::get_setup_priority() const { return setup_priority::IO; } -void PI4IOE5V6408GPIOPin::setup() { this->pin_mode(this->flags_); } -void PI4IOE5V6408GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool PI4IOE5V6408GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void PI4IOE5V6408GPIOPin::digital_write(bool value) { - this->parent_->digital_write(this->pin_, value != this->inverted_); -} -size_t PI4IOE5V6408GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via PI4IOE5V6408", this->pin_); -} - } // namespace esphome::pi4ioe5v6408 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::pi4ioe5v6408::PI4IOE5V6408Component, "%u via PI4IOE5V6408") diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 9909dc2217b..556404a3743 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -49,24 +50,8 @@ class PI4IOE5V6408Component final : public Component, bool read_gpio_outputs_(); }; -class PI4IOE5V6408GPIOPin final : public GPIOPin, public Parented { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using PI4IOE5V6408GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::pi4ioe5v6408 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::pi4ioe5v6408::PI4IOE5V6408Component); diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index c44c52a6fc6..c98f94d4b55 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -4,11 +4,7 @@ from esphome.components import i2c, key_provider from esphome.components.const import CONF_KEYS import esphome.config_validation as cv from esphome.const import ( - CONF_ID, CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, CONF_ON_KEY, CONF_OPEN_DRAIN, CONF_OUTPUT, @@ -113,12 +109,7 @@ async def to_code(config: ConfigType) -> None: def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - if value[CONF_PULLUP] and not value[CONF_INPUT]: - raise cv.Invalid("Pullup only available with input") + pins.gpio_validate_input_xor_output_pullup(value) if value[CONF_PULLDOWN] and not value[CONF_INPUT]: raise cv.Invalid("Pulldown only available with input") if value[CONF_PULLUP] and value[CONF_PULLDOWN]: @@ -129,34 +120,18 @@ def validate_mode(value: ConfigType) -> ConfigType: CONF_SX1509 = "sx1509" -SX1509_PIN_SCHEMA = cv.All( +SX1509_PIN_SCHEMA = pins.gpio_base_schema( + SX1509GPIOPin, + cv.int_range(min=0, max=15), + modes=[CONF_INPUT, CONF_PULLUP, CONF_PULLDOWN, CONF_OUTPUT, CONF_OPEN_DRAIN], + mode_validator=validate_mode, +).extend( { - cv.GenerateID(): cv.declare_id(SX1509GPIOPin), cv.Required(CONF_SX1509): pins.use_id_or_address(SX1509Component), - cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), - cv.Optional(CONF_MODE, default={}): cv.All( - { - cv.Optional(CONF_INPUT, default=False): cv.boolean, - cv.Optional(CONF_PULLUP, default=False): cv.boolean, - cv.Optional(CONF_PULLDOWN, default=False): cv.boolean, - cv.Optional(CONF_OUTPUT, default=False): cv.boolean, - cv.Optional(CONF_OPEN_DRAIN, default=False): cv.boolean, - }, - validate_mode, - ), - cv.Optional(CONF_INVERTED, default=False): cv.boolean, } ) @pins.PIN_SCHEMA_REGISTRY.register(CONF_SX1509, SX1509_PIN_SCHEMA) async def sx1509_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_SX1509]) - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_SX1509) diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index d883982bcd5..bbb38198459 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -312,3 +312,5 @@ void SX1509Component::set_debounce_keypad_(uint8_t time, uint8_t num_rows, uint8 } } // namespace esphome::sx1509 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::sx1509::SX1509Component, "%u via sx1509") diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index ef6ee6ec5e6..5b163cbd6e5 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -5,7 +5,7 @@ #include "esphome/components/gpio_expander/cached_gpio.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "sx1509_gpio_pin.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "sx1509_registers.h" #include @@ -97,4 +97,8 @@ class SX1509Component final : public Component, void clock_(uint8_t osc_source = 2, uint8_t osc_pin_function = 1, uint8_t osc_freq_out = 0, uint8_t osc_divider = 0); }; +using SX1509GPIOPin = gpio_expander::ExpanderGPIOPin; + } // namespace esphome::sx1509 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::sx1509::SX1509Component); diff --git a/esphome/components/sx1509/sx1509_gpio_pin.cpp b/esphome/components/sx1509/sx1509_gpio_pin.cpp deleted file mode 100644 index 41f967b6672..00000000000 --- a/esphome/components/sx1509/sx1509_gpio_pin.cpp +++ /dev/null @@ -1,18 +0,0 @@ -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" -#include "sx1509.h" -#include "sx1509_gpio_pin.h" - -namespace esphome::sx1509 { - -ESPHOME_LOG_TAG(TAG, "sx1509_gpio_pin"); - -void SX1509GPIOPin::setup() { pin_mode(flags_); } -void SX1509GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool SX1509GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void SX1509GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t SX1509GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via sx1509", this->pin_); -} - -} // namespace esphome::sx1509 diff --git a/esphome/components/sx1509/sx1509_gpio_pin.h b/esphome/components/sx1509/sx1509_gpio_pin.h deleted file mode 100644 index 3bd3d90bd93..00000000000 --- a/esphome/components/sx1509/sx1509_gpio_pin.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include "esphome/core/gpio.h" - -namespace esphome::sx1509 { - -class SX1509Component; - -class SX1509GPIOPin final : public GPIOPin { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_parent(SX1509Component *parent) { this->parent_ = parent; } - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - SX1509Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; - -} // namespace esphome::sx1509 diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index a3992b7c88e..5b7f58253e8 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -2,15 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import gpio_expander, i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INTERRUPT_PIN, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_INTERRUPT_PIN, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -44,19 +36,11 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN)) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - TCA9555_PIN_SCHEMA = pins.gpio_base_schema( TCA9555GPIOPin, cv.int_range(min=0, max=15), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -67,10 +51,4 @@ TCA9555_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_TCA9555, TCA9555_PIN_SCHEMA) async def tca9555_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_parented(var, config[CONF_TCA9555]) - - cg.add(var.set_pin(config[CONF_NUMBER])) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_TCA9555) diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 448836e6609..bfb837e243c 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -129,12 +129,6 @@ bool TCA9555Component::digital_read_cache(uint8_t pin) { return this->input_mask float TCA9555Component::get_setup_priority() const { return setup_priority::IO; } -void TCA9555GPIOPin::setup() { this->pin_mode(this->flags_); } -void TCA9555GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool TCA9555GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void TCA9555GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } -size_t TCA9555GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via TCA9555", this->pin_); -} - } // namespace esphome::tca9555 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::tca9555::TCA9555Component, "%u via TCA9555") diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 50037cbe92d..fe4f6c21264 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" @@ -46,25 +47,8 @@ class TCA9555Component final : public Component, InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Helper class to expose a TCA9555 pin as an internal input GPIO pin. -class TCA9555GPIOPin final : public GPIOPin, public Parented { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using TCA9555GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::tca9555 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::tca9555::TCA9555Component); diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py index 0b89ca8b561..e01fd924e7b 100644 --- a/esphome/components/waveshare_io_ch32v003/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -2,14 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -48,19 +41,11 @@ async def to_code(config: ConfigType) -> None: await i2c.new_i2c_component(config) -def validate_mode(value: ConfigType) -> ConfigType: - if not (value[CONF_INPUT] or value[CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_INPUT] and value[CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") - return value - - WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( WaveshareIOCH32V003GPIOPin, cv.int_range(min=0, max=7), modes=[CONF_INPUT, CONF_OUTPUT], - mode_validator=validate_mode, + mode_validator=pins.gpio_validate_input_xor_output, invertible=True, ).extend( { @@ -73,13 +58,4 @@ WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WAVESHARE_IO_CH32V003, WAVESHARE_IO_PIN_SCHEMA) async def waveshare_io_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WAVESHARE_IO_CH32V003]) - - cg.add(var.set_parent(parent)) - - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WAVESHARE_IO_CH32V003) diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 9ff7eb7f382..86571363257 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -124,16 +124,6 @@ void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) { bool WaveshareIOCH32V003Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } float WaveshareIOCH32V003Component::get_setup_priority() const { return setup_priority::IO; } -void WaveshareIOCH32V003GPIOPin::setup() { this->pin_mode(this->flags_); } -void WaveshareIOCH32V003GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool WaveshareIOCH32V003GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } - -void WaveshareIOCH32V003GPIOPin::digital_write(bool value) { - this->parent_->digital_write(this->pin_, value ^ this->inverted_); -} - -size_t WaveshareIOCH32V003GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "EXIO%u via WaveshareIO", this->pin_); -} - } // namespace esphome::waveshare_io_ch32v003 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::waveshare_io_ch32v003::WaveshareIOCH32V003Component, "EXIO%u via WaveshareIO") diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h index 4a31602fa41..adecaa681cc 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h @@ -4,6 +4,7 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::waveshare_io_ch32v003 { @@ -27,8 +28,6 @@ class WaveshareIOCH32V003Component : public Component, void set_pwm_value(uint8_t value); // 0 - 255 protected: - friend class WaveshareIOCH32V003GPIOPin; - bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; @@ -41,25 +40,8 @@ class WaveshareIOCH32V003Component : public Component, bool write_gpio_outputs_(); }; -/// Helper class to expose a WaveshareIO pin as a GPIO pin. -class WaveshareIOCH32V003GPIOPin : public GPIOPin, public Parented { - public: - void setup() override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - size_t dump_summary(char *buffer, size_t len) const override; - - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - protected: - uint8_t pin_{}; - bool inverted_{}; - gpio::Flags flags_{}; -}; +using WaveshareIOCH32V003GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::waveshare_io_ch32v003 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::waveshare_io_ch32v003::WaveshareIOCH32V003Component); diff --git a/esphome/components/weikai/__init__.py b/esphome/components/weikai/__init__.py index 58bfc6f815d..5fb54a906aa 100644 --- a/esphome/components/weikai/__init__.py +++ b/esphome/components/weikai/__init__.py @@ -1,3 +1,4 @@ +from esphome import pins import esphome.codegen as cg from esphome.components import uart from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS @@ -90,10 +91,7 @@ async def register_weikai(var: MockObj, config: ConfigType) -> None: def validate_pin_mode(value: ConfigType) -> ConfigType: """Checks input/output mode inconsistency""" - if not (value[CONF_MODE][CONF_INPUT] or value[CONF_MODE][CONF_OUTPUT]): - raise cv.Invalid("Mode must be either input or output") - if value[CONF_MODE][CONF_INPUT] and value[CONF_MODE][CONF_OUTPUT]: - raise cv.Invalid("Mode must be either input or output") + pins.gpio_validate_input_xor_output(value[CONF_MODE]) return value diff --git a/esphome/components/wk2168_i2c/__init__.py b/esphome/components/wk2168_i2c/__init__.py index c450717ea2c..9c2b074e503 100644 --- a/esphome/components/wk2168_i2c/__init__.py +++ b/esphome/components/wk2168_i2c/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -52,11 +52,4 @@ WK2168_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2168_I2C, WK2168_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2168_I2C]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2168_I2C) diff --git a/esphome/components/wk2168_spi/__init__.py b/esphome/components/wk2168_spi/__init__.py index ef12117a2c7..12192e71db0 100644 --- a/esphome/components/wk2168_spi/__init__.py +++ b/esphome/components/wk2168_spi/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import spi, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -50,11 +50,4 @@ WK2168_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2168_SPI, WK2168_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2168_SPI]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2168_SPI) diff --git a/esphome/components/wk2212_i2c/__init__.py b/esphome/components/wk2212_i2c/__init__.py index 1f51fea3145..73c41b36504 100644 --- a/esphome/components/wk2212_i2c/__init__.py +++ b/esphome/components/wk2212_i2c/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -52,11 +52,4 @@ WK2212_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2212_I2C, WK2212_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2212_I2C]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2212_I2C) diff --git a/esphome/components/wk2212_spi/__init__.py b/esphome/components/wk2212_spi/__init__.py index a0cce3331ad..cc4c358f284 100644 --- a/esphome/components/wk2212_spi/__init__.py +++ b/esphome/components/wk2212_spi/__init__.py @@ -2,7 +2,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import spi, weikai import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INVERTED, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_ID from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -50,11 +50,4 @@ WK2212_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(CONF_WK2212_SPI, WK2212_PIN_SCHEMA) async def sc16is75x_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_WK2212_SPI]) - cg.add(var.set_parent(parent)) - num = config[CONF_NUMBER] - cg.add(var.set_pin(num)) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - return var + return await pins.new_parented_pin(config, CONF_WK2212_SPI) diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 85330357d2d..8d1e03ff156 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -2,19 +2,13 @@ from esphome import pins import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_INPUT, - CONF_INVERTED, - CONF_MODE, - CONF_NUMBER, - CONF_OUTPUT, -) +from esphome.const import CONF_ID, CONF_INPUT, CONF_OUTPUT from esphome.cpp_generator import MockObj from esphome.types import ConfigType CONF_XL9535 = "xl9535" +AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] CODEOWNERS = ["@mreditor97"] DOMAIN = "xl9535" @@ -36,45 +30,24 @@ async def to_code(config: ConfigType) -> None: await i2c.new_i2c_component(config) -def validate_mode(mode: ConfigType) -> ConfigType: - if not (mode[CONF_INPUT] or mode[CONF_OUTPUT]) or ( - mode[CONF_INPUT] and mode[CONF_OUTPUT] - ): - raise cv.Invalid("Mode must be either a input or a output") - return mode - - def validate_pin(pin: int) -> int: if pin in (8, 9): raise cv.Invalid(f"pin {pin} doesn't exist") return pin -XL9535_PIN_SCHEMA = cv.All( +XL9535_PIN_SCHEMA = pins.gpio_base_schema( + XL9535GPIOPin, + cv.All(cv.int_range(min=0, max=17), validate_pin), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=pins.gpio_validate_input_xor_output, +).extend( { - cv.GenerateID(): cv.declare_id(XL9535GPIOPin), cv.Required(CONF_XL9535): pins.use_id_or_address(XL9535Component), - cv.Required(CONF_NUMBER): cv.All(cv.int_range(min=0, max=17), validate_pin), - cv.Optional(CONF_MODE, default={}): cv.All( - { - cv.Optional(CONF_INPUT, default=False): cv.boolean, - cv.Optional(CONF_OUTPUT, default=False): cv.boolean, - }, - validate_mode, - ), - cv.Optional(CONF_INVERTED, default=False): cv.boolean, } ) @pins.PIN_SCHEMA_REGISTRY.register(CONF_XL9535, XL9535_PIN_SCHEMA) async def xl9535_pin_to_code(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - parent = await cg.get_variable(config[CONF_XL9535]) - - cg.add(var.set_parent(parent)) - cg.add(var.set_pin(config[CONF_NUMBER])) - cg.add(var.set_inverted(config[CONF_INVERTED])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) - - return var + return await pins.new_parented_pin(config, CONF_XL9535) diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index 9be536353c8..d26641c3afa 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -103,14 +103,6 @@ void XL9535Component::pin_mode(uint8_t pin, gpio::Flags mode) { } } -void XL9535GPIOPin::setup() { this->pin_mode(this->flags_); } - -size_t XL9535GPIOPin::dump_summary(char *buffer, size_t len) const { - return buf_append_printf(buffer, len, 0, "%u via XL9535", this->pin_); -} - -void XL9535GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } -bool XL9535GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } -void XL9535GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } - } // namespace esphome::xl9535 + +ESPHOME_EXPANDER_PIN_SUMMARY(esphome::xl9535::XL9535Component, "%u via XL9535") diff --git a/esphome/components/xl9535/xl9535.h b/esphome/components/xl9535/xl9535.h index 11fb3acc8d0..8f883d36df7 100644 --- a/esphome/components/xl9535/xl9535.h +++ b/esphome/components/xl9535/xl9535.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/hal.h" +#include "esphome/components/gpio_expander/expander_gpio_pin.h" namespace esphome::xl9535 { @@ -28,27 +29,8 @@ class XL9535Component final : public Component, public i2c::I2CDevice { float get_setup_priority() const override { return setup_priority::IO; } }; -class XL9535GPIOPin final : public GPIOPin { - public: - void set_parent(XL9535Component *parent) { this->parent_ = parent; } - void set_pin(uint8_t pin) { this->pin_ = pin; } - void set_inverted(bool inverted) { this->inverted_ = inverted; } - void set_flags(gpio::Flags flags) { this->flags_ = flags; } - - gpio::Flags get_flags() const override { return this->flags_; } - - void setup() override; - size_t dump_summary(char *buffer, size_t len) const override; - void pin_mode(gpio::Flags flags) override; - bool digital_read() override; - void digital_write(bool value) override; - - protected: - XL9535Component *parent_; - - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; -}; +using XL9535GPIOPin = gpio_expander::ExpanderGPIOPin; } // namespace esphome::xl9535 + +ESPHOME_EXPANDER_PIN_SUMMARY_DECL(esphome::xl9535::XL9535Component); diff --git a/tests/components/pcf8574/test_pcf8574.cpp b/tests/components/pcf8574/test_pcf8574.cpp index 3500069ba10..1caf86febaf 100644 --- a/tests/components/pcf8574/test_pcf8574.cpp +++ b/tests/components/pcf8574/test_pcf8574.cpp @@ -50,10 +50,8 @@ class PCF8574ResetTest : public ::testing::TestWithParam { } void configure_input_(uint8_t number) { - PCF8574GPIOPin pin; - pin.set_parent(&this->component_); - pin.set_pin(number); - pin.set_flags(gpio::FLAG_INPUT); + // Codegen registers input pins with the parent before setup() + this->component_.register_input_pin(number); EXPECT_EQ(this->bus_.writes, 0); EXPECT_EQ(this->bus_.reads, 0); } diff --git a/tests/unit_tests/components/test_pcf8574.py b/tests/unit_tests/components/test_pcf8574.py index e5b6519700d..250eb5be9bc 100644 --- a/tests/unit_tests/components/test_pcf8574.py +++ b/tests/unit_tests/components/test_pcf8574.py @@ -5,7 +5,14 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from esphome.components import pcf8574 -from esphome.const import CONF_ID, CONF_RESET +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, + CONF_RESET, +) @pytest.mark.asyncio @@ -31,3 +38,33 @@ async def test_reset_configuration(options: dict, expected: bool) -> None: var.set_reset.assert_not_called() else: var.set_reset.assert_called_once_with(False) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "number, is_input", + [ + (0, True), + (15, True), + (0, False), + (15, False), + ], +) +async def test_pin_registers_inputs_only(number: int, is_input: bool) -> None: + config = { + CONF_NUMBER: number, + CONF_MODE: {CONF_INPUT: is_input, CONF_OUTPUT: not is_input}, + pcf8574.CONF_PCF8574: "hub", + } + parent = Mock() + with ( + patch.object(pcf8574.pins, "new_parented_pin", new_callable=AsyncMock), + patch.object(pcf8574.cg, "get_variable", AsyncMock(return_value=parent)), + patch.object(pcf8574.cg, "add"), + ): + await pcf8574.pcf8574_pin_to_code(config) + + if is_input: + parent.register_input_pin.assert_called_once_with(number) + else: + parent.register_input_pin.assert_not_called() From 0e8e45c5283b13f175e70d2817dc53790560ca89 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:35:15 +1300 Subject: [PATCH 164/179] [mipi_spi] Fix GC9107 native size and add M5Stack AtomS3 model (#21087) --- esphome/components/mipi_spi/models/m5stack.py | 11 +++ .../mipi_spi/test_padding_and_offsets.py | 67 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py index f62bee5f602..4846635ac85 100644 --- a/esphome/components/mipi_spi/models/m5stack.py +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -55,6 +55,8 @@ ILI9341.extend( GC9107 = ST7789V.extend( "GC9107", + native_width=132, + native_height=130, width=128, height=128, offset_width=2, @@ -73,6 +75,15 @@ GC9107.extend( requires={"psram"}, ) +GC9107.extend( + "M5STACK-ATOMS3", + data_rate="40MHz", + invert_colors=True, + reset_pin=34, + dc_pin=33, + cs_pin=15, +) + # M5Stack AtomS3R (ST7735 revision - May 14, 2026+) ST7735.extend( "M5STACK-ATOMS3R-ST7735", diff --git a/tests/component_tests/mipi_spi/test_padding_and_offsets.py b/tests/component_tests/mipi_spi/test_padding_and_offsets.py index b2b421c1e20..e3471721730 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -601,3 +601,70 @@ class TestSwapXYNativeDimensions: # native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20 assert pad_w == 20 assert pad_h == 0 + + +class TestGC9107Dimensions: + """Test the GC9107 native size and the M5Stack AtomS3 visible area.""" + + @pytest.mark.parametrize( + "config", + [ + pytest.param({"model": "M5STACK-ATOMS3"}, id="atoms3_model"), + pytest.param( + { + "model": "GC9107", + "dc_pin": 33, + "dimensions": { + "width": 128, + "height": 128, + "offset_width": 2, + "offset_height": 1, + }, + }, + id="gc9107_explicit_offsets", + ), + ], + ) + @pytest.mark.parametrize("rotation", [0, 180]) + def test_atoms3_offsets_and_pads( + self, + config: ConfigType, + rotation: int, + set_core_config: SetCoreConfigCallable, + ) -> None: + """Pads come from the 132x130 GC9107 memory, so rotation 180 lands on the visible area.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + config = validated_config({**config, "rotation": rotation}) + _, templateargs = get_instance(config) + + # WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT + assert templateargs[5:11] == [128, 128, 2, 1, 2, 1] + # Hardware rotation swaps in the pads as offsets for rotation 180 + assert templateargs[12] is True + + def test_atoms3r_defaults_unchanged( + self, + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + ) -> None: + """The GC9107 native size does not change the AtomS3R model defaults.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + set_component_config("psram", True) + + config = validated_config({"model": "M5STACK-ATOMS3R-GC9107"}) + _, templateargs = get_instance(config) + + assert templateargs[5:11] == [128, 128, 2, 1, 2, 1] From 4d4ba5a1bcb2c1bd62b88ac63bae73f6379ef746 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:37:35 +1100 Subject: [PATCH 165/179] [core] Move CONF_MAX_PACKET_SIZE to components/const (#21102) Co-authored-by: Claude Opus 5.5 --- esphome/components/const/__init__.py | 1 + esphome/components/usb_host/__init__.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index c2ae1a1a87b..a15df707e60 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -47,6 +47,7 @@ CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" CONF_MANUFACTURER = "manufacturer" +CONF_MAX_PACKET_SIZE = "max_packet_size" CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 1f12112eea7..07a5cae0ae5 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -2,7 +2,7 @@ import itertools import logging import esphome.codegen as cg -from esphome.components.const import CONF_MANUFACTURER +from esphome.components.const import CONF_MANUFACTURER, CONF_MAX_PACKET_SIZE from esphome.components.esp32 import ( VARIANT_ESP32H4, VARIANT_ESP32P4, @@ -38,7 +38,6 @@ CONF_PID = "pid" CONF_PRODUCT = "product" CONF_ENABLE_HUBS = "enable_hubs" CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests" -CONF_MAX_PACKET_SIZE = "max_packet_size" # VID/PID set to 0 or `None` product/manufacturer are wildcards From 1dc4006bfc2787e5da5a76ca9984151263d3b488 Mon Sep 17 00:00:00 2001 From: ab-tools Date: Mon, 19 Oct 2026 05:44:59 +0200 Subject: [PATCH 166/179] [usb_audio] Add USB Audio Class (UAC) host support (#11728) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/usb_audio/__init__.py | 152 +++ .../usb_audio/microphone/__init__.py | 56 ++ .../microphone/usb_audio_microphone.cpp | 182 ++++ .../microphone/usb_audio_microphone.h | 76 ++ .../components/usb_audio/speaker/__init__.py | 62 ++ .../usb_audio/speaker/usb_audio_speaker.cpp | 359 +++++++ .../usb_audio/speaker/usb_audio_speaker.h | 112 +++ esphome/components/usb_audio/usb_audio.cpp | 876 ++++++++++++++++++ esphome/components/usb_audio/usb_audio.h | 239 +++++ esphome/core/defines.h | 5 + esphome/idf_component.yml | 5 + tests/component_tests/usb_audio/__init__.py | 1 + .../usb_audio/test_validation.py | 132 +++ tests/components/usb_audio/common.yaml | 21 + .../usb_audio/test.esp32-p4-idf.yaml | 2 + .../usb_audio/test.esp32-s3-idf.yaml | 2 + 17 files changed, 2283 insertions(+) create mode 100644 esphome/components/usb_audio/__init__.py create mode 100644 esphome/components/usb_audio/microphone/__init__.py create mode 100644 esphome/components/usb_audio/microphone/usb_audio_microphone.cpp create mode 100644 esphome/components/usb_audio/microphone/usb_audio_microphone.h create mode 100644 esphome/components/usb_audio/speaker/__init__.py create mode 100644 esphome/components/usb_audio/speaker/usb_audio_speaker.cpp create mode 100644 esphome/components/usb_audio/speaker/usb_audio_speaker.h create mode 100644 esphome/components/usb_audio/usb_audio.cpp create mode 100644 esphome/components/usb_audio/usb_audio.h create mode 100644 tests/component_tests/usb_audio/__init__.py create mode 100644 tests/component_tests/usb_audio/test_validation.py create mode 100644 tests/components/usb_audio/common.yaml create mode 100644 tests/components/usb_audio/test.esp32-p4-idf.yaml create mode 100644 tests/components/usb_audio/test.esp32-s3-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 61f1f4722cc..3bb01931381 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -684,6 +684,7 @@ esphome/components/ultrasonic/* @ssieb @swoboda1337 esphome/components/unicode/* @esphome/core esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon +esphome/components/usb_audio/* @ab-tools esphome/components/usb_cdc_acm/* @kbx81 esphome/components/usb_host/* @clydebarrow esphome/components/usb_uart/* @clydebarrow diff --git a/esphome/components/usb_audio/__init__.py b/esphome/components/usb_audio/__init__.py new file mode 100644 index 00000000000..fd1333d46f6 --- /dev/null +++ b/esphome/components/usb_audio/__init__.py @@ -0,0 +1,152 @@ +from collections.abc import Callable + +import esphome.codegen as cg +from esphome.components import audio, esp32, usb_host +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_BUFFER_SIZE, + CONF_ID, + CONF_NUM_CHANNELS, + CONF_PLATFORM, + CONF_SAMPLE_RATE, +) +from esphome.cpp_generator import MockObj +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@ab-tools"] +DEPENDENCIES = ["esp32"] +# usb_host installs the USB host library, services its events and limits the variants +AUTO_LOAD = ["audio", "usb_host"] +DOMAIN = "usb_audio" + +usb_audio_ns = cg.esphome_ns.namespace("usb_audio") +USBAudioComponent = usb_audio_ns.class_("USBAudioComponent", cg.Component) +AudioStreamInfo = audio.audio_ns.class_("AudioStreamInfo") + +CONF_USB_AUDIO_ID = "usb_audio_id" +BITS_PER_SAMPLE_OPTIONS = [8, 16, 24, 32] +DEFAULT_BUFFER_SIZE = 6400 + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(USBAudioComponent), + } +).extend(cv.COMPONENT_SCHEMA) + + +def stream_schema(default_sample_rate: int, default_num_channels: int) -> cv.Schema: + """Stream format and buffer options shared by the speaker and microphone platforms.""" + return cv.Schema( + { + cv.GenerateID(CONF_USB_AUDIO_ID): cv.use_id(USBAudioComponent), + cv.Optional(CONF_SAMPLE_RATE, default=default_sample_rate): cv.int_range( + min=8000, max=96000 + ), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + cv.float_with_unit("bits", "bit"), cv.one_of(*BITS_PER_SAMPLE_OPTIONS) + ), + cv.Optional(CONF_NUM_CHANNELS, default=default_num_channels): cv.int_range( + min=1, max=2 + ), + # The default depends on the format; see apply_buffer_size() + cv.Optional(CONF_BUFFER_SIZE): cv.positive_not_null_int, + } + ).extend(cv.COMPONENT_SCHEMA) + + +def set_stream_limits(config: ConfigType) -> ConfigType: + """Pin the audio stream limits to the one configured format.""" + bits_per_sample = int(config[CONF_BITS_PER_SAMPLE]) + channels = config[CONF_NUM_CHANNELS] + sample_rate = config[CONF_SAMPLE_RATE] + # The limits validator edits the config in place and returns None + audio.set_stream_limits( + min_bits_per_sample=bits_per_sample, + max_bits_per_sample=bits_per_sample, + min_channels=channels, + max_channels=channels, + min_sample_rate=sample_rate, + max_sample_rate=sample_rate, + )(config) + return config + + +def bytes_per_frame(config: ConfigType) -> int: + """Bytes in one frame (one sample for every channel) of a platform's configured format.""" + return config[CONF_NUM_CHANNELS] * int(config[CONF_BITS_PER_SAMPLE]) // 8 + + +def apply_buffer_size(config: ConfigType, min_size: int, minimum: str) -> ConfigType: + """Default buffer_size to DEFAULT_BUFFER_SIZE, or the format's minimum when that is larger; reject smaller ones. + + `minimum` describes what min_size holds, for the error message. + """ + if CONF_BUFFER_SIZE not in config: + config[CONF_BUFFER_SIZE] = max(DEFAULT_BUFFER_SIZE, min_size) + elif config[CONF_BUFFER_SIZE] < min_size: + raise cv.Invalid( + f"buffer_size must be at least {min_size} bytes ({minimum}) for this format", + path=[CONF_BUFFER_SIZE], + ) + return config + + +def final_validate_one_per_hub(domain: str) -> Callable[[ConfigType], ConfigType]: + """The hub streams one speaker and one microphone, so reject a second platform of the same kind on a hub.""" + + def validator(config: ConfigType) -> ConfigType: + hub_id = str(config[CONF_USB_AUDIO_ID]) + count = sum( + 1 + for platform_config in fv.full_config.get().get(domain, []) + if platform_config.get(CONF_PLATFORM) == DOMAIN + and str(platform_config.get(CONF_USB_AUDIO_ID)) == hub_id + ) + if count > 1: + raise cv.Invalid( + f"Only one usb_audio {domain} per usb_audio component is supported", + path=[CONF_USB_AUDIO_ID], + ) + return config + + return validator + + +def max_packet_bytes(config: ConfigType) -> int: + """Largest isochronous packet of a platform's format: a 1 ms packet, plus one frame for rate adaptation.""" + return (-(-config[CONF_SAMPLE_RATE] // 1000) + 1) * bytes_per_frame(config) + + +def request_speaker_fifo(config: ConfigType) -> ConfigType: + """Ask usb_host for USB FIFO space for the speaker's isochronous OUT packets.""" + usb_host.request_periodic_fifo(out_bytes=max_packet_bytes(config)) + return config + + +def request_microphone_fifo(config: ConfigType) -> ConfigType: + """Ask usb_host for USB FIFO space for the microphone's isochronous IN packets.""" + usb_host.request_periodic_fifo(in_bytes=max_packet_bytes(config)) + return config + + +def stream_info(config: ConfigType) -> MockObj: + """The C++ AudioStreamInfo for a platform's configured format.""" + return AudioStreamInfo( + int(config[CONF_BITS_PER_SAMPLE]), + config[CONF_NUM_CHANNELS], + config[CONF_SAMPLE_RATE], + ) + + +async def to_code(config: ConfigType) -> None: + # Keep in sync with esphome/idf_component.yml. usb_host adds espressif/usb on IDF 6.0 and later. + esp32.add_idf_component(name="espressif/usb_host_uac", ref="1.5.0") + # 18 ms of transfers in flight instead of the driver's 9 ms, so a late transfer completion does not leave the bus + # idle while the ring buffer still holds audio. + esp32.set_idf_sdkconfig_default("CONFIG_UAC_NUM_ISOC_URBS", 6) + + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add_define("USE_USB_AUDIO") diff --git a/esphome/components/usb_audio/microphone/__init__.py b/esphome/components/usb_audio/microphone/__init__.py new file mode 100644 index 00000000000..51ab3ac47ea --- /dev/null +++ b/esphome/components/usb_audio/microphone/__init__.py @@ -0,0 +1,56 @@ +import esphome.codegen as cg +from esphome.components import microphone +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_SAMPLE_RATE +from esphome.types import ConfigType + +from .. import ( + CONF_USB_AUDIO_ID, + apply_buffer_size, + bytes_per_frame, + final_validate_one_per_hub, + request_microphone_fifo, + set_stream_limits, + stream_info, + stream_schema, + usb_audio_ns, +) + +USBAudioMicrophone = usb_audio_ns.class_( + "USBAudioMicrophone", microphone.Microphone, cg.Component +) + +# Keep in sync with USBAudioMicrophone::READ_TIMEOUT_MS in usb_audio_microphone.h +READ_PERIOD_MS = 20 + + +def validate_buffer_size(config: ConfigType) -> ConfigType: + """The driver buffer must hold the audio that arrives between two reads, with room to spare.""" + bytes_per_second = config[CONF_SAMPLE_RATE] * bytes_per_frame(config) + min_size = bytes_per_second * 2 * READ_PERIOD_MS // 1000 + return apply_buffer_size(config, min_size, f"{2 * READ_PERIOD_MS} ms of audio") + + +CONFIG_SCHEMA = cv.All( + microphone.MICROPHONE_SCHEMA.extend(stream_schema(16000, 1)).extend( + { + cv.GenerateID(): cv.declare_id(USBAudioMicrophone), + } + ), + set_stream_limits, + validate_buffer_size, + request_microphone_fifo, +) + +FINAL_VALIDATE_SCHEMA = final_validate_one_per_hub("microphone") + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_ID], stream_info(config), config[CONF_BUFFER_SIZE] + ) + await cg.register_component(var, config) + await microphone.register_microphone(var, config) + hub = await cg.get_variable(config[CONF_USB_AUDIO_ID]) + await cg.register_parented(var, hub) + cg.add(hub.register_microphone(var)) diff --git a/esphome/components/usb_audio/microphone/usb_audio_microphone.cpp b/esphome/components/usb_audio/microphone/usb_audio_microphone.cpp new file mode 100644 index 00000000000..9d699baccf2 --- /dev/null +++ b/esphome/components/usb_audio/microphone/usb_audio_microphone.cpp @@ -0,0 +1,182 @@ +#include "usb_audio_microphone.h" + +#ifdef USE_USB_AUDIO + +#include "esphome/core/log.h" + +#include +#include + +extern "C" { +#include "esp_err.h" +} + +namespace esphome::usb_audio { + +ESPHOME_LOG_TAG(TAG, "usb_audio.mic"); + +void USBAudioMicrophone::setup() { + this->frame_.reserve(this->audio_stream_info_.ms_to_bytes(2 * READ_TIMEOUT_MS)); + if (!this->mic_task_.create(&USBAudioMicrophone::mic_task, "usb_mic", MIC_TASK_STACK_SIZE, this, MIC_TASK_PRIORITY, + false)) { + this->mark_failed(LOG_STR("Failed to create microphone task")); + return; + } + // start() and stop() wake the loop. + this->disable_loop(); +} + +void USBAudioMicrophone::dump_config() { + ESP_LOGCONFIG(TAG, + "USB Microphone:\n" + " Sample rate: %" PRIu32 " Hz\n" + " Bits per sample: %u\n" + " Channels: %u\n" + " Buffer size: %" PRIu32, + this->audio_stream_info_.get_sample_rate(), this->audio_stream_info_.get_bits_per_sample(), + this->audio_stream_info_.get_channels(), this->buffer_size_); +} + +void USBAudioMicrophone::loop() { + const uint8_t commands = this->commands_.exchange(0); + if (commands & COMMAND_STOP) { + // Also when a failed start left the microphone STOPPED, so the next session's retries start from the minimum. + this->cancel_timeout(RETRY_START_ID); + this->retry_delay_ms_ = RETRY_MIN_MS; + } + if ((commands & COMMAND_STOP) && + (this->state_ == microphone::STATE_STARTING || this->state_ == microphone::STATE_RUNNING)) { + this->parent_->suspend(Endpoint::MICROPHONE); + // The task notices within one read timeout; the stop finishes below once it is idle. + this->running_ = false; + this->state_ = microphone::STATE_STOPPING; + } + if (this->state_ == microphone::STATE_STOPPING && this->task_idle_) { + this->state_ = microphone::STATE_STOPPED; + } + // A retry can race the last stop(), so only start while someone is still listening. + if ((commands & COMMAND_START) && this->listeners_ > 0) { + if (this->state_ == microphone::STATE_STOPPED) { + this->state_ = microphone::STATE_STARTING; + // Answers through on_stream_state() now or once the device connects. + this->parent_->resume(Endpoint::MICROPHONE); + } else if (this->state_ == microphone::STATE_STOPPING) { + // Retry once the task is idle. + this->commands_.fetch_or(COMMAND_START); + } + } + if (this->state_ != microphone::STATE_STOPPING) { + this->disable_loop(); + } +} + +void USBAudioMicrophone::request_(uint8_t set, uint8_t clear) { + this->commands_.fetch_and(static_cast(~clear)); + this->commands_.fetch_or(set); + this->enable_loop_soon_any_context(); +} + +// Every MicrophoneSource forwards its own start() and stop(), so capture runs while any of them is listening, like +// i2s_audio's listener count. +void USBAudioMicrophone::start() { + if (this->is_failed()) { + return; + } + uint8_t listeners = this->listeners_.load(); + do { + if (listeners >= MAX_LISTENERS) { + return; + } + } while (!this->listeners_.compare_exchange_weak(listeners, listeners + 1)); + if (listeners == 0) { + this->request_(COMMAND_START, COMMAND_STOP); + } +} + +void USBAudioMicrophone::stop() { + uint8_t listeners = this->listeners_.load(); + do { + if (listeners == 0) { + return; + } + } while (!this->listeners_.compare_exchange_weak(listeners, listeners - 1)); + if (listeners == 1) { + this->request_(COMMAND_STOP, COMMAND_START); + } +} + +void USBAudioMicrophone::on_stream_state(StreamEvent event) { + switch (event) { + case StreamEvent::RUNNING: + if (this->state_ == microphone::STATE_STARTING) { + this->task_idle_ = false; + this->running_ = true; + xTaskNotifyGive(this->mic_task_.get_handle()); + this->state_ = microphone::STATE_RUNNING; + this->retry_delay_ms_ = RETRY_MIN_MS; + } + break; + case StreamEvent::LOST: + // The hub restarts the stream on reconnect, since the microphone still wants it, so keep waiting in STARTING; + // this also covers a disconnect while the stream was still starting. + if (this->state_ == microphone::STATE_RUNNING) { + ESP_LOGW(TAG, "USB device disconnected, waiting for it to return"); + this->running_ = false; + this->state_ = microphone::STATE_STARTING; + } + break; + case StreamEvent::FAILED: + // Only a start or resume fails, so the task is idle. + if (this->state_ == microphone::STATE_STARTING) { + this->state_ = microphone::STATE_STOPPED; + // A MicrophoneSource calls start() once and then waits for data, and start() only asks for the stream when + // the first listener arrives, so retry here for the listeners still held. + if (this->listeners_ > 0) { + this->set_timeout(RETRY_START_ID, this->retry_delay_ms_, [this]() { + if (this->listeners_ > 0) { + this->request_(COMMAND_START, 0); + } + }); + this->retry_delay_ms_ = std::min(this->retry_delay_ms_ * 2, RETRY_MAX_MS); + } + } + break; + } +} + +void USBAudioMicrophone::mic_task(void *param) { static_cast(param)->mic_task_loop_(); } + +void USBAudioMicrophone::mic_task_loop_() { + while (true) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (this->running_) { + size_t bytes_read = 0; + this->frame_.resize(this->frame_.capacity()); + esp_err_t err = + this->parent_->read_microphone(this->frame_.data(), this->frame_.size(), &bytes_read, READ_TIMEOUT_MS); + if (!this->running_) { + break; + } + + if (err == ESP_OK && bytes_read > 0) { + this->frame_.resize(bytes_read); + this->data_callbacks_.call(this->frame_); + } else if (err == ESP_ERR_TIMEOUT) { + // The driver already waited READ_TIMEOUT_MS for data; read again. + } else if (err == ESP_ERR_INVALID_STATE) { + // The stream is starting or suspended. + vTaskDelay(pdMS_TO_TICKS(READ_TIMEOUT_MS)); + } else if (err != ESP_OK) { + ESP_LOGW(TAG, "Read error: %s", esp_err_to_name(err)); + vTaskDelay(pdMS_TO_TICKS(READ_TIMEOUT_MS)); + } + } + + this->task_idle_ = true; + } +} + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/microphone/usb_audio_microphone.h b/esphome/components/usb_audio/microphone/usb_audio_microphone.h new file mode 100644 index 00000000000..1b2ea744e0e --- /dev/null +++ b/esphome/components/usb_audio/microphone/usb_audio_microphone.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_USB_AUDIO + +#include "esphome/components/usb_audio/usb_audio.h" + +#include "esphome/components/audio/audio.h" +#include "esphome/components/microphone/microphone.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/static_task.h" + +#include +#include + +namespace esphome::usb_audio { + +class USBAudioMicrophone : public microphone::Microphone, + public Component, + public Parented, + public USBAudioStream { + public: + USBAudioMicrophone(const audio::AudioStreamInfo &stream_info, uint32_t buffer_size) : USBAudioStream(buffer_size) { + this->audio_stream_info_ = stream_info; + } + + void setup() override; + void dump_config() override; + void loop() override; + + // Safe from any task; loop() acts on the request. + void start() override; + void stop() override; + + audio::AudioStreamInfo get_usb_stream_info() override { return this->audio_stream_info_; } + void on_stream_state(StreamEvent event) override; + bool stream_idle() const override { return this->task_idle_; } + + protected: + static constexpr uint8_t COMMAND_START = 1 << 0; + static constexpr uint8_t COMMAND_STOP = 1 << 1; + // Same limit as i2s_audio's microphone. + static constexpr uint8_t MAX_LISTENERS = 16; + static constexpr uint32_t READ_TIMEOUT_MS = 20; + // A failed start is retried while listeners are held, backing off so a device that keeps failing does not flood the + // log. + static constexpr uint32_t RETRY_MIN_MS = 1000; + static constexpr uint32_t RETRY_MAX_MS = 30000; + // Scheduler ids. + static constexpr uint32_t RETRY_START_ID = 0; + static constexpr uint32_t MIC_TASK_STACK_SIZE = 4096; + static constexpr UBaseType_t MIC_TASK_PRIORITY = 18; + + static void mic_task(void *param); + void mic_task_loop_(); + void request_(uint8_t set, uint8_t clear); + + // The data callback takes a std::vector, so this is reserved once in setup() and only ever resized within its + // capacity by the mic task. + std::vector frame_; + // Created once in setup() and parked between streams. + StaticTask mic_task_; + std::atomic commands_{0}; + // start() calls not yet matched by a stop(); capture stops when the last listener stops. + std::atomic listeners_{0}; + std::atomic running_{false}; + std::atomic task_idle_{true}; + // Main loop only. + uint32_t retry_delay_ms_{RETRY_MIN_MS}; +}; + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/speaker/__init__.py b/esphome/components/usb_audio/speaker/__init__.py new file mode 100644 index 00000000000..62555f89d3c --- /dev/null +++ b/esphome/components/usb_audio/speaker/__init__.py @@ -0,0 +1,62 @@ +import esphome.codegen as cg +from esphome.components import speaker +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_SAMPLE_RATE +from esphome.types import ConfigType + +from .. import ( + CONF_USB_AUDIO_ID, + apply_buffer_size, + bytes_per_frame, + final_validate_one_per_hub, + request_speaker_fifo, + set_stream_limits, + stream_info, + stream_schema, + usb_audio_ns, +) + +USBAudioSpeaker = usb_audio_ns.class_("USBAudioSpeaker", speaker.Speaker, cg.Component) + +CONF_WRITE_TIMEOUT = "write_timeout" + +# Keep in sync with the usb_host_uac default of CONFIG_UAC_NUM_PACKETS_PER_URB (1 ms packets per transfer) +PACKETS_PER_TRANSFER = 3 + + +def validate_buffer_size(config: ConfigType) -> ConfigType: + """The driver only sends complete transfers, so the buffer must hold two of them to keep audio flowing.""" + frames_per_packet = -(-config[CONF_SAMPLE_RATE] // 1000) + transfer_size = PACKETS_PER_TRANSFER * frames_per_packet * bytes_per_frame(config) + return apply_buffer_size(config, 2 * transfer_size, "two USB transfers") + + +CONFIG_SCHEMA = cv.All( + speaker.SPEAKER_SCHEMA.extend(stream_schema(48000, 2)).extend( + { + cv.GenerateID(): cv.declare_id(USBAudioSpeaker), + cv.Optional( + CONF_WRITE_TIMEOUT, default="20ms" + ): cv.positive_time_period_milliseconds, + } + ), + set_stream_limits, + validate_buffer_size, + request_speaker_fifo, +) + +FINAL_VALIDATE_SCHEMA = final_validate_one_per_hub("speaker") + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_ID], + stream_info(config), + config[CONF_BUFFER_SIZE], + config[CONF_WRITE_TIMEOUT].total_milliseconds, + ) + await cg.register_component(var, config) + await speaker.register_speaker(var, config) + hub = await cg.get_variable(config[CONF_USB_AUDIO_ID]) + await cg.register_parented(var, hub) + cg.add(hub.register_speaker(var)) diff --git a/esphome/components/usb_audio/speaker/usb_audio_speaker.cpp b/esphome/components/usb_audio/speaker/usb_audio_speaker.cpp new file mode 100644 index 00000000000..2e1613b8ccb --- /dev/null +++ b/esphome/components/usb_audio/speaker/usb_audio_speaker.cpp @@ -0,0 +1,359 @@ +#include "usb_audio_speaker.h" + +#ifdef USE_USB_AUDIO + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esp_err.h" +#include "esp_timer.h" +#include "sdkconfig.h" + +namespace esphome::usb_audio { + +ESPHOME_LOG_TAG(TAG, "usb_audio.spk"); + +static constexpr size_t MAX_TIMEOUT_RETRIES = 4; +// Zeros are silence for the signed PCM the driver streams; 240 is a whole number of frames for every format. +static constexpr uint8_t SILENCE[240] = {}; + +void USBAudioSpeaker::setup() { + const auto &info = this->usb_stream_info_; + this->write_chunk_ = info.frames_to_bytes(std::max(info.bytes_to_frames(this->buffer_size_ / 4), 1)); + // The driver sends 1 ms packets, grouped into transfers it keeps in flight. + this->frame_bytes_ = info.frames_to_bytes(1); + this->packet_bytes_ = info.frames_to_bytes(info.get_sample_rate() / 1000); + this->packet_frac_ = info.get_sample_rate() % 1000; + this->transfer_bytes_ = info.frames_to_bytes((info.get_sample_rate() + 999) / 1000 * CONFIG_UAC_NUM_PACKETS_PER_URB); + this->transfer_us_ = CONFIG_UAC_NUM_PACKETS_PER_URB * 1000; + this->in_flight_us_ = CONFIG_UAC_NUM_ISOC_URBS * this->transfer_us_; + // About 10 ms per report, like the I2S speaker's DMA buffers. + this->report_frames_ = std::max(info.get_sample_rate() / 100, 1); + // start(), stop() and finish() wake the loop. + this->disable_loop(); +} + +void USBAudioSpeaker::dump_config() { + ESP_LOGCONFIG(TAG, + "USB Speaker:\n" + " Sample rate: %" PRIu32 " Hz\n" + " Bits per sample: %u\n" + " Channels: %u\n" + " Buffer size: %" PRIu32 "\n" + " Write timeout: %" PRIu32 " ms", + this->usb_stream_info_.get_sample_rate(), this->usb_stream_info_.get_bits_per_sample(), + this->usb_stream_info_.get_channels(), this->buffer_size_, this->write_timeout_ms_); +} + +void USBAudioSpeaker::loop() { + this->deliver_reports_(); + const uint8_t commands = this->commands_.exchange(0); + if (commands & COMMAND_STOP) { + this->stop_(); + } + if ((commands & COMMAND_START) && this->state_ == speaker::STATE_STOPPED) { + // Suspending flushed the driver's ring buffer, and resuming restarts its packet sizing. + portENTER_CRITICAL(&this->taken_lock_); + this->ring_bytes_ = 0; + this->ring_padding_ = 0; + // Resuming resets the driver's accumulator, then prepares every transfer, which advances it by their packets. + this->packet_frac_accum_ = CONFIG_UAC_NUM_ISOC_URBS * CONFIG_UAC_NUM_PACKETS_PER_URB * this->packet_frac_ % 1000; + this->unreported_frames_ = 0; + this->report_frames_pending_ = 0; + this->last_finish_us_ = 0; + portEXIT_CRITICAL(&this->taken_lock_); + this->state_ = speaker::STATE_STARTING; + // Answers through on_stream_state() now or once the device connects. + this->parent_->resume(Endpoint::SPEAKER); + } + + if (commands & COMMAND_FINISH) { + if (this->state_ == speaker::STATE_RUNNING) { + this->begin_finishing_(); + } else if (this->state_ == speaker::STATE_STARTING) { + // The stream is still starting; on_stream_state() finishes it once it runs. + this->finish_after_start_ = true; + } + } + if (this->finishing_ && this->pad_remaining_ > 0) { + this->pad_with_silence_(); + } + // has_buffered_data_() turns false once the driver has taken the last whole transfer and it has had time to play. + if (this->finishing_ && this->pad_remaining_ == 0 && !this->has_buffered_data_(esp_timer_get_time())) { + this->stop_(); + } + if (!this->finishing_) { + this->disable_loop(); + } +} + +void USBAudioSpeaker::request_(uint8_t set, uint8_t clear) { + this->commands_.fetch_and(static_cast(~clear)); + this->commands_.fetch_or(set); + this->enable_loop_soon_any_context(); +} + +void USBAudioSpeaker::start() { + if (this->state_ == speaker::STATE_STARTING || this->state_ == speaker::STATE_RUNNING) { + return; + } + this->request_(COMMAND_START, COMMAND_STOP); +} + +void USBAudioSpeaker::stop() { this->request_(COMMAND_STOP, COMMAND_START | COMMAND_FINISH); } + +void USBAudioSpeaker::finish() { this->request_(COMMAND_FINISH, 0); } + +void USBAudioSpeaker::begin_finishing_() { + if (this->finishing_) { + return; + } + this->finishing_ = true; + this->pad_remaining_ = this->transfer_bytes_; + // loop() pads the last transfer and stops once the audio has played. + this->enable_loop(); +} + +void USBAudioSpeaker::stop_() { + this->finishing_ = false; + this->finish_after_start_ = false; + this->pad_remaining_ = 0; + if (this->state_ == speaker::STATE_STOPPED) { + return; + } + this->parent_->suspend(Endpoint::SPEAKER); + this->state_ = speaker::STATE_STOPPED; +} + +void USBAudioSpeaker::on_stream_state(StreamEvent event) { + if (event == StreamEvent::RUNNING) { + if (this->state_ == speaker::STATE_STARTING) { + this->state_ = speaker::STATE_RUNNING; + if (this->finish_after_start_) { + this->finish_after_start_ = false; + this->begin_finishing_(); + } + } + return; + } + // Playback stops on a lost device as well as a failed start; the next start() opens it again. + if (event == StreamEvent::LOST && this->state_ == speaker::STATE_RUNNING) { + ESP_LOGW(TAG, "USB device disconnected, stopping playback"); + } + this->stop_(); +} + +size_t USBAudioSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { + // 0 ticks is one non-blocking pass; portMAX_DELAY is capped by the configured write timeout. + const uint32_t budget_ms = ticks_to_wait == portMAX_DELAY ? this->write_timeout_ms_ : pdTICKS_TO_MS(ticks_to_wait); + return this->play_internal_(data, length, budget_ms); +} + +// Callers such as voice_assistant and rtttl call this from loop(), so it never blocks. +size_t USBAudioSpeaker::play(const uint8_t *data, size_t length) { return this->play(data, length, 0); } + +size_t USBAudioSpeaker::play_internal_(const uint8_t *data, size_t length, uint32_t budget_ms) { + if (this->audio_stream_info_ != this->usb_stream_info_) { + // Playing it through the configured stream would change the speed and channel layout. + if (!this->format_mismatch_.exchange(true)) { + const auto &info = this->audio_stream_info_; + ESP_LOGE(TAG, + "Audio format (%" PRIu32 " Hz, %u bits, %u channels) does not match the USB stream; use a resampler " + "speaker", + info.get_sample_rate(), info.get_bits_per_sample(), info.get_channels()); + } + if (budget_ms > 0) { + vTaskDelay(pdMS_TO_TICKS(budget_ms)); + } + return 0; + } + this->format_mismatch_ = false; + + if (this->state_ != speaker::STATE_RUNNING) { + this->start(); + // Wait out the budget so a pipeline task retrying play() does not spin. + if (budget_ms > 0) { + vTaskDelay(pdMS_TO_TICKS(budget_ms)); + } + return 0; + } + + // Counted before the hub's handle is read, so the hub never closes the device under this call. + this->writes_in_flight_.fetch_add(1); + + // Runs on the caller's task, so the cached loop time does not apply. + const uint32_t start_ms = millis(); + uint32_t now = start_ms; + size_t written = 0; + size_t timeout_retries = 0; + while (written < length) { + const uint32_t elapsed = now - start_ms; + if (budget_ms > 0 && elapsed >= budget_ms) { + break; + } + const size_t chunk = std::min({length - written, this->write_chunk_, this->ring_room_()}); + if (chunk == 0) { + // Back-pressure: wait for the driver to take a transfer. + if (budget_ms == 0) { + break; + } + vTaskDelay(1); + now = millis(); + continue; + } + const uint32_t timeout_ms = budget_ms > 0 ? budget_ms - elapsed : 0; + const esp_err_t err = this->write_(data + written, chunk, timeout_ms, false); + now = millis(); + if (err == ESP_OK) { + written += chunk; + timeout_retries = 0; + } else if (err == ESP_ERR_INVALID_STATE) { + // The device went away. + break; + } else if (err != ESP_ERR_TIMEOUT) { + ESP_LOGW(TAG, "Write error: %s", esp_err_to_name(err)); + break; + } else if (budget_ms == 0 || ++timeout_retries >= MAX_TIMEOUT_RETRIES) { + // A full driver buffer is back-pressure; the caller retries. + break; + } + } + this->writes_in_flight_.fetch_sub(1); + return written; +} + +size_t USBAudioSpeaker::ring_room_() const { + // Whole frames up to buffer_size_. ring_bytes_ is never below the real fill, so a write of this much does not wait. + const uint32_t ring = this->ring_bytes_; + const uint32_t room = ring < this->buffer_size_ ? this->buffer_size_ - ring : 0; + return room - room % this->frame_bytes_; +} + +esp_err_t USBAudioSpeaker::write_(const uint8_t *data, size_t length, uint32_t timeout_ms, bool padding) { + // Counted first: the driver submits transfers inside the write, and on_transfer_taken() must see these bytes then. + // A failed write stores nothing, since the driver only writes whole chunks. + this->ring_bytes_.fetch_add(length); + if (padding) { + this->ring_padding_.fetch_add(length); + } + const esp_err_t err = this->parent_->write_speaker(data, length, timeout_ms); + if (err != ESP_OK) { + this->ring_bytes_.fetch_sub(length); + if (padding) { + this->ring_padding_.fetch_sub(length); + } + } + return err; +} + +void USBAudioSpeaker::pad_with_silence_() { + if (this->state_ != speaker::STATE_RUNNING) { + this->pad_remaining_ = 0; + return; + } + // Counted before the hub's handle is read, so the hub never closes the device under this call. + this->writes_in_flight_.fetch_add(1); + const size_t piece = sizeof(SILENCE) - sizeof(SILENCE) % this->usb_stream_info_.frames_to_bytes(1); + while (this->pad_remaining_ > 0) { + const size_t chunk = std::min({this->pad_remaining_, piece, this->ring_room_()}); + if (chunk == 0 || this->write_(SILENCE, chunk, 0, true) != ESP_OK) { + // A full buffer; the rest is written on a later loop(). + break; + } + this->pad_remaining_ -= chunk; + } + this->writes_in_flight_.fetch_sub(1); +} + +void USBAudioSpeaker::on_transfer_taken() { + bool report = false; + portENTER_CRITICAL(&this->taken_lock_); + // Mirror the driver: the next transfer is CONFIG_UAC_NUM_PACKETS_PER_URB packets, and the driver takes it only when + // the ring buffer holds all of it; otherwise it parks the transfer until the next write. + uint32_t accum = this->packet_frac_accum_; + uint32_t size = 0; + for (int i = 0; i < CONFIG_UAC_NUM_PACKETS_PER_URB; i++) { + accum += this->packet_frac_; + size += this->packet_bytes_; + if (accum >= 1000) { + size += this->frame_bytes_; + accum -= 1000; + } + } + const uint32_t ring = this->ring_bytes_; + if (ring >= size) { + this->packet_frac_accum_ = accum; + this->ring_bytes_.fetch_sub(size); + // Padding sits at the end of the ring buffer and is not audio from the caller, so it is not reported. + const uint32_t padding = std::min(this->ring_padding_, ring); + const uint32_t audio = std::min(size, ring - padding); + if (audio < size) { + this->ring_padding_.fetch_sub(std::min(size - audio, padding)); + } + const int64_t now_us = esp_timer_get_time(); + this->last_taken_us_ = static_cast(now_us); + // The bus plays one packet per millisecond, so while it streams a transfer finishes one transfer after the one + // taken before it, however late this callback runs. It cannot finish later than now plus the transfers in flight, + // so a callback that runs on time pulls back any lateness the chain picked up. When the bus is idle, after a start + // (the driver submits nothing until the first write) or once it ran dry, the transfer plays right away. + const int64_t finish_us = this->last_finish_us_ < now_us + ? now_us + this->transfer_us_ + : std::min(this->last_finish_us_ + this->transfer_us_, now_us + this->in_flight_us_); + this->last_finish_us_ = finish_us; + this->unreported_frames_ += this->usb_stream_info_.bytes_to_frames(audio); + // Batched like the I2S speaker's DMA buffers: about every 10 ms, and once the caller's audio runs out. + if (this->unreported_frames_ > 0 && (this->unreported_frames_ >= this->report_frames_ || audio == ring - padding)) { + this->report_frames_pending_ += this->unreported_frames_; + this->report_finish_us_ = finish_us; + this->unreported_frames_ = 0; + report = true; + } + } + portEXIT_CRITICAL(&this->taken_lock_); + if (report) { + // The listeners can block, so loop() calls them; the frames and their finish time travel together, so the delay + // does not change what they compute. + this->enable_loop_soon_any_context(); + } +} + +void USBAudioSpeaker::deliver_reports_() { + portENTER_CRITICAL(&this->taken_lock_); + const uint32_t frames = this->report_frames_pending_; + const int64_t finish_us = this->report_finish_us_; + this->report_frames_pending_ = 0; + portEXIT_CRITICAL(&this->taken_lock_); + if (frames > 0) { + this->audio_output_callback_(frames, finish_us); + } +} + +bool USBAudioSpeaker::has_buffered_data_(int64_t now_us) const { + if (this->state_ != speaker::STATE_RUNNING) { + return false; + } + // The driver still holds a whole transfer to take, or one it took has not finished playing. Less than a transfer + // stays in the ring buffer until the next write. + return this->ring_bytes_ >= this->transfer_bytes_ || + static_cast(static_cast(now_us) - this->last_taken_us_) < + static_cast(this->in_flight_us_); +} + +bool USBAudioSpeaker::has_buffered_data() const { return this->has_buffered_data_(esp_timer_get_time()); } + +// The hub's worker sends volume and mute to the device. +void USBAudioSpeaker::set_volume(float volume) { + speaker::Speaker::set_volume(volume); + this->parent_->set_speaker_volume_level(volume); +} + +void USBAudioSpeaker::set_mute_state(bool mute_state) { + speaker::Speaker::set_mute_state(mute_state); + this->parent_->set_speaker_mute_state(mute_state); +} + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/speaker/usb_audio_speaker.h b/esphome/components/usb_audio/speaker/usb_audio_speaker.h new file mode 100644 index 00000000000..a4edda7e0ea --- /dev/null +++ b/esphome/components/usb_audio/speaker/usb_audio_speaker.h @@ -0,0 +1,112 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_USB_AUDIO + +#include "esphome/components/usb_audio/usb_audio.h" + +#include "esphome/components/audio/audio.h" +#include "esphome/components/speaker/speaker.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::usb_audio { + +class USBAudioSpeaker : public speaker::Speaker, + public Component, + public Parented, + public USBAudioStream { + public: + USBAudioSpeaker(const audio::AudioStreamInfo &stream_info, uint32_t buffer_size, uint32_t write_timeout_ms) + : USBAudioStream(buffer_size), usb_stream_info_(stream_info), write_timeout_ms_(write_timeout_ms) { + this->audio_stream_info_ = stream_info; + } + + void setup() override; + void dump_config() override; + void loop() override; + + // Safe from any task; loop() acts on the request. + void start() override; + void stop() override; + void finish() override; + + size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override; + size_t play(const uint8_t *data, size_t length) override; + bool has_buffered_data() const override; + + void set_volume(float volume) override; + void set_mute_state(bool mute_state) override; + + audio::AudioStreamInfo get_usb_stream_info() override { return this->usb_stream_info_; } + void on_stream_state(StreamEvent event) override; + bool stream_idle() const override { return this->writes_in_flight_ == 0; } + void on_transfer_taken() override; + + protected: + static constexpr uint8_t COMMAND_START = 1 << 0; + static constexpr uint8_t COMMAND_STOP = 1 << 1; + static constexpr uint8_t COMMAND_FINISH = 1 << 2; + void request_(uint8_t set, uint8_t clear); + void deliver_reports_(); + void begin_finishing_(); + void stop_(); + size_t play_internal_(const uint8_t *data, size_t length, uint32_t budget_ms); + /// Write silence until a whole USB transfer is padded, so the driver sends a final partial transfer. + void pad_with_silence_(); + /// Write to the driver, counting the bytes in ring_bytes_ (and in ring_padding_ for silence padding). + esp_err_t write_(const uint8_t *data, size_t length, uint32_t timeout_ms, bool padding); + /// Bytes that can be written without filling the driver's ring buffer past buffer_size_. + size_t ring_room_() const; + bool has_buffered_data_(int64_t now_us) const; + + // The configured format the USB stream runs with. Callers such as the media pipeline set audio_stream_info_ for + // each item, so play() checks it against this instead of reopening the stream with another format. + const audio::AudioStreamInfo usb_stream_info_; + uint32_t write_timeout_ms_; + // Frame-aligned size of each driver write, set in setup(). + uint32_t write_chunk_{0}; + // The driver's packet sizing, mirrored so on_transfer_taken() knows how much each transfer takes: whole frames per + // 1 ms packet, plus a frame whenever the remainder of sample_rate / 1000 adds up. Set in setup(). + uint32_t frame_bytes_{0}; + uint32_t packet_bytes_{0}; + uint32_t packet_frac_{0}; + // Bytes in the largest USB transfer, how long one transfer plays and how long all the driver keeps in flight play, + // and the batch of frames reported at a time; set in setup(). + uint32_t transfer_bytes_{0}; + uint32_t transfer_us_{0}; + uint32_t in_flight_us_{0}; + uint32_t report_frames_{0}; + // Silence still to write while finishing. + uint32_t pad_remaining_{0}; + // Bytes in the driver's ring buffer: added before each write, since the driver can take a transfer inside it, and + // taken off for each transfer it takes. ring_padding_ is the silence padding at its end. + std::atomic ring_bytes_{0}; + std::atomic ring_padding_{0}; + // Low 32 bits of esp_timer_get_time() when the driver last took a transfer. + std::atomic last_taken_us_{0}; + // on_transfer_taken() runs on the USB driver's task, which must not block, and inside writes, so its state is + // guarded by a spinlock and the reports it collects are delivered from loop(). Reset while the stream is stopped. + portMUX_TYPE taken_lock_ = portMUX_INITIALIZER_UNLOCKED; + uint32_t packet_frac_accum_{0}; + uint32_t unreported_frames_{0}; + uint32_t report_frames_pending_{0}; + int64_t report_finish_us_{0}; + // When the last transfer taken finishes playing; reset on a start, so the next one starts the chain from now. + int64_t last_finish_us_{0}; + std::atomic commands_{0}; + // play() calls and silence padding that may be using the device handle; the hub closes a lost device only at zero. + std::atomic writes_in_flight_{0}; + // Set while play() rejects audio in a format other than usb_stream_info_, so the error is logged once. + std::atomic format_mismatch_{false}; + bool finishing_{false}; + // finish() arrived while the stream was still starting. + bool finish_after_start_{false}; +}; + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/usb_audio.cpp b/esphome/components/usb_audio/usb_audio.cpp new file mode 100644 index 00000000000..ec9537e1dce --- /dev/null +++ b/esphome/components/usb_audio/usb_audio.cpp @@ -0,0 +1,876 @@ +#include "usb_audio.h" + +#ifdef USE_USB_AUDIO + +#include +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +extern "C" { +#include "esp_err.h" +} + +namespace esphome::usb_audio { + +ESPHOME_LOG_TAG(TAG, "usb_audio"); + +// The driver task completes and resubmits the speaker's transfers, with only CONFIG_UAC_NUM_ISOC_URBS of them +// queued on the bus, so it runs above the network stack like the I2S speaker task; each wakeup is short. +static constexpr UBaseType_t UAC_TASK_PRIORITY = 19; +static constexpr uint32_t UAC_TASK_STACK_SIZE = 4096; +// Below the driver's task, so its transfers are not held up by control requests. +static constexpr UBaseType_t WORKER_TASK_PRIORITY = 5; +static constexpr uint32_t WORKER_TASK_STACK_SIZE = 4096; +// The worker retries volume and mute up to this many times, this far apart. +static constexpr uint8_t MAX_CONTROL_ATTEMPTS = 5; +static constexpr uint32_t CONTROL_RETRY_MS = 100; +// A failed close is retried up to this many times, this far apart, before the handle is given up. +static constexpr uint8_t MAX_CLOSE_ATTEMPTS = 5; +static constexpr uint32_t CLOSE_RETRY_MS = 500; + +static const LogString *endpoint_name(Endpoint endpoint) { + return endpoint == Endpoint::SPEAKER ? LOG_STR("Speaker") : LOG_STR("Microphone"); +} + +static uint8_t discrete_rate_count(const uac_host_dev_alt_param_t &alt) { + return std::min(alt.sample_freq_type, UAC_FREQ_NUM_MAX); +} + +static bool alt_supports_rate(const uac_host_dev_alt_param_t &alt, uint32_t sample_rate) { + if (alt.sample_freq_type == 0) { + return sample_rate >= alt.sample_freq_lower && sample_rate <= alt.sample_freq_upper; + } + const uint8_t count = discrete_rate_count(alt); + return std::find(alt.sample_freq, alt.sample_freq + count, sample_rate) != alt.sample_freq + count; +} + +void USBAudioComponent::setup() { + this->event_queue_ = xQueueCreateStatic(EVENT_QUEUE_LENGTH, sizeof(USBAudioEvent), this->event_queue_storage_.data(), + &this->event_queue_struct_); + this->op_queue_ = + xQueueCreateStatic(OP_QUEUE_LENGTH, sizeof(Op), this->op_queue_storage_.data(), &this->op_queue_struct_); + + uac_host_driver_config_t uac_cfg = { + .create_background_task = true, + .task_priority = UAC_TASK_PRIORITY, + .stack_size = UAC_TASK_STACK_SIZE, + .core_id = 0, + .callback = &USBAudioComponent::driver_event_stub, + .callback_arg = this, + }; + + esp_err_t err = uac_host_install(&uac_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "uac_host_install failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + this->uac_installed_ = true; + + // Opening, starting, suspending and closing streams and setting volume are control transfers that can block for + // seconds, so they run here instead of on the main loop. + if (!this->worker_.create(&USBAudioComponent::worker_task, "usb_audio", WORKER_TASK_STACK_SIZE, this, + WORKER_TASK_PRIORITY, false)) { + this->mark_failed(LOG_STR("Failed to create worker task")); + return; + } + // The USB callbacks and the worker wake the loop when there is work. + this->disable_loop(); +} + +void USBAudioComponent::loop() { + this->process_events_(App.get_loop_component_start_time()); + + if (const uint32_t errors = this->transfer_errors_.exchange(0); errors != 0) { + ESP_LOGW(TAG, "%" PRIu32 " USB transfer errors", errors); + } + + // Stay awake while a lost device still waits to be closed. + if (std::any_of(this->endpoints_.begin(), this->endpoints_.end(), + [](const EndpointState &ep) { return ep.closing != nullptr; })) { + return; + } + this->disable_loop(); +} + +void USBAudioComponent::process_events_(uint32_t now) { + this->finish_closes_(now); + + USBAudioEvent event; + // Only the events already queued: one posted again while this runs waits for the next loop(). + for (UBaseType_t queued = uxQueueMessagesWaiting(this->event_queue_); + queued > 0 && xQueueReceive(this->event_queue_, &event, 0) == pdTRUE; queued--) { + this->process_event_(event, now); + } +} + +void USBAudioComponent::dump_config() { + const auto &speaker = this->endpoint_(Endpoint::SPEAKER); + const auto µphone = this->endpoint_(Endpoint::MICROPHONE); + ESP_LOGCONFIG(TAG, + "USB Audio:\n" + " Device connected: %s\n" + " Speaker registered: %s\n" + " Microphone registered: %s", + YESNO(speaker.handle.load() != nullptr || microphone.handle.load() != nullptr), + YESNO(speaker.platform != nullptr), YESNO(microphone.platform != nullptr)); +} + +bool USBAudioComponent::teardown() { + if (this->event_queue_ == nullptr) { + return true; + } + // Stop the platforms first, then let the worker close each device once its platform is idle. + this->begin_close_(Endpoint::SPEAKER); + this->begin_close_(Endpoint::MICROPHONE); + // The loop time does not advance while components tear down. + const uint32_t now = millis(); + this->process_events_(now); + if (!this->finish_closes_(now)) { + return false; + } + // Wait for the worker to finish whatever it was still running. + if (uxQueueMessagesWaiting(this->op_queue_) != 0 || this->driver_busy_) { + return false; + } + xQueueReset(this->event_queue_); + + if (this->uac_installed_) { + esp_err_t err = uac_host_uninstall(); + if (err == ESP_ERR_INVALID_STATE) { + return false; + } + if (err != ESP_OK) { + ESP_LOGW(TAG, "uac_host_uninstall returned %s", esp_err_to_name(err)); + } + this->uac_installed_ = false; + } + return true; +} + +void USBAudioComponent::resume(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + ep.active = true; + ep.request_seq++; + const uac_host_device_handle_t handle = ep.handle; + if (handle == nullptr) { + if (ep.open_failed && !ep.open_pending) { + // The device is still plugged in but its last open failed; try again for this start. + ep.open_failed = false; + this->open_stream_(endpoint, ep.failed_addr, ep.failed_iface); + return; + } + ESP_LOGD(TAG, "%s interface not connected yet", LOG_STR_ARG(endpoint_name(endpoint))); + return; + } + if (ep.start_pending) { + // The pending start reports back to the platform, and checks `active` first. + return; + } + if (!ep.stream_started) { + if (ep.start_rejected) { + // Already probed and logged for this device; a retrying caller must not flood the log. + this->stream_failed_(endpoint); + return; + } + this->start_stream_(endpoint); + return; + } + // Queued even while an earlier resume or suspend is pending: the worker runs them in order, so the driver ends in + // the latest requested state, and only this request's RESUMED reports back. + Op op; + op.type = Op::Type::RESUME; + op.endpoint = endpoint; + op.handle = handle; + op.seq = ep.request_seq; + if (!this->queue_op_(op)) { + this->stream_failed_(endpoint); + } +} + +void USBAudioComponent::suspend(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + ep.active = false; + ep.request_seq++; + const uac_host_device_handle_t handle = ep.handle; + // A start still running on the worker is suspended once its STARTED arrives. + if (!ep.stream_started || handle == nullptr) { + return; + } + Op op; + op.type = Op::Type::SUSPEND; + op.endpoint = endpoint; + op.handle = handle; + this->queue_op_(op); +} + +bool USBAudioComponent::enter_driver_(uint32_t &timeout_ms) { + const uint32_t start = millis(); + while (true) { + // Counted before the busy check, so the worker waits for this call to leave the driver before it takes it. + this->driver_users_.fetch_add(1); + if (!this->driver_busy_) { + const uint32_t waited = millis() - start; + timeout_ms = waited < timeout_ms ? timeout_ms - waited : 0; + return true; + } + this->driver_users_.fetch_sub(1); + // A control request takes a few milliseconds, so go on as soon as the worker is done with it. + if (millis() - start >= timeout_ms) { + return false; + } + vTaskDelay(1); + } +} + +esp_err_t USBAudioComponent::read_microphone(uint8_t *buffer, size_t size, size_t *bytes_read, uint32_t timeout_ms) { + *bytes_read = 0; + if (!this->enter_driver_(timeout_ms)) { + return ESP_ERR_TIMEOUT; + } + uac_host_device_handle_t handle = this->endpoint_(Endpoint::MICROPHONE).handle; + if (handle == nullptr) { + this->driver_users_.fetch_sub(1); + return ESP_ERR_INVALID_STATE; + } + // The driver collects up to `size` bytes for the whole timeout and returns what it got, or ESP_ERR_TIMEOUT when + // nothing arrived. + uint32_t actual = 0; + esp_err_t err = uac_host_device_read(handle, buffer, static_cast(size), &actual, pdMS_TO_TICKS(timeout_ms)); + this->driver_users_.fetch_sub(1); + *bytes_read = actual; + return err; +} + +esp_err_t USBAudioComponent::write_speaker(const uint8_t *data, size_t length, uint32_t timeout_ms) { + if (!this->enter_driver_(timeout_ms)) { + // A non-blocking caller retries later. + return ESP_ERR_TIMEOUT; + } + uac_host_device_handle_t handle = this->endpoint_(Endpoint::SPEAKER).handle; + if (handle == nullptr) { + this->driver_users_.fetch_sub(1); + return ESP_ERR_INVALID_STATE; + } + esp_err_t err = uac_host_device_write(handle, const_cast(data), static_cast(length), + pdMS_TO_TICKS(timeout_ms)); + this->driver_users_.fetch_sub(1); + if (err == ESP_FAIL || err == ESP_ERR_INVALID_STATE) { + // Ring buffer full or stream inactive; report as timeout so callers back off and retry. + err = ESP_ERR_TIMEOUT; + } + return err; +} + +void USBAudioComponent::process_event_(const USBAudioEvent &event, uint32_t now) { + switch (event.type) { + case USBAudioEvent::Type::CONNECTED: { + auto &ep = this->endpoint_(event.endpoint); + if (ep.closing != nullptr) { + // A device that came back waits until its old handle is closed; the queue keeps draining meanwhile, so the + // worker's CLOSED can arrive. + ep.connect_deferred = true; + ep.deferred_addr = event.addr; + ep.deferred_iface = event.iface_num; + break; + } + this->open_stream_(event.endpoint, event.addr, event.iface_num); + break; + } + case USBAudioEvent::Type::DISCONNECTED: + this->handle_disconnect_(event.handle); + break; + case USBAudioEvent::Type::OPENED: + this->on_opened_(event); + break; + case USBAudioEvent::Type::STARTED: + this->on_started_(event); + break; + case USBAudioEvent::Type::RESUMED: + this->on_resumed_(event); + break; + case USBAudioEvent::Type::CLOSED: + this->on_closed_(event, now); + break; + } +} + +void USBAudioComponent::open_stream_(Endpoint endpoint, uint8_t addr, uint8_t iface_num) { + auto &ep = this->endpoint_(endpoint); + const LogString *name = endpoint_name(endpoint); + if (ep.platform == nullptr) { + ESP_LOGD(TAG, "%s interface connected but not configured", LOG_STR_ARG(name)); + return; + } + if (ep.open_pending) { + // Either a duplicate, or a replacement for a device that went away during the pending open; on_opened_() tells + // them apart and replays a replacement. + ep.connect_deferred = true; + ep.deferred_addr = addr; + ep.deferred_iface = iface_num; + return; + } + if (ep.handle.load() != nullptr) { + ESP_LOGW(TAG, "%s interface already open, ignoring duplicate event", LOG_STR_ARG(name)); + return; + } + Op op; + op.type = Op::Type::OPEN; + op.endpoint = endpoint; + op.addr = addr; + op.iface_num = iface_num; + if (!this->queue_op_(op)) { + // The event is already off the queue, so post it again for a later loop() instead of losing the device. + USBAudioEvent event; + event.type = USBAudioEvent::Type::CONNECTED; + event.endpoint = endpoint; + event.addr = addr; + event.iface_num = iface_num; + this->post_event_(event); + return; + } + ep.open_pending = true; +} + +void USBAudioComponent::on_opened_(const USBAudioEvent &event) { + auto &ep = this->endpoint_(event.endpoint); + ep.open_pending = false; + bool lost_meanwhile = false; + for (auto &lost : this->lost_while_opening_) { + if (event.ok && lost == event.handle) { + lost = nullptr; + lost_meanwhile = true; + } + } + if (std::none_of(this->endpoints_.begin(), this->endpoints_.end(), + [](const EndpointState &other) { return other.open_pending; })) { + // Only a pending open can still claim an entry; a failed one reports no handle, so its entry would stay behind + // and could match a later device that reuses the freed handle. + this->lost_while_opening_.fill(nullptr); + } + if (!event.ok) { + if (ep.connect_deferred) { + // A newer connect arrived during the failed open; try that one. + ep.connect_deferred = false; + this->open_stream_(event.endpoint, ep.deferred_addr, ep.deferred_iface); + return; + } + // Keep where the device is, so the next start() retries the open without a replug. + ep.open_failed = true; + ep.failed_addr = event.addr; + ep.failed_iface = event.iface_num; + if (ep.active) { + this->stream_failed_(event.endpoint); + } + return; + } + ep.open_failed = false; + ep.handle = event.handle; + ep.iface = event.iface_num; + ESP_LOGI(TAG, "%s interface %u connected (addr=%u)", LOG_STR_ARG(endpoint_name(event.endpoint)), ep.iface, + event.addr); + if (lost_meanwhile) { + // It went away while the worker was opening it; a connect deferred meanwhile is opened once it is closed. + this->handle_disconnect_(event.handle); + return; + } + // The device that was being opened is still here, so a connect deferred meanwhile was a duplicate. + ep.connect_deferred = false; + if (ep.active) { + this->start_stream_(event.endpoint); + } +} + +void USBAudioComponent::start_stream_(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + Op op; + op.type = Op::Type::START; + op.endpoint = endpoint; + op.handle = ep.handle; + if (!this->queue_op_(op)) { + this->stream_failed_(endpoint); + return; + } + ep.start_pending = true; +} + +void USBAudioComponent::on_started_(const USBAudioEvent &event) { + auto &ep = this->endpoint_(event.endpoint); + ep.start_pending = false; + if (event.handle != ep.handle.load()) { + // The device went away meanwhile; the platform was already told. + return; + } + if (!event.ok) { + ep.start_rejected = event.rejected; + if (ep.active) { + this->stream_failed_(event.endpoint); + } + return; + } + ep.stream_started = true; + if (event.endpoint == Endpoint::SPEAKER) { + // Send the current volume and mute to the newly started stream. + this->volume_.pending = true; + this->mute_.pending = true; + this->queue_controls_(); + } + if (!ep.active) { + // The platform stopped while the stream was starting. + this->suspend(event.endpoint); + return; + } + ep.platform->on_stream_state(StreamEvent::RUNNING); +} + +void USBAudioComponent::on_resumed_(const USBAudioEvent &event) { + auto &ep = this->endpoint_(event.endpoint); + if (event.handle != ep.handle.load() || event.seq != ep.request_seq || !ep.active) { + // The device went away, or a later suspend or resume is queued behind this one and decides the state. + return; + } + if (!event.ok) { + this->stream_failed_(event.endpoint); + return; + } + ep.platform->on_stream_state(StreamEvent::RUNNING); +} + +void USBAudioComponent::stream_failed_(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + // The platform goes back to stopped, so a reconnect must not restart the stream; its next start() retries. + ep.active = false; + ep.platform->on_stream_state(StreamEvent::FAILED); +} + +void USBAudioComponent::set_speaker_volume_level(float volume) { + this->speaker_volume_ = static_cast(std::lround(std::clamp(volume, 0.0f, 1.0f) * 100.0f)); + this->volume_.pending = true; + this->queue_controls_(); +} + +void USBAudioComponent::set_speaker_mute_state(bool mute_state) { + this->speaker_muted_ = mute_state; + this->mute_.pending = true; + this->queue_controls_(); +} + +void USBAudioComponent::queue_controls_() { + if (this->op_queue_ == nullptr) { + return; + } + if (this->controls_queued_.exchange(true)) { + // The queued op sends the latest values, so lifecycle ops keep their queue slots. + return; + } + Op op; + op.type = Op::Type::CONTROLS; + if (xQueueSend(this->op_queue_, &op, 0) != pdTRUE) { + // The controls stay pending and the next start or change queues them again. + this->controls_queued_ = false; + } +} + +void USBAudioComponent::handle_disconnect_(uac_host_device_handle_t handle) { + for (size_t i = 0; i < this->endpoints_.size(); i++) { + auto &ep = this->endpoints_[i]; + if (handle != nullptr && ep.handle.load() == handle) { + const auto endpoint = static_cast(i); + ESP_LOGI(TAG, "%s interface %u disconnected", LOG_STR_ARG(endpoint_name(endpoint)), ep.iface); + this->begin_close_(endpoint); + return; + } + } + if (std::any_of(this->endpoints_.begin(), this->endpoints_.end(), + [](const EndpointState &ep) { return ep.open_pending; })) { + // Its open is still running on the worker; close it once it reports back. + for (auto &lost : this->lost_while_opening_) { + if (lost == nullptr || lost == handle) { + lost = handle; + return; + } + } + // Both endpoints are already waiting on a lost open; the oldest one is the least likely to still report back. + this->lost_while_opening_[0] = handle; + return; + } + ESP_LOGD(TAG, "Disconnect event for an interface with no active stream"); +} + +void USBAudioComponent::begin_close_(Endpoint endpoint) { + auto &ep = this->endpoint_(endpoint); + // Platform reads and writes see nullptr from here on and return at once. + const uac_host_device_handle_t handle = ep.handle.exchange(nullptr); + if (handle == nullptr) { + return; + } + ep.closing = handle; + ep.close_queued = false; + // `active` is kept, so the stream restarts when the device returns. + ep.platform->on_stream_state(StreamEvent::LOST); +} + +bool USBAudioComponent::finish_closes_(uint32_t now) { + bool done = true; + for (size_t i = 0; i < this->endpoints_.size(); i++) { + auto &ep = this->endpoints_[i]; + if (ep.closing == nullptr) { + continue; + } + done = false; + // Closing frees the driver's interface, so wait until no platform task can still be using the handle. + if (ep.close_queued || !ep.platform->stream_idle()) { + continue; + } + if (ep.close_attempts > 0 && static_cast(now - ep.close_retry_ms) < 0) { + // A failed close waits a moment before it is retried. + continue; + } + Op op; + op.type = Op::Type::CLOSE; + op.endpoint = static_cast(i); + op.handle = ep.closing; + op.stop = ep.stream_started; + ep.close_queued = this->queue_op_(op); + } + return done; +} + +void USBAudioComponent::on_closed_(const USBAudioEvent &event, uint32_t now) { + auto &ep = this->endpoint_(event.endpoint); + ep.close_queued = false; + if (!event.ok && ++ep.close_attempts < MAX_CLOSE_ATTEMPTS) { + // Keep the handle, so finish_closes_() retries the close. + ep.close_retry_ms = now + CLOSE_RETRY_MS; + return; + } + if (!event.ok) { + ESP_LOGE(TAG, "Giving up closing the %s interface after %u attempts", LOG_STR_ARG(endpoint_name(event.endpoint)), + ep.close_attempts); + } + ep.close_attempts = 0; + ep.closing = nullptr; + ep.stream_started = false; + ep.start_rejected = false; + ep.start_pending = false; + if (ep.connect_deferred) { + // The device came back while its old handle was closing. + ep.connect_deferred = false; + this->open_stream_(event.endpoint, ep.deferred_addr, ep.deferred_iface); + } +} + +bool USBAudioComponent::queue_op_(const Op &op) { + if (xQueueSend(this->op_queue_, &op, 0) != pdTRUE) { + ESP_LOGW(TAG, "USB audio work queue full, dropping %s request", LOG_STR_ARG(endpoint_name(op.endpoint))); + return false; + } + return true; +} + +void USBAudioComponent::post_event_(const USBAudioEvent &event) { + if (xQueueSend(this->event_queue_, &event, 0) != pdTRUE) { + ESP_LOGW(TAG, "USB audio event queue full, dropping event"); + } + this->enable_loop_soon_any_context(); +} + +void USBAudioComponent::worker_task(void *param) { + auto *self = static_cast(param); + Op op; + while (true) { + if (xQueueReceive(self->op_queue_, &op, portMAX_DELAY) != pdTRUE) { + continue; + } + self->lock_driver_(); + self->run_op_(op); + self->unlock_driver_(); + } +} + +void USBAudioComponent::lock_driver_() { + this->driver_busy_ = true; + // Reads and writes give up within their timeout; wait for those already inside the driver. + while (this->driver_users_ != 0) { + vTaskDelay(1); + } +} + +void USBAudioComponent::unlock_driver_() { this->driver_busy_ = false; } + +void USBAudioComponent::run_op_(const Op &op) { + const LogString *name = endpoint_name(op.endpoint); + USBAudioEvent result; + result.endpoint = op.endpoint; + result.handle = op.handle; + + switch (op.type) { + case Op::Type::OPEN: { + uint32_t buffer_size = this->endpoint_(op.endpoint).platform->get_buffer_size(); + uint32_t threshold = std::max(buffer_size / 4, 1U); + if (op.endpoint == Endpoint::SPEAKER) { + // The driver raises TX_DONE after taking a transfer only while the ring buffer holds at most the threshold, + // and it needs the threshold below the size. The speaker never fills past its buffer_size, so it hears about + // every transfer even when a waiting write lands right after one. + threshold = buffer_size; + buffer_size += SPEAKER_RING_SLACK; + } + uac_host_device_config_t dev_cfg = { + .addr = op.addr, + .iface_num = op.iface_num, + .buffer_size = buffer_size, + .buffer_threshold = threshold, + .callback = &USBAudioComponent::device_event_stub, + .callback_arg = this, + }; + uac_host_device_handle_t handle{nullptr}; + esp_err_t err = uac_host_device_open(&dev_cfg, &handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to open %s interface: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + result.type = USBAudioEvent::Type::OPENED; + result.ok = err == ESP_OK; + result.handle = handle; + result.addr = op.addr; + result.iface_num = op.iface_num; + this->post_event_(result); + return; + } + case Op::Type::START: { + result.type = USBAudioEvent::Type::STARTED; + uac_host_stream_config_t stm_cfg{}; + if (this->populate_stream_config_(op.endpoint, op.handle, stm_cfg, result.rejected)) { + esp_err_t err = uac_host_device_start(op.handle, &stm_cfg); + // Already started or active is ESP_OK; ESP_ERR_INVALID_STATE means the interface is not ready. + result.ok = err == ESP_OK; + // The host cannot allocate the endpoint's pipe, for example when its max packet size exceeds the USB FIFO. + result.rejected = err == ESP_ERR_NOT_SUPPORTED; + if (!result.ok) { + ESP_LOGW(TAG, "Failed to start %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + } + this->post_event_(result); + return; + } + case Op::Type::RESUME: { + esp_err_t err = uac_host_device_resume(op.handle); + result.type = USBAudioEvent::Type::RESUMED; + result.seq = op.seq; + // Already started or active is ESP_OK; ESP_ERR_INVALID_STATE means the interface is not ready. + result.ok = err == ESP_OK; + if (!result.ok) { + ESP_LOGW(TAG, "Failed to resume %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + this->post_event_(result); + return; + } + case Op::Type::SUSPEND: { + esp_err_t err = uac_host_device_suspend(op.handle); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "Failed to suspend %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + return; + } + case Op::Type::CLOSE: { + if (op.stop) { + esp_err_t err = uac_host_device_stop(op.handle); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + ESP_LOGW(TAG, "Failed to stop %s stream: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + } + } + esp_err_t err = uac_host_device_close(op.handle); + result.type = USBAudioEvent::Type::CLOSED; + result.ok = err == ESP_OK; + if (!result.ok) { + // The interface may still be held, so the main loop keeps the handle and retries. + ESP_LOGW(TAG, "uac_host_device_close failed: %s", esp_err_to_name(err)); + this->post_event_(result); + return; + } + if (op.endpoint == Endpoint::SPEAKER) { + // A reconnected device may support different controls. + for (ControlState *control : {&this->volume_, &this->mute_}) { + control->supported = true; + control->attempts = 0; + } + } + this->post_event_(result); + return; + } + case Op::Type::CONTROLS: { + // Cleared first, so a change made during the transfer queues another op. + this->controls_queued_ = false; + if (this->apply_pending_speaker_controls_()) { + // Give the device a moment before retrying; the queue keeps any other work in order. + this->unlock_driver_(); + vTaskDelay(pdMS_TO_TICKS(CONTROL_RETRY_MS)); + this->lock_driver_(); + this->queue_controls_(); + } + return; + } + } +} + +bool USBAudioComponent::populate_stream_config_(Endpoint endpoint, uac_host_device_handle_t handle, + uac_host_stream_config_t &stream_config, bool &rejected) { + const LogString *name = endpoint_name(endpoint); + const audio::AudioStreamInfo info = this->endpoint_(endpoint).platform->get_usb_stream_info(); + const uint8_t channels = info.get_channels(); + const uint8_t bits = info.get_bits_per_sample(); + const uint32_t sample_rate = info.get_sample_rate(); + + uac_host_dev_info_t dev_info{}; + esp_err_t err = uac_host_get_device_info(handle, &dev_info); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to query %s interface info: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + return false; + } + + // uac_host_device_start() picks the alternate setting itself from channels, bit depth and rate: the last match with + // discrete rates, or the first match with a rate range. Find the one it will pick, so its subframe size is checked. + uint8_t selected_alt = 0; + uint8_t selected_subframe = 0; + for (uint8_t alt = 1; alt <= dev_info.iface_alt_num; alt++) { + uac_host_dev_alt_param_t alt_param{}; + if (uac_host_get_device_alt_param(handle, alt, &alt_param) != ESP_OK || alt_param.channels != channels || + alt_param.bit_resolution != bits || !alt_supports_rate(alt_param, sample_rate)) { + continue; + } + selected_alt = alt; + selected_subframe = alt_param.subframe_size; + if (alt_param.sample_freq_type == 0) { + break; + } + } + + // ESPHome's samples are packed, so a device that pads them (e.g. 24-bit samples in 4-byte subframes) cannot be + // streamed unchanged. + if (selected_alt != 0 && selected_subframe * 8 == bits) { + stream_config.channels = channels; + stream_config.bit_resolution = bits; + stream_config.sample_freq = sample_rate; + stream_config.flags = 0; + return true; + } + + rejected = true; + if (selected_alt != 0) { + ESP_LOGW(TAG, "%s interface %u would stream alt %u with %u-bit samples padded to %u bytes, which is not supported", + LOG_STR_ARG(name), dev_info.iface_num, selected_alt, bits, selected_subframe); + } + ESP_LOGW(TAG, "%s interface %u does not support requested format (channels=%u bits=%u sample_rate=%" PRIu32 " Hz)", + LOG_STR_ARG(name), dev_info.iface_num, channels, bits, sample_rate); + for (uint8_t alt = 1; alt <= dev_info.iface_alt_num; alt++) { + uac_host_dev_alt_param_t alt_param{}; + if (uac_host_get_device_alt_param(handle, alt, &alt_param) != ESP_OK) { + continue; + } + if (alt_param.sample_freq_type == 0) { + ESP_LOGI(TAG, " alt %u: channels=%u bits=%u subframe=%u bytes sample rate range=%" PRIu32 "-%" PRIu32 " Hz", alt, + alt_param.channels, alt_param.bit_resolution, alt_param.subframe_size, alt_param.sample_freq_lower, + alt_param.sample_freq_upper); + continue; + } + ESP_LOGI(TAG, " alt %u: channels=%u bits=%u subframe=%u bytes sample rates:", alt, alt_param.channels, + alt_param.bit_resolution, alt_param.subframe_size); + const uint8_t count = discrete_rate_count(alt_param); + for (uint8_t i = 0; i < count; i++) { + // Some devices list a rate more than once. + if (std::find(alt_param.sample_freq, alt_param.sample_freq + i, alt_param.sample_freq[i]) == + alt_param.sample_freq + i) { + ESP_LOGI(TAG, " %" PRIu32 " Hz", alt_param.sample_freq[i]); + } + } + } + return false; +} + +bool USBAudioComponent::apply_pending_speaker_controls_() { + const uac_host_device_handle_t handle = this->endpoint_(Endpoint::SPEAKER).handle; + if (handle == nullptr) { + // Sent once the stream starts. + return false; + } + bool retry = false; + if (this->volume_.pending.exchange(false) && this->volume_.supported) { + retry |= this->apply_control_(this->volume_, uac_host_device_set_volume(handle, this->speaker_volume_), + LOG_STR("volume")); + } + if (this->mute_.pending.exchange(false) && this->mute_.supported) { + retry |= this->apply_control_(this->mute_, uac_host_device_set_mute(handle, this->speaker_muted_), LOG_STR("mute")); + } + return retry; +} + +bool USBAudioComponent::apply_control_(ControlState &control, esp_err_t err, const LogString *name) { + if (err == ESP_OK) { + control.attempts = 0; + return false; + } + if (err == ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(TAG, "Speaker %s control not supported by device", LOG_STR_ARG(name)); + control.supported = false; + return false; + } + if (err == ESP_ERR_INVALID_STATE) { + // The stream is not started yet; starting it queues the controls again. + control.pending = true; + return false; + } + if (++control.attempts >= MAX_CONTROL_ATTEMPTS) { + ESP_LOGW(TAG, "Failed to set speaker %s: %s", LOG_STR_ARG(name), esp_err_to_name(err)); + control.attempts = 0; + return false; + } + control.pending = true; + return true; +} + +void USBAudioComponent::driver_event_stub(uint8_t addr, uint8_t iface_num, const uac_host_driver_event_t event, + void *user_data) { + auto *self = static_cast(user_data); + USBAudioEvent evt; + switch (event) { + case UAC_HOST_DRIVER_EVENT_TX_CONNECTED: + evt.endpoint = Endpoint::SPEAKER; + break; + case UAC_HOST_DRIVER_EVENT_RX_CONNECTED: + evt.endpoint = Endpoint::MICROPHONE; + break; + default: + return; + } + evt.type = USBAudioEvent::Type::CONNECTED; + evt.addr = addr; + evt.iface_num = iface_num; + self->post_event_(evt); +} + +void USBAudioComponent::device_event_stub(uac_host_device_handle_t handle, const uac_host_device_event_t event, + void *user_data) { + auto *self = static_cast(user_data); + if (event == UAC_HOST_DEVICE_EVENT_TX_DONE) { + auto &speaker = self->endpoint_(Endpoint::SPEAKER); + // Only the speaker's own interface; the handle is cleared first when the device goes away. + if (speaker.platform != nullptr && handle == speaker.handle.load()) { + speaker.platform->on_transfer_taken(); + } + } else if (event == UAC_HOST_DEVICE_EVENT_TRANSFER_ERROR) { + self->transfer_errors_.fetch_add(1, std::memory_order_relaxed); + self->enable_loop_soon_any_context(); + } else if (event == UAC_HOST_DRIVER_EVENT_DISCONNECTED) { + USBAudioEvent evt; + evt.type = USBAudioEvent::Type::DISCONNECTED; + evt.handle = handle; + self->post_event_(evt); + } +} + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/components/usb_audio/usb_audio.h b/esphome/components/usb_audio/usb_audio.h new file mode 100644 index 00000000000..a283fb18f2e --- /dev/null +++ b/esphome/components/usb_audio/usb_audio.h @@ -0,0 +1,239 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_USB_AUDIO + +#include +#include + +#include "esphome/components/audio/audio.h" +#include "esphome/core/component.h" +#include "esphome/core/static_task.h" + +extern "C" { +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "usb/uac_host.h" +} + +namespace esphome::usb_audio { + +enum class Endpoint : uint8_t { + SPEAKER, + MICROPHONE, +}; + +/// What the hub reports to a platform about its stream. +enum class StreamEvent : uint8_t { + // The stream runs. + RUNNING, + // The device went away; the hub restarts the stream when it returns while the platform still wants it. + LOST, + // The stream failed to start; the platform goes back to stopped and its next start() retries. + FAILED, +}; + +/// A speaker or microphone platform that streams through the hub. The hub calls it on the main loop only. +class USBAudioStream { + public: + explicit USBAudioStream(uint32_t buffer_size) : buffer_size_(buffer_size) {} + + uint32_t get_buffer_size() const { return this->buffer_size_; } + /// The fixed format the hub opens the stream with. + virtual audio::AudioStreamInfo get_usb_stream_info() = 0; + virtual void on_stream_state(StreamEvent event) = 0; + /// No platform task is inside a read or write call on the device; the hub closes a lost device only then. + virtual bool stream_idle() const = 0; + /// Speaker only: the driver tried to take its next transfer from the ring buffer, which it does each time a transfer + /// completes and on each write, and takes it only when the ring buffer holds all of it. Called on the USB driver's + /// task or inside a write. + virtual void on_transfer_taken() {} + + protected: + uint32_t buffer_size_; +}; + +/// Posted to the main loop, by the USB driver task (CONNECTED, DISCONNECTED) or the hub's worker (the results). +struct USBAudioEvent { + enum class Type : uint8_t { + CONNECTED, + // The driver reports disconnects through the device callback, not the driver callback. + DISCONNECTED, + OPENED, + STARTED, + RESUMED, + CLOSED, + }; + + uac_host_device_handle_t handle{nullptr}; + uint8_t addr{0}; + uint8_t iface_num{0}; + // RESUMED: the request it answers; only the latest request's result is acted on. + uint8_t seq{0}; + Type type{Type::CONNECTED}; + Endpoint endpoint{Endpoint::SPEAKER}; + bool ok{false}; + // STARTED: the start will fail the same way until the device reconnects: the device does not support the configured + // format, or the host cannot allocate its pipe. + bool rejected{false}; +}; + +class USBAudioComponent : public Component { + public: + void setup() override; + void dump_config() override; + void loop() override; + // Just after usb_host, which installs the USB host library that uac_host_install needs. + float get_setup_priority() const override { return setup_priority::BUS - 1.0f; } + bool teardown() override; + + void register_speaker(USBAudioStream *speaker) { this->endpoint_(Endpoint::SPEAKER).platform = speaker; } + void register_microphone(USBAudioStream *microphone) { this->endpoint_(Endpoint::MICROPHONE).platform = microphone; } + + /// Start streaming as soon as the device is connected; the platform hears back through on_stream_state(). + void resume(Endpoint endpoint); + void suspend(Endpoint endpoint); + + /// Safe from any task; the worker sends the values to the device. + void set_speaker_volume_level(float volume); + void set_speaker_mute_state(bool mute_state); + + // Safe from any task. While the worker holds the driver for a control transfer, these wait for it within timeout_ms + // and return ESP_ERR_TIMEOUT if it is still busy, instead of blocking on the driver's interface lock for seconds. + esp_err_t read_microphone(uint8_t *buffer, size_t size, size_t *bytes_read, uint32_t timeout_ms); + esp_err_t write_speaker(const uint8_t *data, size_t length, uint32_t timeout_ms); + + protected: + static constexpr size_t EVENT_QUEUE_LENGTH = 16; + static constexpr size_t OP_QUEUE_LENGTH = 8; + // Extra driver ring buffer space above the speaker's buffer_size, see Op::Type::OPEN. + static constexpr uint32_t SPEAKER_RING_SLACK = 4; + + struct EndpointState { + // Read by the platform tasks and the worker; cleared first when the device goes away. + std::atomic handle{nullptr}; + // A lost device, closed by the worker once the platform reports stream_idle(). + uac_host_device_handle_t closing{nullptr}; + USBAudioStream *platform{nullptr}; + uint8_t iface{0}; + bool stream_started{false}; + // The platform asked for the stream and has not stopped it. + bool active{false}; + // Starting the connected device failed for good (see USBAudioEvent::rejected); cleared when it is closed. + bool start_rejected{false}; + // An open or start is queued on the worker; its result event clears it. + bool open_pending{false}; + bool start_pending{false}; + bool close_queued{false}; + // Counts resume() and suspend() calls, so a RESUMED that a later suspend overtook is ignored. + uint8_t request_seq{0}; + // A connect that arrived while the old handle was still opening or closing; opened once that finishes. + bool connect_deferred{false}; + uint8_t deferred_addr{0}; + uint8_t deferred_iface{0}; + // The last open failed; the next resume() retries it at this address and interface. + bool open_failed{false}; + uint8_t failed_addr{0}; + uint8_t failed_iface{0}; + // A failed close keeps `closing` and is retried after this time, a limited number of times. + uint8_t close_attempts{0}; + uint32_t close_retry_ms{0}; + }; + + struct ControlState { + std::atomic pending{false}; + // Only touched by the worker, and reset while the speaker is closed. + bool supported{true}; + uint8_t attempts{0}; + }; + + /// A blocking driver call, run on the worker. + struct Op { + enum class Type : uint8_t { + OPEN, + START, + RESUME, + SUSPEND, + CLOSE, + CONTROLS, + }; + + uac_host_device_handle_t handle{nullptr}; + uint8_t addr{0}; + uint8_t iface_num{0}; + // RESUME: copied to the result, see EndpointState::request_seq. + uint8_t seq{0}; + Type type{Type::OPEN}; + Endpoint endpoint{Endpoint::SPEAKER}; + // CLOSE: stop the stream before closing. + bool stop{false}; + }; + + EndpointState &endpoint_(Endpoint endpoint) { return this->endpoints_[static_cast(endpoint)]; } + const EndpointState &endpoint_(Endpoint endpoint) const { return this->endpoints_[static_cast(endpoint)]; } + void process_events_(uint32_t now); + void process_event_(const USBAudioEvent &event, uint32_t now); + void open_stream_(Endpoint endpoint, uint8_t addr, uint8_t iface_num); + void on_opened_(const USBAudioEvent &event); + void on_started_(const USBAudioEvent &event); + void on_resumed_(const USBAudioEvent &event); + void on_closed_(const USBAudioEvent &event, uint32_t now); + void start_stream_(Endpoint endpoint); + void stream_failed_(Endpoint endpoint); + void handle_disconnect_(uac_host_device_handle_t handle); + void begin_close_(Endpoint endpoint); + bool finish_closes_(uint32_t now); + bool queue_op_(const Op &op); + void queue_controls_(); + void post_event_(const USBAudioEvent &event); + + // Worker side: the only place that makes blocking driver calls after setup(). + static void worker_task(void *param); + void run_op_(const Op &op); + /// Keep platform reads and writes out of the driver while the worker uses it. + void lock_driver_(); + /// Platform side: wait, within timeout_ms, until the worker is not using the driver, and count the caller as a + /// driver user; timeout_ms is reduced by the wait. Returns false when the worker still holds it. + bool enter_driver_(uint32_t &timeout_ms); + void unlock_driver_(); + bool populate_stream_config_(Endpoint endpoint, uac_host_device_handle_t handle, + uac_host_stream_config_t &stream_config, bool &rejected); + /// Send pending volume and mute; returns true when one should be retried later. + bool apply_pending_speaker_controls_(); + bool apply_control_(ControlState &control, esp_err_t err, const LogString *name); + + static void driver_event_stub(uint8_t addr, uint8_t iface_num, uac_host_driver_event_t event, void *user_data); + static void device_event_stub(uac_host_device_handle_t handle, uac_host_device_event_t event, void *user_data); + + StaticTask worker_; + // Filled from the USB driver task and the worker, drained on the main loop. + QueueHandle_t event_queue_{nullptr}; + // Filled on the main loop (and by volume/mute changes from any task), drained by the worker. + QueueHandle_t op_queue_{nullptr}; + std::array endpoints_{}; + // Disconnects for devices whose open is still running on the worker, one per endpoint since a speakerphone opens + // two interfaces; each is closed once its OPENED arrives. + std::array lost_while_opening_{}; + // Counted on the USB driver task, logged from loop(). + std::atomic transfer_errors_{0}; + StaticQueue_t event_queue_struct_{}; + StaticQueue_t op_queue_struct_{}; + std::array event_queue_storage_{}; + std::array op_queue_storage_{}; + ControlState volume_{}; + ControlState mute_{}; + std::atomic speaker_volume_{100}; + std::atomic speaker_muted_{false}; + // A CONTROLS op is queued; the pending flags carry the latest values, so one is enough. + std::atomic controls_queued_{false}; + // Set while the worker runs a driver call; platform reads and writes counted in driver_users_ back off. + std::atomic driver_busy_{false}; + std::atomic driver_users_{0}; + bool uac_installed_{false}; +}; + +} // namespace esphome::usb_audio + +#endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 931ccf3c789..70838d897c8 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -557,6 +557,11 @@ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #define USE_SERIAL_PROXY_USB_IDENTITY #endif +// usb_host_uac supports only these variants +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) +#define USE_USB_AUDIO +#endif #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 12) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index dd837d61632..2c12eec6d47 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -108,6 +108,11 @@ dependencies: version: "1.4.1" rules: - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" + # Keep in sync with esphome/components/usb_audio/__init__.py + espressif/usb_host_uac: + version: "1.5.0" + rules: + - if: "target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esp32async/asynctcp: version: 3.4.91 # sendspin is ESP-IDF only (it needs noise-c as an ESP-IDF component, see below) diff --git a/tests/component_tests/usb_audio/__init__.py b/tests/component_tests/usb_audio/__init__.py new file mode 100644 index 00000000000..284b0c6d298 --- /dev/null +++ b/tests/component_tests/usb_audio/__init__.py @@ -0,0 +1 @@ +"""Tests for the usb_audio platforms' config validation.""" diff --git a/tests/component_tests/usb_audio/test_validation.py b/tests/component_tests/usb_audio/test_validation.py new file mode 100644 index 00000000000..c7b0300b3a1 --- /dev/null +++ b/tests/component_tests/usb_audio/test_validation.py @@ -0,0 +1,132 @@ +"""Tests for the usb_audio buffer floors, the one-platform-per-hub check and the USB FIFO requests.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import usb_audio, usb_host +from esphome.components.usb_audio import ( + CONF_USB_AUDIO_ID, + microphone as usb_microphone, + speaker as usb_speaker, +) +from esphome.config import Config +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_BUFFER_SIZE, + CONF_NUM_CHANNELS, + CONF_PLATFORM, + CONF_SAMPLE_RATE, + PlatformFramework, +) +from esphome.core import CORE, ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _stream(sample_rate: int, channels: int, bits: int, buffer_size: int) -> ConfigType: + return { + CONF_SAMPLE_RATE: sample_rate, + CONF_NUM_CHANNELS: channels, + CONF_BITS_PER_SAMPLE: float(bits), + CONF_BUFFER_SIZE: buffer_size, + } + + +def _entry(hub: str, platform: str = "usb_audio") -> ConfigType: + return {CONF_PLATFORM: platform, CONF_USB_AUDIO_ID: ID(hub)} + + +def _set_full_config( + set_core_config: SetCoreConfigCallable, **domains: list[ConfigType] +) -> None: + full = Config() + full.update(domains) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full) + + +def test_speaker_buffer_floor() -> None: + # Two transfers of 3 packets x 48 frames x 4 bytes at 48 kHz 16-bit stereo. + usb_speaker.validate_buffer_size(_stream(48000, 2, 16, 1152)) + with pytest.raises(cv.Invalid, match="at least 1152 bytes"): + usb_speaker.validate_buffer_size(_stream(48000, 2, 16, 1151)) + + +def test_speaker_floor_rounds_up_fractional_rates() -> None: + # 44.1 kHz packets carry up to 45 frames. + with pytest.raises(cv.Invalid, match="at least 1080 bytes"): + usb_speaker.validate_buffer_size(_stream(44100, 2, 16, 1079)) + + +def test_speaker_default_buffer_meets_floor() -> None: + usb_speaker.validate_buffer_size(_stream(96000, 2, 32, 6400)) + + +def test_microphone_buffer_floor() -> None: + # 16 kHz 16-bit mono is 32000 bytes/s, so two read periods (40 ms) are 1280 bytes. + usb_microphone.validate_buffer_size(_stream(16000, 1, 16, 1280)) + with pytest.raises(cv.Invalid, match="at least 1280 bytes"): + usb_microphone.validate_buffer_size(_stream(16000, 1, 16, 1279)) + + +def _unset_buffer(sample_rate: int, channels: int, bits: int) -> ConfigType: + config = _stream(sample_rate, channels, bits, 0) + del config[CONF_BUFFER_SIZE] + return config + + +def test_default_buffer_size() -> None: + assert ( + usb_speaker.validate_buffer_size(_unset_buffer(48000, 2, 16))[CONF_BUFFER_SIZE] + == 6400 + ) + assert ( + usb_microphone.validate_buffer_size(_unset_buffer(16000, 1, 16))[ + CONF_BUFFER_SIZE + ] + == 6400 + ) + + +def test_default_buffer_size_grows_to_the_floor() -> None: + # 48 kHz 16-bit stereo is 192000 bytes/s, so 40 ms need 7680 bytes, more than the 6400 default. + assert ( + usb_microphone.validate_buffer_size(_unset_buffer(48000, 2, 16))[ + CONF_BUFFER_SIZE + ] + == 7680 + ) + + +def test_one_speaker_per_hub_accepted(set_core_config: SetCoreConfigCallable) -> None: + entries = [_entry("hub_a"), _entry("hub_b"), _entry("hub_a", "i2s_audio")] + _set_full_config(set_core_config, speaker=entries) + usb_speaker.FINAL_VALIDATE_SCHEMA(entries[0]) + usb_speaker.FINAL_VALIDATE_SCHEMA(entries[1]) + + +def test_two_speakers_on_one_hub_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + entries = [_entry("hub_a"), _entry("hub_a")] + _set_full_config(set_core_config, speaker=entries) + with pytest.raises(cv.Invalid, match="Only one usb_audio speaker"): + usb_speaker.FINAL_VALIDATE_SCHEMA(entries[0]) + + +def test_two_microphones_on_one_hub_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + entries = [_entry("hub_a"), _entry("hub_a")] + _set_full_config(set_core_config, microphone=entries, speaker=[_entry("hub_a")]) + with pytest.raises(cv.Invalid, match="Only one usb_audio microphone"): + usb_microphone.FINAL_VALIDATE_SCHEMA(entries[0]) + + +def test_platforms_request_usb_fifo_space( + set_core_config: SetCoreConfigCallable, +) -> None: + # 48 kHz 16-bit stereo packets carry up to 49 frames of 4 bytes; 16 kHz 16-bit mono up to 17 frames of 2 bytes. + set_core_config(PlatformFramework.ESP32_IDF) + usb_audio.request_speaker_fifo(_stream(48000, 2, 16, 6400)) + usb_audio.request_microphone_fifo(_stream(16000, 1, 16, 6400)) + assert CORE.data[usb_host.DOMAIN][usb_host.KEY_PERIODIC_FIFO] == [34, 196] diff --git a/tests/components/usb_audio/common.yaml b/tests/components/usb_audio/common.yaml new file mode 100644 index 00000000000..7e60ca0da02 --- /dev/null +++ b/tests/components/usb_audio/common.yaml @@ -0,0 +1,21 @@ +usb_audio: + id: usb_audio_id + +microphone: + - platform: usb_audio + id: usb_mic + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 16bit + num_channels: 2 + buffer_size: 9600 + +speaker: + - platform: usb_audio + id: usb_speaker + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 16bit + num_channels: 2 + buffer_size: 6400 + write_timeout: 20ms diff --git a/tests/components/usb_audio/test.esp32-p4-idf.yaml b/tests/components/usb_audio/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..53588849d1a --- /dev/null +++ b/tests/components/usb_audio/test.esp32-p4-idf.yaml @@ -0,0 +1,2 @@ +packages: + usb_audio: !include common.yaml diff --git a/tests/components/usb_audio/test.esp32-s3-idf.yaml b/tests/components/usb_audio/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..53588849d1a --- /dev/null +++ b/tests/components/usb_audio/test.esp32-s3-idf.yaml @@ -0,0 +1,2 @@ +packages: + usb_audio: !include common.yaml From 2addbaaa7fecd990f2eb75f977c264bf748fb3ea Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:31:16 -1000 Subject: [PATCH 167/179] [ci] Refresh integration test durations (#21105) --- .../integration_test_durations.json | 373 +++++++++--------- 1 file changed, 195 insertions(+), 178 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 75bf0b48427..c7169c1d6db 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,180 +1,197 @@ { - "tests/integration/test_action_concurrent_reentry.py": 14.33, - "tests/integration/test_addressable_light_transition.py": 18.53, - "tests/integration/test_alarm_control_panel_state_transitions.py": 15.5, - "tests/integration/test_api_action_metadata.py": 11.08, - "tests/integration/test_api_action_responses.py": 15.46, - "tests/integration/test_api_action_timeout.py": 16.54, - "tests/integration/test_api_conditional_memory.py": 11.84, - "tests/integration/test_api_custom_services.py": 13.37, - "tests/integration/test_api_decode_wire_types.py": 17.49, - "tests/integration/test_api_empty_message_roundtrip.py": 12.35, - "tests/integration/test_api_encode_boundaries.py": 10.93, - "tests/integration/test_api_get_time_response_timezone.py": 13.5, - "tests/integration/test_api_homeassistant.py": 18.7, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 14.96, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 12.76, - "tests/integration/test_api_homeassistant_entities.py": 15.12, - "tests/integration/test_api_list_entities_backpressure.py": 14.2, - "tests/integration/test_api_message_size_batching.py": 13.72, - "tests/integration/test_api_outgoing_connection.py": 28.66, - "tests/integration/test_api_reboot_timeout.py": 9.01, - "tests/integration/test_api_string_lambda.py": 12.16, - "tests/integration/test_api_vv_logging.py": 10.14, - "tests/integration/test_api_zero_psk_provisioning.py": 18.26, - "tests/integration/test_areas_and_devices.py": 10.34, - "tests/integration/test_automation_wait_actions.py": 9.93, - "tests/integration/test_automations.py": 20.02, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.34, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 14.03, - "tests/integration/test_binary_sensor_invalidate_state.py": 8.43, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 9.39, - "tests/integration/test_branch_resume_async.py": 16.64, - "tests/integration/test_build_info.py": 12.41, - "tests/integration/test_camera_mock.py": 13.29, - "tests/integration/test_climate_control_action.py": 15.18, - "tests/integration/test_climate_custom_modes.py": 14.12, - "tests/integration/test_continuation_actions.py": 14.06, - "tests/integration/test_cover_control_action.py": 9.59, - "tests/integration/test_crc8_helper.py": 11.51, - "tests/integration/test_device_id_in_state.py": 18.41, - "tests/integration/test_duplicate_entities.py": 14.95, - "tests/integration/test_entity_icon.py": 15.57, - "tests/integration/test_entity_missing_state.py": 13.91, - "tests/integration/test_fan_turn_on_action.py": 13.85, - "tests/integration/test_fnv1_hash_object_id.py": 10.12, - "tests/integration/test_fnv1a_hash.py": 9.95, - "tests/integration/test_gpio_expander_cache.py": 4.43, - "tests/integration/test_host_logger_thread_safety.py": 12.54, - "tests/integration/test_host_mode_basic.py": 12.66, - "tests/integration/test_host_mode_batch_delay.py": 13.34, - "tests/integration/test_host_mode_climate_basic_state.py": 15.19, - "tests/integration/test_host_mode_climate_control.py": 15.35, - "tests/integration/test_host_mode_empty_string_options.py": 13.04, - "tests/integration/test_host_mode_entity_fields.py": 10.42, - "tests/integration/test_host_mode_fan_preset.py": 11.7, - "tests/integration/test_host_mode_many_entities.py": 16.79, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 15.62, - "tests/integration/test_host_mode_noise_encryption.py": 21.06, - "tests/integration/test_host_mode_reconnect.py": 11.29, - "tests/integration/test_host_mode_sensor.py": 12.25, - "tests/integration/test_host_ota.py": 109.97, - "tests/integration/test_host_preferences.py": 12.92, - "tests/integration/test_host_preferences_suspend_resume.py": 12.3, - "tests/integration/test_host_write_after_peer_close.py": 14.42, - "tests/integration/test_improv_serial_uart.py": 16.29, - "tests/integration/test_ir_rf_transmit_complete.py": 10.73, - "tests/integration/test_large_message_batching.py": 13.94, - "tests/integration/test_legacy_area.py": 13.34, - "tests/integration/test_legacy_climate_compat.py": 13.19, - "tests/integration/test_legacy_fan_compat.py": 9.31, - "tests/integration/test_light_automations.py": 14.39, - "tests/integration/test_light_binary_effect_off_phase.py": 14.75, - "tests/integration/test_light_calls.py": 13.85, - "tests/integration/test_light_constant_brightness.py": 12.22, - "tests/integration/test_light_control_action.py": 15.46, - "tests/integration/test_light_dim_relative_action.py": 15.3, - "tests/integration/test_light_effect_zero_brightness.py": 16.75, - "tests/integration/test_light_initial_state.py": 9.8, - "tests/integration/test_light_repeat_effect.py": 22.27, - "tests/integration/test_light_restore_state.py": 15.67, - "tests/integration/test_light_resume_effect.py": 17.79, - "tests/integration/test_light_toggle_action.py": 15.22, - "tests/integration/test_light_transition_state_publish_interval.py": 44.96, - "tests/integration/test_lock_automations.py": 12.55, - "tests/integration/test_logger_buffered_recursion_guard.py": 11.78, - "tests/integration/test_loop_disable_enable.py": 13.19, - "tests/integration/test_loop_interval_decoupling.py": 15.14, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 14.91, - "tests/integration/test_lvgl_color_picker_render.py": 91.0, - "tests/integration/test_lvgl_headless_render.py": 46.26, - "tests/integration/test_micros_to_millis.py": 9.14, - "tests/integration/test_multi_click_trigger.py": 13.62, - "tests/integration/test_multi_device_preferences.py": 15.18, - "tests/integration/test_noise_encryption_key_protection.py": 21.14, - "tests/integration/test_object_id_api_verification.py": 9.54, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 8.78, - "tests/integration/test_object_id_no_friendly_name.py": 24.2, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 5.07, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 26.9, - "tests/integration/test_online_image_bmp.py": 24.53, - "tests/integration/test_oversized_payloads.py": 43.03, - "tests/integration/test_preference_key_stability.py": 14.22, - "tests/integration/test_runtime_stats.py": 15.85, - "tests/integration/test_safe_mode_loop_runs.py": 8.89, - "tests/integration/test_scheduler_background_wake.py": 17.82, - "tests/integration/test_scheduler_blocking_warning.py": 30.27, - "tests/integration/test_scheduler_bulk_cleanup.py": 8.78, - "tests/integration/test_scheduler_defer_cancel.py": 12.9, - "tests/integration/test_scheduler_defer_cancel_regular.py": 12.79, - "tests/integration/test_scheduler_defer_fifo_simple.py": 13.14, - "tests/integration/test_scheduler_defer_stress.py": 13.2, - "tests/integration/test_scheduler_heap_stress.py": 9.15, - "tests/integration/test_scheduler_internal_id_no_collision.py": 14.81, - "tests/integration/test_scheduler_interval_reschedule.py": 13.07, - "tests/integration/test_scheduler_interval_zero_coerced.py": 12.32, - "tests/integration/test_scheduler_null_name.py": 3.81, - "tests/integration/test_scheduler_numeric_id_test.py": 9.77, - "tests/integration/test_scheduler_pool.py": 10.16, - "tests/integration/test_scheduler_rapid_cancellation.py": 4.7, - "tests/integration/test_scheduler_recursive_timeout.py": 13.72, - "tests/integration/test_scheduler_removed_item_race.py": 12.73, - "tests/integration/test_scheduler_self_keyed.py": 13.03, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 11.88, - "tests/integration/test_scheduler_string_test.py": 14.1, - "tests/integration/test_script_array_params.py": 11.14, - "tests/integration/test_script_delay_params.py": 13.84, - "tests/integration/test_script_queued.py": 15.45, - "tests/integration/test_script_queued_idle_loop.py": 13.15, - "tests/integration/test_script_wait_on_boot.py": 12.64, - "tests/integration/test_sdl_headless_screenshot.py": 15.19, - "tests/integration/test_select_stringref_trigger.py": 13.51, - "tests/integration/test_sensor_filters_delta.py": 13.6, - "tests/integration/test_sensor_filters_ring_buffer.py": 9.22, - "tests/integration/test_sensor_filters_sliding_window.py": 40.87, - "tests/integration/test_sensor_filters_value_list.py": 15.43, - "tests/integration/test_sensor_raw_state.py": 25.12, - "tests/integration/test_sensor_timeout_filter.py": 9.64, - "tests/integration/test_sensor_timeout_throttle_filter.py": 14.63, - "tests/integration/test_set_internal_at_boot.py": 13.18, - "tests/integration/test_snapshot_display.py": 16.01, - "tests/integration/test_socket_ipv4_resolve.py": 11.3, - "tests/integration/test_socket_tcp_client_link.py": 13.88, - "tests/integration/test_socket_wake_gate_tcp.py": 14.28, - "tests/integration/test_status_flags.py": 20.55, - "tests/integration/test_status_momentary.py": 12.63, - "tests/integration/test_strftime_to.py": 9.47, - "tests/integration/test_syslog.py": 17.73, - "tests/integration/test_tcp_uart_bridge.py": 16.27, - "tests/integration/test_tcp_uart_server.py": 13.64, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 13.42, - "tests/integration/test_template_climate_basic.py": 14.42, - "tests/integration/test_template_climate_custom_modes.py": 14.3, - "tests/integration/test_template_climate_nonoptimistic.py": 10.11, - "tests/integration/test_template_climate_on_control_ordering.py": 4.57, - "tests/integration/test_template_climate_publish_all_fields.py": 12.56, - "tests/integration/test_template_climate_sensor_push.py": 14.89, - "tests/integration/test_template_climate_set_actions.py": 14.12, - "tests/integration/test_template_climate_two_point_temperature.py": 9.08, - "tests/integration/test_template_text_save.py": 13.96, - "tests/integration/test_template_time.py": 12.73, - "tests/integration/test_text_command.py": 13.09, - "tests/integration/test_text_sensor_raw_state.py": 14.3, - "tests/integration/test_uart_mock_ld2410.py": 33.11, - "tests/integration/test_uart_mock_ld2412.py": 61.26, - "tests/integration/test_uart_mock_ld2420.py": 23.39, - "tests/integration/test_uart_mock_ld2450.py": 11.79, - "tests/integration/test_uart_mock_modbus.py": 323.58, - "tests/integration/test_uart_mock_ufm01.py": 71.03, - "tests/integration/test_uart_tcp_bridge.py": 13.3, - "tests/integration/test_udp.py": 7.04, - "tests/integration/test_use_address_runtime.py": 12.4, - "tests/integration/test_valve_control_action.py": 9.25, - "tests/integration/test_varint_five_byte_device_id.py": 9.34, - "tests/integration/test_wait_until_mid_loop_timing.py": 13.21, - "tests/integration/test_wait_until_on_boot.py": 12.66, - "tests/integration/test_wait_until_ordering.py": 9.06, - "tests/integration/test_wait_until_reentrant_restart.py": 10.83, - "tests/integration/test_wake_loop_forces_phase_b.py": 14.23, - "tests/integration/test_water_heater_template.py": 21.96 + "tests/integration/test_action_concurrent_reentry.py": 14.84, + "tests/integration/test_addressable_light_transition.py": 17.96, + "tests/integration/test_alarm_control_panel_state_transitions.py": 16.42, + "tests/integration/test_api_action_metadata.py": 12.08, + "tests/integration/test_api_action_responses.py": 16.62, + "tests/integration/test_api_action_timeout.py": 17.38, + "tests/integration/test_api_conditional_memory.py": 12.24, + "tests/integration/test_api_custom_services.py": 10.53, + "tests/integration/test_api_decode_wire_types.py": 17.97, + "tests/integration/test_api_empty_message_roundtrip.py": 12.54, + "tests/integration/test_api_encode_boundaries.py": 14.46, + "tests/integration/test_api_get_time_response_timezone.py": 16.43, + "tests/integration/test_api_homeassistant.py": 17.44, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 11.63, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 12.61, + "tests/integration/test_api_homeassistant_entities.py": 10.45, + "tests/integration/test_api_list_entities_backpressure.py": 13.7, + "tests/integration/test_api_message_size_batching.py": 9.04, + "tests/integration/test_api_outgoing_connection.py": 26.32, + "tests/integration/test_api_reboot_timeout.py": 11.82, + "tests/integration/test_api_string_lambda.py": 11.94, + "tests/integration/test_api_vv_logging.py": 13.47, + "tests/integration/test_api_zero_psk_provisioning.py": 27.07, + "tests/integration/test_areas_and_devices.py": 9.9, + "tests/integration/test_automation_wait_actions.py": 13.22, + "tests/integration/test_automations.py": 26.92, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 14.21, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 13.7, + "tests/integration/test_binary_sensor_invalidate_state.py": 13.52, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 12.48, + "tests/integration/test_branch_resume_async.py": 16.18, + "tests/integration/test_build_info.py": 11.15, + "tests/integration/test_camera_mock.py": 13.74, + "tests/integration/test_climate_control_action.py": 14.33, + "tests/integration/test_climate_custom_modes.py": 13.85, + "tests/integration/test_continuation_actions.py": 12.9, + "tests/integration/test_cover_control_action.py": 10.2, + "tests/integration/test_crc8_helper.py": 11.98, + "tests/integration/test_device_id_in_state.py": 25.5, + "tests/integration/test_duplicate_entities.py": 14.91, + "tests/integration/test_entity_icon.py": 15.33, + "tests/integration/test_entity_missing_state.py": 8.06, + "tests/integration/test_fan_turn_on_action.py": 13.45, + "tests/integration/test_fnv1_hash_object_id.py": 10.16, + "tests/integration/test_fnv1a_hash.py": 9.94, + "tests/integration/test_gpio_expander_cache.py": 10.52, + "tests/integration/test_host_logger_thread_safety.py": 11.38, + "tests/integration/test_host_mode_basic.py": 10.96, + "tests/integration/test_host_mode_batch_delay.py": 13.46, + "tests/integration/test_host_mode_climate_basic_state.py": 7.78, + "tests/integration/test_host_mode_climate_control.py": 14.03, + "tests/integration/test_host_mode_empty_string_options.py": 12.45, + "tests/integration/test_host_mode_entity_fields.py": 15.66, + "tests/integration/test_host_mode_fan_preset.py": 9.7, + "tests/integration/test_host_mode_many_entities.py": 23.55, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 14.87, + "tests/integration/test_host_mode_noise_encryption.py": 29.31, + "tests/integration/test_host_mode_reconnect.py": 7.15, + "tests/integration/test_host_mode_sensor.py": 12.09, + "tests/integration/test_host_ota.py": 307.27, + "tests/integration/test_host_preferences.py": 13.14, + "tests/integration/test_host_preferences_suspend_resume.py": 14.41, + "tests/integration/test_host_write_after_peer_close.py": 13.85, + "tests/integration/test_improv_serial_uart.py": 14.8, + "tests/integration/test_in_range_conditions.py": 13.63, + "tests/integration/test_ir_rf_transmit_complete.py": 17.32, + "tests/integration/test_large_message_batching.py": 15.13, + "tests/integration/test_legacy_area.py": 11.23, + "tests/integration/test_legacy_climate_compat.py": 13.99, + "tests/integration/test_legacy_fan_compat.py": 8.48, + "tests/integration/test_light_automations.py": 8.52, + "tests/integration/test_light_binary_effect_off_phase.py": 20.04, + "tests/integration/test_light_calls.py": 16.91, + "tests/integration/test_light_constant_brightness.py": 10.63, + "tests/integration/test_light_control_action.py": 14.43, + "tests/integration/test_light_dim_relative_action.py": 16.59, + "tests/integration/test_light_effect_zero_brightness.py": 17.36, + "tests/integration/test_light_initial_state.py": 13.38, + "tests/integration/test_light_repeat_effect.py": 13.04, + "tests/integration/test_light_restore_state.py": 14.38, + "tests/integration/test_light_resume_effect.py": 17.23, + "tests/integration/test_light_toggle_action.py": 15.35, + "tests/integration/test_light_transition_state_publish_interval.py": 44.06, + "tests/integration/test_lock_automations.py": 13.01, + "tests/integration/test_logger_buffered_recursion_guard.py": 8.93, + "tests/integration/test_loop_disable_enable.py": 12.38, + "tests/integration/test_loop_interval_decoupling.py": 15.54, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 13.16, + "tests/integration/test_lvgl_color_picker_render.py": 53.51, + "tests/integration/test_lvgl_headless_render.py": 58.96, + "tests/integration/test_micros_to_millis.py": 2.84, + "tests/integration/test_multi_click_trigger.py": 8.88, + "tests/integration/test_multi_device_preferences.py": 14.72, + "tests/integration/test_noise_encryption_key_protection.py": 21.32, + "tests/integration/test_object_id_api_verification.py": 12.83, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 7.81, + "tests/integration/test_object_id_no_friendly_name.py": 24.12, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 25.21, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 25.14, + "tests/integration/test_online_image_bmp.py": 16.77, + "tests/integration/test_online_image_stalled_download.py": 17.06, + "tests/integration/test_oversized_payloads.py": 37.98, + "tests/integration/test_preference_key_stability.py": 13.43, + "tests/integration/test_runtime_stats.py": 12.02, + "tests/integration/test_safe_mode_loop_runs.py": 11.39, + "tests/integration/test_scheduler_background_wake.py": 17.85, + "tests/integration/test_scheduler_blocking_warning.py": 30.7, + "tests/integration/test_scheduler_bulk_cleanup.py": 12.73, + "tests/integration/test_scheduler_defer_cancel.py": 13.05, + "tests/integration/test_scheduler_defer_cancel_regular.py": 12.12, + "tests/integration/test_scheduler_defer_fifo_simple.py": 12.73, + "tests/integration/test_scheduler_defer_stress.py": 12.9, + "tests/integration/test_scheduler_heap_stress.py": 13.61, + "tests/integration/test_scheduler_internal_id_no_collision.py": 9.06, + "tests/integration/test_scheduler_interval_reschedule.py": 12.19, + "tests/integration/test_scheduler_interval_zero_coerced.py": 3.33, + "tests/integration/test_scheduler_loop_phase_timeout.py": 10.81, + "tests/integration/test_scheduler_null_name.py": 3.45, + "tests/integration/test_scheduler_numeric_id_test.py": 13.88, + "tests/integration/test_scheduler_pool.py": 13.86, + "tests/integration/test_scheduler_rapid_cancellation.py": 4.54, + "tests/integration/test_scheduler_recursive_timeout.py": 4.32, + "tests/integration/test_scheduler_removed_item_race.py": 13.06, + "tests/integration/test_scheduler_self_keyed.py": 4.29, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 12.79, + "tests/integration/test_scheduler_string_test.py": 8.51, + "tests/integration/test_script_array_params.py": 8.05, + "tests/integration/test_script_delay_params.py": 14.57, + "tests/integration/test_script_on_stop_and_choose.py": 14.11, + "tests/integration/test_script_queued.py": 15.68, + "tests/integration/test_script_queued_idle_loop.py": 8.46, + "tests/integration/test_script_wait_on_boot.py": 12.91, + "tests/integration/test_sdl_headless_screenshot.py": 15.05, + "tests/integration/test_select_stringref_trigger.py": 11.48, + "tests/integration/test_sensor_filters_delta.py": 13.28, + "tests/integration/test_sensor_filters_ring_buffer.py": 13.95, + "tests/integration/test_sensor_filters_sliding_window.py": 40.27, + "tests/integration/test_sensor_filters_value_list.py": 14.95, + "tests/integration/test_sensor_raw_state.py": 24.93, + "tests/integration/test_sensor_timeout_filter.py": 14.93, + "tests/integration/test_sensor_timeout_throttle_filter.py": 16.23, + "tests/integration/test_set_internal_at_boot.py": 12.69, + "tests/integration/test_snapshot_display.py": 15.83, + "tests/integration/test_socket_ipv4_resolve.py": 10.41, + "tests/integration/test_socket_tcp_client_link.py": 12.96, + "tests/integration/test_socket_wake_gate_tcp.py": 11.61, + "tests/integration/test_socket_wake_gate_udp.py": 10.45, + "tests/integration/test_status_flags.py": 20.81, + "tests/integration/test_status_momentary.py": 15.46, + "tests/integration/test_strftime_to.py": 12.97, + "tests/integration/test_syslog.py": 21.29, + "tests/integration/test_tcp_uart_bridge.py": 16.41, + "tests/integration/test_tcp_uart_server.py": 13.43, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 13.34, + "tests/integration/test_template_climate_basic.py": 12.78, + "tests/integration/test_template_climate_custom_modes.py": 14.81, + "tests/integration/test_template_climate_nonoptimistic.py": 8.48, + "tests/integration/test_template_climate_on_control_ordering.py": 7.38, + "tests/integration/test_template_climate_publish_all_fields.py": 8.89, + "tests/integration/test_template_climate_sensor_push.py": 15.73, + "tests/integration/test_template_climate_set_actions.py": 11.23, + "tests/integration/test_template_climate_two_point_temperature.py": 9.47, + "tests/integration/test_template_text_save.py": 13.65, + "tests/integration/test_template_time.py": 9.1, + "tests/integration/test_text_command.py": 12.29, + "tests/integration/test_text_sensor_raw_state.py": 13.19, + "tests/integration/test_uart_mock_bl0906_stray_rx.py": 13.99, + "tests/integration/test_uart_mock_dfrobot_sen0465.py": 20.14, + "tests/integration/test_uart_mock_dfrobot_sen0609.py": 10.57, + "tests/integration/test_uart_mock_ld2410.py": 31.61, + "tests/integration/test_uart_mock_ld2410s.py": 49.83, + "tests/integration/test_uart_mock_ld2412.py": 89.93, + "tests/integration/test_uart_mock_ld2420.py": 20.19, + "tests/integration/test_uart_mock_ld2450.py": 16.45, + "tests/integration/test_uart_mock_ld2450_has_target_only.py": 17.25, + "tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py": 10.7, + "tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py": 15.95, + "tests/integration/test_uart_mock_ld2450_polygon_zones.py": 11.68, + "tests/integration/test_uart_mock_ld2460.py": 15.46, + "tests/integration/test_uart_mock_modbus.py": 214.58, + "tests/integration/test_uart_mock_pylontech.py": 14.66, + "tests/integration/test_uart_mock_ufm01.py": 70.02, + "tests/integration/test_uart_tcp_bridge.py": 10.46, + "tests/integration/test_udp.py": 9.38, + "tests/integration/test_udp_ipv6.py": 8.74, + "tests/integration/test_udp_multicast.py": 11.71, + "tests/integration/test_use_address_runtime.py": 15.24, + "tests/integration/test_valve_control_action.py": 13.75, + "tests/integration/test_varint_five_byte_device_id.py": 13.56, + "tests/integration/test_wait_until_mid_loop_timing.py": 8.57, + "tests/integration/test_wait_until_on_boot.py": 9.51, + "tests/integration/test_wait_until_ordering.py": 4.48, + "tests/integration/test_wait_until_reentrant_restart.py": 5.63, + "tests/integration/test_wake_loop_forces_phase_b.py": 9.4, + "tests/integration/test_water_heater_template.py": 28.16 } From 4498ad2c777e5c21db0cee9c022375e38993c186 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:38:47 +1100 Subject: [PATCH 168/179] [core] Warn when the .esphome build tree is tracked by git (#18973) --- esphome/__main__.py | 1 + esphome/writer.py | 87 ++++++++++- tests/unit_tests/test_main.py | 48 +++++++ tests/unit_tests/test_writer.py | 246 ++++++++++++++++++++++++++++++++ 4 files changed, 381 insertions(+), 1 deletion(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 82ef585e1a9..39451849bc2 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -791,6 +791,7 @@ def write_cpp(config: ConfigType) -> int: if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() + writer.check_build_tree_not_tracked() generate_cpp_contents(config) return write_cpp_file() diff --git a/esphome/writer.py b/esphome/writer.py index 934595ef7e8..e6c0c0e2e90 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -4,6 +4,7 @@ import logging import os from pathlib import Path import re +import subprocess import time from esphome import loader @@ -16,6 +17,7 @@ from esphome.const import ( __version__, ) from esphome.core import CORE, LINE_DIRECTIVE_RESET, EsphomeError, line_directive +from esphome.git import _GIT_REPO_SCOPING_ENV from esphome.helpers import ( copy_file_if_changed, cpp_string_escape, @@ -722,7 +724,11 @@ def clean_all(configuration: list[str]): _LOGGER.info("Cleaning %s", dir) # Don't remove storage or .json files which are needed by the dashboard for item in dir.iterdir(): - if item.is_file() and not item.name.endswith(".json"): + if ( + item.is_file() + and not item.name.endswith(".json") + and item.name != ".gitkeep" + ): item.unlink() elif item.is_dir() and item.name != "storage": rmtree(item) @@ -764,3 +770,82 @@ def write_gitignore(): path = CORE.relative_config_path(".gitignore") if not path.is_file(): path.write_text(GITIGNORE_CONTENT, encoding="utf-8") + + +def check_build_tree_not_tracked() -> None: + """Warn if the build tree is tracked by git. + + ``write_gitignore`` never touches an existing ``.gitignore``, so a build + tree that was committed before it was excluded (or on a config predating + ESPHome's auto-generated ``.gitignore``) stays tracked forever: adding a + pattern to ``.gitignore`` does not untrack files already in the index. + Every subsequent build would then keep changing the regenerated files + under version control. + """ + if os.environ.get("CI"): + # CI checkouts build fixture configs, not a user's own repository, so + # there is nothing meaningful to warn about -- skip the extra process + # spawn on every single build. + return + data_dir = CORE.data_dir + if (data_dir / ".gitkeep").is_file(): + # The user deliberately placed a .gitkeep file, opting the build + # directory into version control on purpose. + return + + try: + relative_data_dir = data_dir.relative_to(CORE.config_dir) + except ValueError: + # Outside the config directory (e.g. the Home Assistant add-on's + # /data, or ESPHOME_DATA_DIR pointing elsewhere) -- can't be part of + # this repository. + return + + # Strip the same repo-scoping variables run_git_command() strips (see + # esphome/git.py): a git hook or CI wrapper exporting GIT_DIR/GIT_INDEX_FILE + # would otherwise redirect this read-only query to its own repository. + env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_SCOPING_ENV} + try: + result = subprocess.run( + ["git", "ls-files", "-z", "--", str(relative_data_dir)], + cwd=CORE.config_dir, + capture_output=True, + check=False, + env=env, + timeout=5, + ) + except FileNotFoundError: + # No git installed, so the directory cannot be tracked. + return + except (OSError, subprocess.TimeoutExpired) as err: + # This is a purely advisory check -- an unusable or slow git must + # never turn into a hard failure of the build itself. + _LOGGER.debug( + "Could not check whether %s is tracked by git: %s", relative_data_dir, err + ) + return + if result.returncode != 0: + if result.stderr: + _LOGGER.debug( + "git ls-files failed (%d): %s", + result.returncode, + result.stderr.decode("utf-8", errors="replace").strip(), + ) + return + if not result.stdout: + return + + tracked_count = sum(1 for name in result.stdout.split(b"\0") if name) + _LOGGER.warning( + "%s is tracked by git (%d file%s). This is the ESPHome build " + "directory; it holds generated files that may contain your secrets " + "(e.g. Wi-Fi credentials or API keys) in plain text, and should not " + "be committed. Add it to your .gitignore, then remove it from " + "version control with: git rm -r --cached %s " + "(if you really want to keep this directory in git, add a file " + "named .gitkeep to it to silence this warning)", + relative_data_dir, + tracked_count, + "" if tracked_count == 1 else "s", + data_dir, + ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 801eda4496a..88a91e38cf9 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -69,6 +69,7 @@ from esphome.__main__ import ( upload_using_esptool, upload_using_picotool, upload_using_platformio, + write_cpp, ) from esphome.address_cache import AddressCache from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult @@ -108,6 +109,7 @@ from esphome.const import ( CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, + ENV_NOGITIGNORE, KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, @@ -4861,6 +4863,52 @@ def test_run_esphome_dashboard_redirects_to_device_builder( assert "esphome-device-builder" in caplog.text +def test_write_cpp_writes_gitignore_and_checks_build_tree( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """write_cpp writes the .gitignore and checks the build tree isn't tracked.""" + monkeypatch.delenv(ENV_NOGITIGNORE, raising=False) + + with ( + patch("esphome.writer.update_storage_json") as mock_update_storage, + patch("esphome.writer.write_gitignore") as mock_write_gitignore, + patch("esphome.writer.check_build_tree_not_tracked") as mock_check_tracked, + patch("esphome.__main__.generate_cpp_contents") as mock_generate, + patch("esphome.__main__.write_cpp_file", return_value=0) as mock_write_file, + ): + result = write_cpp({}) + + assert result == 0 + mock_update_storage.assert_called_once() + mock_write_gitignore.assert_called_once() + mock_check_tracked.assert_called_once() + mock_generate.assert_called_once_with({}) + mock_write_file.assert_called_once() + + +def test_write_cpp_skips_gitignore_but_still_checks_build_tree( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ESPHOME_NOGITIGNORE skips writing .gitignore. + + The (read-only) tracked-file check still runs -- it isn't tied to + whether ESPHome is allowed to write files. + """ + monkeypatch.setenv(ENV_NOGITIGNORE, "true") + + with ( + patch("esphome.writer.update_storage_json"), + patch("esphome.writer.write_gitignore") as mock_write_gitignore, + patch("esphome.writer.check_build_tree_not_tracked") as mock_check_tracked, + patch("esphome.__main__.generate_cpp_contents"), + patch("esphome.__main__.write_cpp_file", return_value=0), + ): + write_cpp({}) + + mock_write_gitignore.assert_not_called() + mock_check_tracked.assert_called_once() + + def test_command_config_hash( tmp_path: Path, capfd: CaptureFixture[str], diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 67acfa6a189..f03a42b6e8d 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -5,10 +5,12 @@ from contextlib import contextmanager from dataclasses import dataclass from datetime import datetime import json +import logging import os from pathlib import Path import re import stat +import subprocess from typing import Any from unittest.mock import MagicMock, patch @@ -32,6 +34,7 @@ from esphome.writer import ( CPP_INCLUDE_BEGIN, CPP_INCLUDE_END, GITIGNORE_CONTENT, + check_build_tree_not_tracked, clean_all, clean_build, clean_cmake_cache, @@ -874,6 +877,244 @@ def test_write_gitignore_skips_existing_file( assert gitignore_path.read_text() == existing_content +@patch("esphome.writer.subprocess.run") +def test_check_build_tree_not_tracked_silent_in_ci( + mock_run: MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CI builds fixture configs, not a user's repo -- skip the git spawn entirely.""" + monkeypatch.setenv("CI", "true") + + check_build_tree_not_tracked() + + mock_run.assert_not_called() + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_warns_when_tracked( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked warns when the build dir is tracked by git.""" + monkeypatch.delenv("CI", raising=False) + data_dir = tmp_path / ".esphome" + mock_core.config_dir = tmp_path + mock_core.data_dir = data_dir + mock_run.return_value = MagicMock( + returncode=0, + stdout=b".esphome/build/node/main.cpp\0.esphome/node.json\0", + stderr=b"", + ) + + with ( + patch.dict(os.environ, {"GIT_DIR": "/somewhere/else/.git"}), + caplog.at_level("WARNING"), + ): + check_build_tree_not_tracked() + + assert "is tracked by git" in caplog.text + assert "2 files" in caplog.text + assert "secrets" in caplog.text + assert ".gitkeep" in caplog.text + # The remediation command must use the absolute path so it also works + # when pasted from the repository root of a nested config layout. + assert f"git rm -r --cached {data_dir}" in caplog.text + + mock_run.assert_called_once() + args, kwargs = mock_run.call_args + assert args[0] == ["git", "ls-files", "-z", "--", ".esphome"] + assert kwargs["cwd"] == tmp_path + assert kwargs["capture_output"] is True + assert kwargs["check"] is False + assert kwargs["timeout"] == 5 + # Repo-scoping env vars must be stripped so a hook/CI wrapper can't + # redirect this read-only query to a different repository. + assert "GIT_DIR" not in kwargs["env"] + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_gitkeep_present( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked is skipped when .gitkeep opts in.""" + monkeypatch.delenv("CI", raising=False) + data_dir = tmp_path / ".esphome" + data_dir.mkdir() + (data_dir / ".gitkeep").touch() + mock_core.config_dir = tmp_path + mock_core.data_dir = data_dir + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + mock_run.assert_not_called() + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_untracked( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked stays silent when nothing is tracked.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.return_value = MagicMock(returncode=0, stdout=b"", stderr=b"") + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_outside_git_repo( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked stays silent when not inside a git repo.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.return_value = MagicMock( + returncode=128, stdout=b"", stderr=b"fatal: not a git repository\n" + ) + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_on_failure_without_stderr( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-zero exit with no stderr must not raise trying to log the reason.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.return_value = MagicMock(returncode=1, stdout=b"", stderr=b"") + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + assert "tracked by git" not in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_git_missing( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked stays silent when git isn't installed.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.side_effect = FileNotFoundError() + + with caplog.at_level("DEBUG"): + check_build_tree_not_tracked() + + assert caplog.text == "" + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_on_other_os_error( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-executable git on PATH (PermissionError) must not abort the build.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.side_effect = PermissionError("git is not executable") + + with caplog.at_level("DEBUG"): + check_build_tree_not_tracked() + + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + assert "Could not check whether" in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_on_timeout( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A hung git process must not block the build indefinitely.""" + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path + mock_core.data_dir = tmp_path / ".esphome" + mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5) + + with caplog.at_level("DEBUG"): + check_build_tree_not_tracked() + + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + assert "Could not check whether" in caplog.text + + +@patch("esphome.writer.subprocess.run") +@patch("esphome.writer.CORE") +def test_check_build_tree_not_tracked_silent_when_data_dir_outside_config_dir( + mock_core: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test check_build_tree_not_tracked skips the check for an external data dir. + + Covers the Home Assistant add-on (/data) and ESPHOME_DATA_DIR cases, where + the build directory can't be part of the config's git repository. + """ + monkeypatch.delenv("CI", raising=False) + mock_core.config_dir = tmp_path / "config" + mock_core.data_dir = tmp_path / "data" + + with caplog.at_level("WARNING"): + check_build_tree_not_tracked() + + mock_run.assert_not_called() + assert "tracked by git" not in caplog.text + + @patch("esphome.writer.write_file_if_changed") # Mock to capture output @patch("esphome.writer.copy_src_tree") # Keep this mock as it's complex @patch("esphome.writer.CORE") @@ -1373,6 +1614,7 @@ def test_clean_all( build_dir2.mkdir() (build_dir1 / "dummy.txt").write_text("x") (build_dir2 / "dummy.txt").write_text("x") + (build_dir1 / ".gitkeep").touch() # Create PlatformIO directories pio_cache = tmp_path / "pio_cache" @@ -1415,6 +1657,10 @@ def test_clean_all( # Verify that files in .esphome were removed assert not (build_dir1 / "dummy.txt").exists() assert not (build_dir2 / "dummy.txt").exists() + + # .gitkeep is the opt-out check_build_tree_not_tracked() advertises; it + # must survive clean-all or the warning comes right back on next compile. + assert (build_dir1 / ".gitkeep").exists() assert not pio_cache.exists() assert not pio_packages.exists() assert not pio_platforms.exists() From b919b692f7f3687d161ad020c47ee384993abfc6 Mon Sep 17 00:00:00 2001 From: zerafachris Date: Mon, 19 Oct 2026 09:54:33 +0200 Subject: [PATCH 169/179] [scd4x] Fix ambient_pressure_compensation unit handling (#19888) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/scd4x/sensor.py | 13 ++++++++++++- tests/components/scd4x/common.yaml | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index 4b67319efb1..f1c7beaea8b 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -42,6 +42,15 @@ MEASUREMENT_MODE_OPTIONS = MeasurementMode.options( ) +def pressure_hpa(value: str | float) -> float: + """Pressure in hPa. A bare number is hPa, matching what the C++ sends to the sensor.""" + try: + return cv.float_(value) + except cv.Invalid: + pass + return cv.pressure(value) * 1000 # bar, mbar, mBar + + CONFIG_SCHEMA = ( cv.Schema( { @@ -72,7 +81,9 @@ CONFIG_SCHEMA = ( cv.float_with_unit("altitude", "(m|m a.s.l.|MAMSL|MASL)"), cv.int_range(min=0, max=0xFFFF, max_included=False), ), - cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.pressure, + cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.All( + pressure_hpa, cv.Any(cv.float_range(min=700, max=1200), 0) + ), cv.Optional(CONF_TEMPERATURE_OFFSET, default="4°C"): cv.temperature_delta, cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id( sensor.Sensor diff --git a/tests/components/scd4x/common.yaml b/tests/components/scd4x/common.yaml index cedb88977ea..e875cb0cc57 100644 --- a/tests/components/scd4x/common.yaml +++ b/tests/components/scd4x/common.yaml @@ -14,3 +14,19 @@ sensor: ambient_pressure_compensation: 961mBar temperature_offset: 4.2C update_interval: 15s + - platform: scd4x + i2c_id: i2c_bus + co2: + name: SCD4X CO2 (bare hPa) + ambient_pressure_compensation: 961 + - platform: scd4x + i2c_id: i2c_bus + co2: + name: SCD4X CO2 (bar) + ambient_pressure_compensation: 0.961bar + - platform: scd4x + i2c_id: i2c_bus + co2: + name: SCD4X CO2 (altitude only) + altitude_compensation: 500m + ambient_pressure_compensation: 0 From 2087d2868469a1ac179ffaece6cf5623ea39e87b Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:07:50 +0000 Subject: [PATCH 170/179] Bump aioesphomeapi from 46.12.0 to 46.14.0 (#21114) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 13625818ad8..7d48235966c 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.12.0 +aioesphomeapi==46.14.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 2d0e3faa3d1282d1ec6928d4e76b62db3d37434c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:57:05 +1100 Subject: [PATCH 171/179] [st7305] Add reflective LCD display driver (#21099) --- CODEOWNERS | 1 + esphome/components/st7305/__init__.py | 2 + esphome/components/st7305/display.py | 224 +++++++++++++++ esphome/components/st7305/models/__init__.py | 0 esphome/components/st7305/models/waveshare.py | 60 ++++ esphome/components/st7305/st7305.cpp | 258 ++++++++++++++++++ esphome/components/st7305/st7305.h | 127 +++++++++ tests/component_tests/st7305/test_st7305.py | 74 +++++ tests/components/st7305/common.h | 77 ++++++ tests/components/st7305/common.yaml | 6 + .../components/st7305/display/test_st7305.cpp | 255 +++++++++++++++++ .../components/st7305/test.esp32-s3-idf.yaml | 8 + .../validate-custom-landscape.esp32-idf.yaml | 21 ++ .../st7305/validate-custom.esp32-idf.yaml | 22 ++ 14 files changed, 1135 insertions(+) create mode 100644 esphome/components/st7305/__init__.py create mode 100644 esphome/components/st7305/display.py create mode 100644 esphome/components/st7305/models/__init__.py create mode 100644 esphome/components/st7305/models/waveshare.py create mode 100644 esphome/components/st7305/st7305.cpp create mode 100644 esphome/components/st7305/st7305.h create mode 100644 tests/component_tests/st7305/test_st7305.py create mode 100644 tests/components/st7305/common.h create mode 100644 tests/components/st7305/common.yaml create mode 100644 tests/components/st7305/display/test_st7305.cpp create mode 100644 tests/components/st7305/test.esp32-s3-idf.yaml create mode 100644 tests/components/st7305/validate-custom-landscape.esp32-idf.yaml create mode 100644 tests/components/st7305/validate-custom.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 3bb01931381..9f9956930d2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -602,6 +602,7 @@ esphome/components/ssd1331_spi/* @kbx81 esphome/components/ssd1351_base/* @kbx81 esphome/components/ssd1351_spi/* @kbx81 esphome/components/st7123/* @miniskipper +esphome/components/st7305/* @clydebarrow esphome/components/st7567_base/* @latonita esphome/components/st7567_i2c/* @latonita esphome/components/st7567_spi/* @latonita diff --git a/esphome/components/st7305/__init__.py b/esphome/components/st7305/__init__.py new file mode 100644 index 00000000000..6b48f6a9504 --- /dev/null +++ b/esphome/components/st7305/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@clydebarrow"] +DOMAIN = "st7305" diff --git a/esphome/components/st7305/display.py b/esphome/components/st7305/display.py new file mode 100644 index 00000000000..09ca8cdf7c0 --- /dev/null +++ b/esphome/components/st7305/display.py @@ -0,0 +1,224 @@ +import importlib +import pkgutil + +from esphome import pins +import esphome.codegen as cg +from esphome.components import display, spi +from esphome.components.display import CONF_SHOW_TEST_CARD +from esphome.components.mipi import ( + DriverChip, + flatten_sequence, + map_sequence, + model_schema_extractor, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, + CONF_CS_PIN, + CONF_DATA_RATE, + CONF_DC_PIN, + CONF_HEIGHT, + CONF_ID, + CONF_INIT_SEQUENCE, + CONF_LAMBDA, + CONF_MODEL, + CONF_PAGES, + CONF_RESET_PIN, + CONF_ROTATION, + CONF_WIDTH, +) +from esphome.types import ConfigType + +from . import models + +DEPENDENCIES = ["spi"] + +CONF_COLUMN_START = "column_start" +CONF_COLUMN_END = "column_end" +CONF_PORTRAIT = "portrait" +CONF_ROW_START = "row_start" +CONF_ROW_END = "row_end" + +st7305_ns = cg.esphome_ns.namespace("st7305") +ST7305 = st7305_ns.class_("ST7305", display.DisplayBuffer, spi.SPIDevice) + +Orientation = st7305_ns.enum("Orientation") + +# CUSTOM must be registered before the model modules are imported. +DriverChip("CUSTOM") +for module_info in pkgutil.iter_modules(models.__path__): + importlib.import_module(f".models.{module_info.name}", package=__package__) +MODELS = DriverChip.get_models() + +DEFAULT_MODEL = "WAVESHARE-400X300" + + +def _model_schema(model: DriverChip) -> cv.Schema: + iseqconf = ( + cv.Required(CONF_INIT_SEQUENCE) + if model.initsequence is None + else cv.Optional(CONF_INIT_SEQUENCE) + ) + return ( + display.FULL_DISPLAY_SCHEMA.extend( + spi.spi_device_schema( + cs_pin_required=False, + default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), + default_mode="MODE0", + ) + ) + .extend(cv.COMPONENT_SCHEMA) + .extend( + { + cv.GenerateID(): cv.declare_id(ST7305), + cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), + model.option(CONF_CS_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + model.option(CONF_DC_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, + cv.Optional(CONF_WIDTH): cv.int_range(min=8, max=1024), + cv.Optional(CONF_HEIGHT): cv.int_range(min=8, max=1024), + cv.Optional(CONF_PORTRAIT): cv.boolean, + cv.Optional(CONF_COLUMN_START): cv.int_range(min=0, max=255), + cv.Optional(CONF_COLUMN_END): cv.int_range(min=0, max=255), + cv.Optional(CONF_ROW_START): cv.int_range(min=0, max=255), + cv.Optional(CONF_ROW_END): cv.int_range(min=0, max=255), + iseqconf: cv.ensure_list(map_sequence), + } + ) + ) + + +def _resolve(config: ConfigType, model: DriverChip) -> None: + """Fill in model defaults and validate the geometry and address window.""" + if (width := config.get(CONF_WIDTH)) is None: + if (width := model.get_default(CONF_WIDTH, None)) is None: + raise cv.Invalid("'width' is required for this model", path=[CONF_WIDTH]) + config[CONF_WIDTH] = width + if (height := config.get(CONF_HEIGHT)) is None: + if (height := model.get_default(CONF_HEIGHT, None)) is None: + raise cv.Invalid("'height' is required for this model", path=[CONF_HEIGHT]) + config[CONF_HEIGHT] = height + + if config.get(CONF_PORTRAIT) is None: + if (default := model.get_default(CONF_PORTRAIT, None)) is None: + raise cv.Invalid( + "'portrait' is required for this model", path=[CONF_PORTRAIT] + ) + config[CONF_PORTRAIT] = default + + if config[CONF_PORTRAIT]: + if width % 12 or height % 2: + raise cv.Invalid( + "portrait panels require a width divisible by 12 and a height divisible by 2" + ) + columns = width // 12 + rows = height // 2 + else: + if width % 2 or height % 12: + raise cv.Invalid( + "landscape panels require a width divisible by 2 and a height divisible by 12" + ) + columns = height // 12 + rows = width // 2 + + column_start = config.get( + CONF_COLUMN_START, model.get_default(CONF_COLUMN_START, 0) + ) + row_start = config.get(CONF_ROW_START, model.get_default(CONF_ROW_START, 0)) + column_end = config.get( + CONF_COLUMN_END, model.get_default(CONF_COLUMN_END, column_start + columns - 1) + ) + row_end = config.get( + CONF_ROW_END, model.get_default(CONF_ROW_END, row_start + rows - 1) + ) + if column_end < column_start or row_end < row_start: + raise cv.Invalid("address window end must not be before its start") + if not ( + 0 <= column_start <= 255 + and 0 <= column_end <= 255 + and 0 <= row_start <= 255 + and 0 <= row_end <= 255 + ): + raise cv.Invalid("address window values must be between 0 and 255") + if (column_end - column_start + 1) * (row_end - row_start + 1) * 3 < ( + width * height // 8 + ): + raise cv.Invalid("address window is too small for the display dimensions") + config[CONF_COLUMN_START] = column_start + config[CONF_COLUMN_END] = column_end + config[CONF_ROW_START] = row_start + config[CONF_ROW_END] = row_end + + if config.get(CONF_INIT_SEQUENCE) is None: + config[CONF_INIT_SEQUENCE] = model.initsequence + + for key, label in ((CONF_CS_PIN, "cs_pin"), (CONF_DC_PIN, "dc_pin")): + if key not in config: + raise cv.Invalid(f"'{label}' is required for this model", path=[key]) + + +def model_schema(config: ConfigType) -> cv.Schema: + """Return the schema for the model selected in the config.""" + return _model_schema(MODELS[config[CONF_MODEL]]) + + +@model_schema_extractor(MODELS, model_schema) +def customise_schema(config: ConfigType) -> ConfigType: + config = cv.Schema( + { + cv.Optional(CONF_MODEL, default=DEFAULT_MODEL): cv.one_of( + *MODELS, upper=True + ) + }, + extra=cv.ALLOW_EXTRA, + )(config) + model = MODELS[config[CONF_MODEL]] + config = _model_schema(model)(config) + _resolve(config, model) + + display.add_metadata( + config[CONF_ID], + config[CONF_WIDTH], + config[CONF_HEIGHT], + has_hardware_rotation=False, + byte_order=cv.UNDEFINED, + has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True + or config.get(CONF_PAGES) is not None + or config.get(CONF_LAMBDA) is not None + or config.get(CONF_SHOW_TEST_CARD) is True, + rotation=config.get(CONF_ROTATION, 0), + ) + return config + + +CONFIG_SCHEMA = customise_schema + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + "st7305", require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + sequence = flatten_sequence(config[CONF_INIT_SEQUENCE]) + table = cg.shared_progmem_array("st7305_init_sequence", cg.uint8, sequence) + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_MODEL], + config[CONF_WIDTH], + config[CONF_HEIGHT], + Orientation.ORIENTATION_PORTRAIT + if config[CONF_PORTRAIT] + else Orientation.ORIENTATION_LANDSCAPE, + config[CONF_COLUMN_START], + config[CONF_COLUMN_END], + config[CONF_ROW_START], + config[CONF_ROW_END], + table, + len(sequence), + ) + + await display.register_display(var, config) + await spi.register_spi_device(var, config, write_only=True) + await cg.add_pin(var.set_dc_pin, config[CONF_DC_PIN]) + await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) + await display.add_writer(var, config) diff --git a/esphome/components/st7305/models/__init__.py b/esphome/components/st7305/models/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/st7305/models/waveshare.py b/esphome/components/st7305/models/waveshare.py new file mode 100644 index 00000000000..e9cde5879a2 --- /dev/null +++ b/esphome/components/st7305/models/waveshare.py @@ -0,0 +1,60 @@ +from esphome.components.mipi import DriverChip, delay + +# Waveshare ESP32-S3-RLCD-4.2, 4.2" reflective panel. The same controller command set is +# used by the 300x400 ST7306 panels. Values follow the vendor demo code, which is the sequence +# proven on this panel; the datasheet's referential sequence is close but not identical. +DriverChip( + "WAVESHARE-400X300", + width=400, + height=300, + portrait=False, + cs_pin=40, + dc_pin=5, + reset_pin=41, + column_start=0x12, + column_end=0x2A, + row_start=0x00, + row_end=0xC7, + initsequence=( + (0xD6, 0x17, 0x02), # NVM load control + (0xD1, 0x01), # booster enable + (0xC0, 0x11, 0x04), # gate voltage + (0xC1, 0x69, 0x69, 0x69, 0x69), # VSHP + (0xC2, 0x19, 0x19, 0x19, 0x19), # VSLP + (0xC4, 0x4B, 0x4B, 0x4B, 0x4B), # VSHN + (0xC5, 0x19, 0x19, 0x19, 0x19), # VSLN + (0xD8, 0x80, 0xE9), # oscillator + (0xB2, 0x02), # frame rate + ( + 0xB3, + 0xE5, + 0xF6, + 0x05, + 0x46, + 0x77, + 0x77, + 0x77, + 0x77, + 0x76, + 0x45, + ), # HPM gate EQ + (0xB4, 0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45), # LPM gate EQ + (0x62, 0x32, 0x03, 0x1F), # gate timing + (0xB7, 0x13), # source EQ + (0xB0, 0x64), # gate line setting, 400 lines + (0x11,), # sleep out + delay(200), + (0xC9, 0x00), # source voltage select + (0x36, 0x48), # MADCTL: MX=1, DO=1 + (0x3A, 0x11), # 1-bit data format, 3 bytes per 24-pixel block + (0xB9, 0x20), # gamma, mono + (0xB8, 0x29), # panel setting + (0x21,), # display inversion on + (0x2A, 0x12, 0x2A), # column address window + (0x2B, 0x00, 0xC7), # row address window + (0x35, 0x00), # tearing effect line on + (0xD0, 0xFF), # auto power down + (0x38,), # high power mode + (0x29,), # display on + ), +) diff --git a/esphome/components/st7305/st7305.cpp b/esphome/components/st7305/st7305.cpp new file mode 100644 index 00000000000..36b58fdb240 --- /dev/null +++ b/esphome/components/st7305/st7305.cpp @@ -0,0 +1,258 @@ +#include "st7305.h" + +#include +#include + +#include "esphome/core/log.h" + +namespace esphome::st7305 { + +ESPHOME_LOG_TAG(TAG, "st7305"); + +void ST7305::setup() { + this->spi_setup(); + this->dc_pin_->setup(); + if (this->reset_pin_ != nullptr) + this->reset_pin_->setup(); + + if (!this->init_buffer_()) + return; + + this->reset_(); +} + +bool ST7305::init_buffer_() { + this->init_internal_(this->buffer_length_); + if (this->buffer_ == nullptr) { + this->mark_failed(LOG_STR("Buffer allocation failed")); + return false; + } + memset(this->buffer_, 0x00, this->buffer_length_); + this->clear_dirty_(); + return true; +} + +void ST7305::reset_() { + static constexpr uint32_t RESET_HIGH_MS = 50; + static constexpr uint32_t RESET_LOW_MS = 20; + static constexpr uint32_t RESET_SETTLE_MS = 50; + static constexpr uint32_t SOFT_RESET_MS = 5; + + if (this->reset_pin_ == nullptr) { + this->enable(); + this->send_command_(0x01); // software reset + this->disable(); + this->set_timeout(STARTUP_TIMEOUT_ID, SOFT_RESET_MS, [this]() { this->send_init_sequence_(); }); + return; + } + this->reset_pin_->digital_write(true); + this->set_timeout(STARTUP_TIMEOUT_ID, RESET_HIGH_MS, [this]() { + this->reset_pin_->digital_write(false); + this->set_timeout(STARTUP_TIMEOUT_ID, RESET_LOW_MS, [this]() { + this->reset_pin_->digital_write(true); + this->set_timeout(STARTUP_TIMEOUT_ID, RESET_SETTLE_MS, [this]() { this->send_init_sequence_(); }); + }); + }); +} + +void ST7305::send_init_sequence_() { + while (this->init_index_ < this->init_sequence_length_) { + if (this->init_sequence_length_ - this->init_index_ < 2) { + this->mark_failed(LOG_STR("Malformed init sequence")); + return; + } + const uint8_t command = progmem_read_byte(&this->init_sequence_[this->init_index_++]); + const uint8_t count = progmem_read_byte(&this->init_sequence_[this->init_index_++]); + if (count == 0xFF) { + // Resume the sequence after the delay instead of blocking the main loop. + this->set_timeout(STARTUP_TIMEOUT_ID, command, [this]() { this->send_init_sequence_(); }); + return; + } + if (this->init_sequence_length_ - this->init_index_ < count) { + this->mark_failed(LOG_STR("Malformed init sequence")); + return; + } + this->enable(); + this->send_command_(command); + if (count != 0) { + this->dc_pin_->digital_write(true); + for (uint8_t i = 0; i < count; i++) + this->write_byte(progmem_read_byte(&this->init_sequence_[this->init_index_ + i])); + } + this->disable(); + this->init_index_ += count; + } + this->finish_init_(); +} + +void ST7305::finish_init_() { + this->write_full_frame_(); + this->clear_dirty_(); + this->ready_ = true; +} + +void ST7305::write_full_frame_() { + const uint8_t caset[] = {this->column_start_, this->column_end_}; + const uint8_t raset[] = {this->row_start_, this->row_end_}; + this->enable(); + this->send_command_(0x2A); + this->send_data_(caset, sizeof(caset)); + this->send_command_(0x2B); + this->send_data_(raset, sizeof(raset)); + this->send_command_(0x2C); + this->send_data_(this->buffer_, this->buffer_length_); + this->disable(); +} + +ST7305::Window ST7305::compute_window_() const { + Window window{}; + if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) { + // RASET rows are x pairs; CASET columns are 12-pixel bands of y blocks. + window.stride = static_cast(this->height_ / 4); + window.outer_start = static_cast(this->x_low_ >> 1); + window.outer_end = static_cast(this->x_high_ >> 1); + const int inv_min = this->height_ - 1 - this->y_high_; + const int inv_max = this->height_ - 1 - this->y_low_; + window.block_start = static_cast((inv_min >> 2) / 3 * 3); + window.block_end = + static_cast(std::min(((inv_max >> 2) / 3) * 3 + 2, static_cast(window.stride) - 1)); + } else { + // RASET rows are y pairs; CASET columns are 12-pixel bands of x blocks. + window.stride = static_cast(this->width_ / 4); + window.outer_start = static_cast(this->y_low_ >> 1); + window.outer_end = static_cast(this->y_high_ >> 1); + const int block_min = this->x_low_ >> 2; + const int block_max = this->x_high_ >> 2; + window.block_start = static_cast(block_min / 3 * 3); + window.block_end = static_cast(std::min((block_max / 3) * 3 + 2, static_cast(window.stride) - 1)); + } + window.caset_start = static_cast(this->column_start_ + window.block_start / 3); + window.caset_end = static_cast(this->column_start_ + window.block_end / 3); + return window; +} + +void ST7305::write_window_(const Window &window) { + const uint8_t caset[] = {window.caset_start, window.caset_end}; + const uint8_t raset[] = {static_cast(this->row_start_ + window.outer_start), + static_cast(this->row_start_ + window.outer_end)}; + this->enable(); + this->send_command_(0x2A); + this->send_data_(caset, sizeof(caset)); + this->send_command_(0x2B); + this->send_data_(raset, sizeof(raset)); + this->send_command_(0x2C); + if (window.block_start == 0 && window.block_end == window.stride - 1) { + // Full-height window: every outer unit's data is contiguous with the next. + this->send_data_(this->buffer_ + window.outer_start * window.stride, + static_cast(window.outer_end - window.outer_start + 1) * window.stride); + } else { + const size_t length = window.block_end - window.block_start + 1; + for (uint16_t outer = window.outer_start; outer <= window.outer_end; outer++) + this->send_data_(this->buffer_ + outer * window.stride + window.block_start, length); + } + this->disable(); +} + +void ST7305::update() { + if (!this->ready_) + return; + + this->do_update_(); + if (!this->is_dirty_()) + return; + + const Window window = this->compute_window_(); + if (window.data_length() > this->buffer_length_ / 2) { + this->write_full_frame_(); + } else { + this->write_window_(window); + } + this->clear_dirty_(); +} + +void HOT ST7305::draw_absolute_pixel_internal(int x, int y, Color color) { + if (x < 0 || y < 0 || x >= this->width_ || y >= this->height_) + return; + size_t index; + uint8_t mask; + if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) { + const int inv_y = this->height_ - 1 - y; + index = static_cast(x >> 1) * (this->height_ / 4) + (inv_y >> 2); + mask = static_cast(1 << (7 - (((inv_y & 3) << 1) | (x & 1)))); + } else { + index = static_cast(y >> 1) * (this->width_ / 4) + (x >> 2); + mask = static_cast(1 << (7 - (((x & 3) << 1) | (y & 1)))); + } + // The init sequence enables INVON, so a set bit is white and a clear bit is black. + uint8_t &value = this->buffer_[index]; + const uint8_t previous = value; + if (color.is_on()) { + value |= mask; + } else { + value &= ~mask; + } + if (value == previous) + return; + this->extend_dirty_(x, y, x, y); +} + +void ST7305::fill(Color color) { + if (this->get_clipping().is_set()) { + display::Display::fill(color); + return; + } + if (this->buffer_ == nullptr) + return; + + const uint8_t value = color.is_on() ? 0xFF : 0x00; + size_t first = this->buffer_length_; + size_t last = 0; + for (size_t index = 0; index < this->buffer_length_; index++) { + if (this->buffer_[index] == value) + continue; + this->buffer_[index] = value; + if (first == this->buffer_length_) + first = index; + last = index; + } + if (first == this->buffer_length_) + return; + + // Widen the changed index range to whole outer units, and the other axis to the full + // display. The extra pixels are current in the buffer, so sending them is harmless. + if (this->orientation_ == Orientation::ORIENTATION_LANDSCAPE) { + const int stride = this->height_ / 4; + this->extend_dirty_(static_cast(first / stride) * 2, 0, static_cast(last / stride) * 2 + 1, + this->height_ - 1); + } else { + const int stride = this->width_ / 4; + this->extend_dirty_(0, static_cast(first / stride) * 2, this->width_ - 1, + static_cast(last / stride) * 2 + 1); + } +} + +void ST7305::extend_dirty_(int x0, int y0, int x1, int y1) { + if (x0 < this->x_low_) + this->x_low_ = x0; + if (x1 > this->x_high_) + this->x_high_ = x1; + if (y0 < this->y_low_) + this->y_low_ = y0; + if (y1 > this->y_high_) + this->y_high_ = y1; +} + +void ST7305::dump_config() { + LOG_DISPLAY("", "ST7305", this); + ESP_LOGCONFIG(TAG, " Model: %s", this->model_); + ESP_LOGCONFIG(TAG, " Orientation: %s", + this->orientation_ == Orientation::ORIENTATION_LANDSCAPE ? LOG_STR_LITERAL("landscape") + : LOG_STR_LITERAL("portrait")); + ESP_LOGCONFIG(TAG, " Window: columns %u..%u, rows %u..%u", this->column_start_, this->column_end_, this->row_start_, + this->row_end_); + LOG_PIN(" DC Pin: ", this->dc_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::st7305 diff --git a/esphome/components/st7305/st7305.h b/esphome/components/st7305/st7305.h new file mode 100644 index 00000000000..02b00b729df --- /dev/null +++ b/esphome/components/st7305/st7305.h @@ -0,0 +1,127 @@ +#pragma once + +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::st7305 { + +enum Orientation : uint8_t { + ORIENTATION_LANDSCAPE = 0, + ORIENTATION_PORTRAIT = 1, +}; + +/// Sitronix ST7305/ST7306 reflective monochrome LCD, 1 bit per pixel. +/// +/// The controller RAM is addressed in 12-column x 2-row blocks (3 bytes per address). With a +/// landscape panel one byte covers 2 pixels in x and 4 in y; with a portrait panel it covers +/// 4 pixels in x and 2 in y. +class ST7305 : public display::DisplayBuffer, + public spi::SPIDevice { + public: + ST7305(const char *model, int width, int height, Orientation orientation, uint8_t column_start, uint8_t column_end, + uint8_t row_start, uint8_t row_end, const uint8_t *init_sequence, size_t init_sequence_length) + : model_(model), + width_(width), + height_(height), + orientation_(orientation), + column_start_(column_start), + column_end_(column_end), + row_start_(row_start), + row_end_(row_end), + buffer_length_(static_cast(width) * static_cast(height) / 8), + init_sequence_(init_sequence), + init_sequence_length_(init_sequence_length) {} + + void setup() override; + void update() override; + void dump_config() override; + + void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + + void fill(Color color) override; + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_BINARY; } + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + protected: + /// An addressable write window. RASET addresses are the outer index (x pairs in landscape, + /// y pairs in portrait); CASET addresses are 12-pixel bands of the inner index. + struct Window { + uint16_t outer_start; + uint16_t outer_end; + uint16_t block_start; + uint16_t block_end; + uint16_t stride; + uint8_t caset_start; + uint8_t caset_end; + + size_t data_length() const { + return static_cast(this->outer_end - this->outer_start + 1) * + static_cast(this->block_end - this->block_start + 1); + } + }; + + void draw_absolute_pixel_internal(int x, int y, Color color) override; + int get_width_internal() override { return this->width_; } + int get_height_internal() override { return this->height_; } + + bool init_buffer_(); + void reset_(); + void send_init_sequence_(); + void finish_init_(); + void write_full_frame_(); + void write_window_(const Window &window); + Window compute_window_() const; + + /// Scheduler id for the non-blocking reset and init sequence. The steps are sequential, + /// so one id is enough; a new timeout replaces any pending one. + static constexpr uint32_t STARTUP_TIMEOUT_ID = 0; + + void send_command_(uint8_t command) { + this->dc_pin_->digital_write(false); + this->write_byte(command); + } + void send_data_(const uint8_t *data, size_t length) { + this->dc_pin_->digital_write(true); + this->write_array(data, length); + } + + bool is_dirty_() const { return this->x_high_ >= this->x_low_ && this->y_high_ >= this->y_low_; } + void clear_dirty_() { + this->x_low_ = this->width_; + this->x_high_ = -1; + this->y_low_ = this->height_; + this->y_high_ = -1; + } + void extend_dirty_(int x0, int y0, int x1, int y1); + + GPIOPin *dc_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + + const char *model_; + int width_; + int height_; + Orientation orientation_; + uint8_t column_start_; + uint8_t column_end_; + uint8_t row_start_; + uint8_t row_end_; + size_t buffer_length_; + const uint8_t *init_sequence_; + size_t init_sequence_length_; + size_t init_index_{0}; + bool ready_{false}; + + int x_low_{0}; + int x_high_{-1}; + int y_low_{0}; + int y_high_{-1}; +}; + +} // namespace esphome::st7305 diff --git a/tests/component_tests/st7305/test_st7305.py b/tests/component_tests/st7305/test_st7305.py new file mode 100644 index 00000000000..016252cdf28 --- /dev/null +++ b/tests/component_tests/st7305/test_st7305.py @@ -0,0 +1,74 @@ +"""Tests for the st7305 configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.st7305.display import ( + CONF_COLUMN_END, + CONF_COLUMN_START, + CONF_PORTRAIT, + CONF_ROW_END, + MODELS, + _resolve, +) +from esphome.const import CONF_CS_PIN, CONF_DC_PIN, CONF_HEIGHT, CONF_WIDTH + + +def _config(**overrides): + config = { + CONF_WIDTH: 400, + CONF_HEIGHT: 300, + CONF_PORTRAIT: False, + CONF_CS_PIN: 1, + CONF_DC_PIN: 2, + } + config.update(overrides) + return config + + +def test_landscape_derives_window() -> None: + config = _config() + _resolve(config, MODELS["CUSTOM"]) + assert config[CONF_COLUMN_START] == 0 + assert config[CONF_COLUMN_END] == 24 + assert config[CONF_ROW_END] == 199 + + +@pytest.mark.parametrize("height", [160, 200, 296, 308]) +def test_landscape_rejects_inner_not_multiple_of_12(height: int) -> None: + with pytest.raises(cv.Invalid, match="height divisible by 12"): + _resolve(_config(**{CONF_HEIGHT: height}), MODELS["CUSTOM"]) + + +def test_portrait_derives_window() -> None: + config = _config( + **{ + CONF_WIDTH: 168, + CONF_HEIGHT: 384, + CONF_PORTRAIT: True, + CONF_COLUMN_START: 0x17, + } + ) + _resolve(config, MODELS["CUSTOM"]) + assert config[CONF_COLUMN_END] == 0x24 + assert config[CONF_ROW_END] == 191 + + +@pytest.mark.parametrize("width", [160, 200, 208]) +def test_portrait_rejects_inner_not_multiple_of_12(width: int) -> None: + with pytest.raises(cv.Invalid, match="width divisible by 12"): + _resolve( + _config( + **{ + CONF_WIDTH: width, + CONF_HEIGHT: 384, + CONF_PORTRAIT: True, + } + ), + MODELS["CUSTOM"], + ) + + +def test_window_ends_must_fit_in_a_byte() -> None: + with pytest.raises(cv.Invalid, match="between 0 and 255"): + _resolve(_config(**{CONF_WIDTH: 1024, CONF_HEIGHT: 300}), MODELS["CUSTOM"]) diff --git a/tests/components/st7305/common.h b/tests/components/st7305/common.h new file mode 100644 index 00000000000..856e1b83b01 --- /dev/null +++ b/tests/components/st7305/common.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/spi/spi.h" +#include "esphome/components/st7305/st7305.h" +#include "esphome/core/hal.h" + +namespace esphome::st7305::testing { + +class TestableST7305 : public ST7305 { + public: + TestableST7305(int width = 400, int height = 300, Orientation orientation = Orientation::ORIENTATION_LANDSCAPE, + uint8_t column_start = 0x12, uint8_t column_end = 0x2A, uint8_t row_start = 0x00, + uint8_t row_end = 0xC7) + : ST7305("TEST", width, height, orientation, column_start, column_end, row_start, row_end, nullptr, 0) {} + + bool init_buffer() { return this->init_buffer_(); } + void clear_dirty() { this->clear_dirty_(); } + bool is_dirty() const { return this->is_dirty_(); } + int x_low() const { return this->x_low_; } + int x_high() const { return this->x_high_; } + int y_low() const { return this->y_low_; } + int y_high() const { return this->y_high_; } + Window compute_window() const { return this->compute_window_(); } + void write_window() { this->write_window_(this->compute_window_()); } + uint8_t *buffer() { return this->buffer_; } + void attach_spi(spi::SPIDelegate *delegate, GPIOPin *dc_pin) { + this->delegate_ = delegate; + this->set_dc_pin(dc_pin); + } + + size_t buffer_length() const { return this->buffer_length_; } + int width() const { return this->width_; } + int height() const { return this->height_; } +}; + +/// GPIO pin that remembers the last level written to it. +class RecordingPin : public GPIOPin { + public: + void setup() override {} + void pin_mode(gpio::Flags flags) override {} + gpio::Flags get_flags() const override { return gpio::Flags::FLAG_NONE; } + bool digital_read() override { return false; } + void digital_write(bool value) override { this->level = value; } + size_t dump_summary(char *buffer, size_t len) const override { return snprintf(buffer, len, "recording"); } + + bool level{true}; +}; + +/// SPI delegate that records every payload and whether it was a command or data phase. +class RecordingDelegate : public spi::SPIDelegate { + public: + explicit RecordingDelegate(const RecordingPin *dc) + : spi::SPIDelegate(10'000'000, spi::BIT_ORDER_MSB_FIRST, spi::MODE0, nullptr), dc_(dc) {} + + uint8_t transfer(uint8_t data) override { + this->record_(&data, 1); + return 0; + } + void write_array(const uint8_t *ptr, size_t length) override { this->record_(ptr, length); } + + std::vector bytes; + std::vector> chunks; // (data phase, length) + + protected: + void record_(const uint8_t *ptr, size_t length) { + this->chunks.emplace_back(this->dc_->level, length); + this->bytes.insert(this->bytes.end(), ptr, ptr + length); + } + + const RecordingPin *dc_; +}; + +} // namespace esphome::st7305::testing diff --git a/tests/components/st7305/common.yaml b/tests/components/st7305/common.yaml new file mode 100644 index 00000000000..064ddcb45aa --- /dev/null +++ b/tests/components/st7305/common.yaml @@ -0,0 +1,6 @@ +display: + - platform: st7305 + model: WAVESHARE-400X300 + id: st7305_display + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); diff --git a/tests/components/st7305/display/test_st7305.cpp b/tests/components/st7305/display/test_st7305.cpp new file mode 100644 index 00000000000..e447e09e14d --- /dev/null +++ b/tests/components/st7305/display/test_st7305.cpp @@ -0,0 +1,255 @@ +#include + +#include "../common.h" + +namespace esphome::st7305::testing { + +class ST7305Test : public ::testing::Test { + protected: + void SetUp() override { ASSERT_TRUE(this->display_.init_buffer()); } + + void reset_screen_() { + this->display_.fill(Color::BLACK); + this->display_.clear_dirty(); + } + + TestableST7305 display_; +}; + +TEST_F(ST7305Test, FillSetsAllBytes) { + this->display_.fill(Color::WHITE); + for (size_t i = 0; i < this->display_.buffer_length(); i++) + EXPECT_EQ(this->display_.buffer()[i], 0xFF) << "byte " << i; + + this->display_.fill(Color::BLACK); + for (size_t i = 0; i < this->display_.buffer_length(); i++) + EXPECT_EQ(this->display_.buffer()[i], 0x00) << "byte " << i; +} + +TEST_F(ST7305Test, PixelMapping) { + struct PixelCase { + int x; + int y; + size_t index; + uint8_t mask; + }; + const PixelCase cases[] = { + {0, 299, 0, 0x80}, {1, 299, 0, 0x40}, {0, 298, 0, 0x20}, {0, 297, 0, 0x08}, {0, 296, 0, 0x02}, + {2, 299, 75, 0x80}, {0, 0, 74, 0x02}, {1, 0, 74, 0x01}, {200, 150, 7537, 0x20}, {399, 0, 14999, 0x01}, + }; + + for (const auto &pixel : cases) { + this->display_.draw_pixel_at(pixel.x, pixel.y, Color::WHITE); + EXPECT_EQ(this->display_.buffer()[pixel.index], pixel.mask) << "pixel " << pixel.x << "," << pixel.y; + this->display_.draw_pixel_at(pixel.x, pixel.y, Color::BLACK); + EXPECT_EQ(this->display_.buffer()[pixel.index], 0x00) << "pixel " << pixel.x << "," << pixel.y; + } +} + +TEST_F(ST7305Test, ClearingOnePixelLeavesNeighbours) { + this->display_.draw_pixel_at(0, 299, Color::WHITE); + this->display_.draw_pixel_at(1, 299, Color::WHITE); + EXPECT_EQ(this->display_.buffer()[0], 0xC0); + + this->display_.draw_pixel_at(0, 299, Color::BLACK); + EXPECT_EQ(this->display_.buffer()[0], 0x40); +} + +TEST_F(ST7305Test, DirtyBoundsFollowChanges) { + EXPECT_FALSE(this->display_.is_dirty()); + + this->display_.draw_pixel_at(10, 20, Color::WHITE); + EXPECT_TRUE(this->display_.is_dirty()); + EXPECT_EQ(this->display_.x_low(), 10); + EXPECT_EQ(this->display_.x_high(), 10); + EXPECT_EQ(this->display_.y_low(), 20); + EXPECT_EQ(this->display_.y_high(), 20); + + this->display_.clear_dirty(); + this->display_.draw_pixel_at(10, 20, Color::WHITE); + EXPECT_FALSE(this->display_.is_dirty()); + + this->display_.draw_pixel_at(10, 20, Color::BLACK); + EXPECT_TRUE(this->display_.is_dirty()); + + this->display_.clear_dirty(); + this->display_.draw_pixel_at(10, 20, Color::WHITE); + this->display_.draw_pixel_at(100, 200, Color::WHITE); + EXPECT_EQ(this->display_.x_low(), 10); + EXPECT_EQ(this->display_.x_high(), 100); + EXPECT_EQ(this->display_.y_low(), 20); + EXPECT_EQ(this->display_.y_high(), 200); +} + +TEST_F(ST7305Test, FillOnlyDirtiesChangedBytes) { + this->display_.clear_dirty(); + this->display_.fill(Color::BLACK); + EXPECT_FALSE(this->display_.is_dirty()); + + this->display_.fill(Color::WHITE); + EXPECT_TRUE(this->display_.is_dirty()); + EXPECT_EQ(this->display_.x_low(), 0); + EXPECT_EQ(this->display_.x_high(), 399); + EXPECT_EQ(this->display_.y_low(), 0); + EXPECT_EQ(this->display_.y_high(), 299); + + this->display_.clear_dirty(); + this->display_.fill(Color::WHITE); + EXPECT_FALSE(this->display_.is_dirty()); +} + +TEST_F(ST7305Test, WindowAlignsToAddressBlocks) { + this->reset_screen_(); + this->display_.draw_pixel_at(0, 299, Color::WHITE); + this->display_.draw_pixel_at(1, 299, Color::WHITE); + + const auto window = this->display_.compute_window(); + EXPECT_EQ(window.outer_start, 0); + EXPECT_EQ(window.outer_end, 0); + EXPECT_EQ(window.block_start, 0); + EXPECT_EQ(window.block_end, 2); + EXPECT_EQ(window.caset_start, 0x12); + EXPECT_EQ(window.caset_end, 0x12); + EXPECT_EQ(window.data_length(), 3u); + + this->reset_screen_(); + this->display_.draw_pixel_at(200, 150, Color::WHITE); + + const auto middle = this->display_.compute_window(); + EXPECT_EQ(middle.outer_start, 100); + EXPECT_EQ(middle.outer_end, 100); + EXPECT_EQ(middle.block_start, 36); + EXPECT_EQ(middle.block_end, 38); + EXPECT_EQ(middle.caset_start, 0x1E); + EXPECT_EQ(middle.caset_end, 0x1E); + EXPECT_EQ(middle.data_length(), 3u); + + this->reset_screen_(); + this->display_.draw_pixel_at(0, 0, Color::WHITE); + this->display_.draw_pixel_at(0, 299, Color::WHITE); + + const auto full_height = this->display_.compute_window(); + EXPECT_EQ(full_height.outer_start, 0); + EXPECT_EQ(full_height.outer_end, 0); + EXPECT_EQ(full_height.block_start, 0); + EXPECT_EQ(full_height.block_end, 74); + EXPECT_EQ(full_height.caset_start, 0x12); + EXPECT_EQ(full_height.caset_end, 0x2A); + EXPECT_EQ(full_height.data_length(), 75u); +} + +TEST_F(ST7305Test, WriteWindowEmitsWindowCommands) { + for (size_t i = 0; i < this->display_.buffer_length(); i++) + this->display_.buffer()[i] = static_cast(i & 0xFF); + + RecordingPin dc_pin; + RecordingDelegate delegate(&dc_pin); + this->display_.attach_spi(&delegate, &dc_pin); + + this->display_.draw_pixel_at(200, 151, Color::WHITE); + this->display_.write_window(); + + const uint8_t expected[] = { + 0x2A, + 0x1E, + 0x1E, + 0x2B, + 100, + 100, + 0x2C, + this->display_.buffer()[100 * 75 + 36], + this->display_.buffer()[100 * 75 + 37], + this->display_.buffer()[100 * 75 + 38], + }; + ASSERT_EQ(delegate.bytes.size(), sizeof(expected)); + for (size_t i = 0; i < sizeof(expected); i++) + EXPECT_EQ(delegate.bytes[i], expected[i]) << "byte " << i; + + ASSERT_EQ(delegate.chunks.size(), 6u); + EXPECT_EQ(delegate.chunks[0], std::make_pair(false, size_t{1})); + EXPECT_EQ(delegate.chunks[1], std::make_pair(true, size_t{2})); + EXPECT_EQ(delegate.chunks[2], std::make_pair(false, size_t{1})); + EXPECT_EQ(delegate.chunks[3], std::make_pair(true, size_t{2})); + EXPECT_EQ(delegate.chunks[4], std::make_pair(false, size_t{1})); + EXPECT_EQ(delegate.chunks[5], std::make_pair(true, size_t{3})); +} + +TEST_F(ST7305Test, PortraitPixelMapping) { + TestableST7305 portrait(168, 384, Orientation::ORIENTATION_PORTRAIT, 0x17, 0x24, 0x00, 0xBF); + ASSERT_TRUE(portrait.init_buffer()); + ASSERT_EQ(portrait.get_width(), 168); + ASSERT_EQ(portrait.get_height(), 384); + + struct PixelCase { + int x; + int y; + size_t index; + uint8_t mask; + }; + const PixelCase cases[] = { + {0, 0, 0, 0x80}, {1, 0, 0, 0x20}, {2, 0, 0, 0x08}, {3, 0, 0, 0x02}, + {0, 1, 0, 0x40}, {4, 0, 1, 0x80}, {0, 2, 42, 0x80}, {167, 383, 8063, 0x01}, + }; + + for (const auto &pixel : cases) { + portrait.draw_pixel_at(pixel.x, pixel.y, Color::WHITE); + EXPECT_EQ(portrait.buffer()[pixel.index], pixel.mask) << "pixel " << pixel.x << "," << pixel.y; + portrait.draw_pixel_at(pixel.x, pixel.y, Color::BLACK); + EXPECT_EQ(portrait.buffer()[pixel.index], 0x00) << "pixel " << pixel.x << "," << pixel.y; + } +} + +TEST_F(ST7305Test, PortraitWindowAlignsToAddressBlocks) { + TestableST7305 portrait(168, 384, Orientation::ORIENTATION_PORTRAIT, 0x17, 0x24, 0x00, 0xBF); + ASSERT_TRUE(portrait.init_buffer()); + + portrait.draw_pixel_at(84, 192, Color::WHITE); + const auto window = portrait.compute_window(); + EXPECT_EQ(window.outer_start, 96); + EXPECT_EQ(window.outer_end, 96); + EXPECT_EQ(window.stride, 42); + EXPECT_EQ(window.block_start, 21); + EXPECT_EQ(window.block_end, 23); + EXPECT_EQ(window.caset_start, 0x1E); + EXPECT_EQ(window.caset_end, 0x1E); + EXPECT_EQ(window.data_length(), 3u); + + portrait.fill(Color::BLACK); + portrait.clear_dirty(); + portrait.draw_pixel_at(0, 0, Color::WHITE); + portrait.draw_pixel_at(3, 1, Color::WHITE); + const auto corner = portrait.compute_window(); + EXPECT_EQ(corner.outer_start, 0); + EXPECT_EQ(corner.outer_end, 0); + EXPECT_EQ(corner.block_start, 0); + EXPECT_EQ(corner.block_end, 2); + EXPECT_EQ(corner.caset_start, 0x17); + EXPECT_EQ(corner.caset_end, 0x17); + EXPECT_EQ(corner.data_length(), 3u); + + portrait.fill(Color::BLACK); + portrait.clear_dirty(); + portrait.draw_pixel_at(167, 200, Color::WHITE); + const auto right_edge = portrait.compute_window(); + EXPECT_EQ(right_edge.outer_start, 100); + EXPECT_EQ(right_edge.outer_end, 100); + EXPECT_EQ(right_edge.block_start, 39); + EXPECT_EQ(right_edge.block_end, 41); + EXPECT_EQ(right_edge.caset_start, 0x24); + EXPECT_EQ(right_edge.caset_end, 0x24); + EXPECT_EQ(right_edge.data_length(), 3u); + + portrait.fill(Color::BLACK); + portrait.clear_dirty(); + portrait.draw_pixel_at(167, 383, Color::WHITE); + const auto bottom_right = portrait.compute_window(); + EXPECT_EQ(bottom_right.outer_start, 191); + EXPECT_EQ(bottom_right.outer_end, 191); + EXPECT_EQ(bottom_right.block_start, 39); + EXPECT_EQ(bottom_right.block_end, 41); + EXPECT_EQ(bottom_right.caset_start, 0x24); + EXPECT_EQ(bottom_right.caset_end, 0x24); + EXPECT_EQ(bottom_right.data_length(), 3u); +} + +} // namespace esphome::st7305::testing diff --git a/tests/components/st7305/test.esp32-s3-idf.yaml b/tests/components/st7305/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..0cc297c380e --- /dev/null +++ b/tests/components/st7305/test.esp32-s3-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + clk_pin: GPIO18 + mosi_pin: GPIO17 + miso_pin: GPIO16 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + st7305: !include common.yaml diff --git a/tests/components/st7305/validate-custom-landscape.esp32-idf.yaml b/tests/components/st7305/validate-custom-landscape.esp32-idf.yaml new file mode 100644 index 00000000000..55314946e14 --- /dev/null +++ b/tests/components/st7305/validate-custom-landscape.esp32-idf.yaml @@ -0,0 +1,21 @@ +substitutions: + cs_pin: GPIO12 + dc_pin: GPIO13 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: st7305 + model: CUSTOM + width: 400 + height: 300 + portrait: false + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + init_sequence: + - [0x01] + - [0x11, 0x00] + - [0x29] + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); diff --git a/tests/components/st7305/validate-custom.esp32-idf.yaml b/tests/components/st7305/validate-custom.esp32-idf.yaml new file mode 100644 index 00000000000..c29874aa190 --- /dev/null +++ b/tests/components/st7305/validate-custom.esp32-idf.yaml @@ -0,0 +1,22 @@ +substitutions: + cs_pin: GPIO12 + dc_pin: GPIO13 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: st7305 + model: CUSTOM + width: 168 + height: 384 + portrait: true + column_start: 0x17 + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + init_sequence: + - [0x01] + - [0x11, 0x00] + - [0x29] + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); From 15b988be9c6c65970daec59f30d5b5a16df5cb8d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:44:22 +1100 Subject: [PATCH 172/179] [cst9220] Fix long press reported as several short presses (#21034) --- .../touchscreen/cst9220_touchscreen.cpp | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp index 6d869ec5ea7..766bc38929c 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -5,6 +5,10 @@ namespace esphome::cst9220 { +// Id 0 is the base Touchscreen class touch timeout. +static constexpr uint32_t REREAD_TIMEOUT_ID = 1; +static constexpr uint32_t REREAD_DELAY_MS = 20; + void CST9220Touchscreen::setup() { if (this->reset_pin_ != nullptr) { this->reset_pin_->setup(); @@ -92,15 +96,15 @@ void CST9220Touchscreen::update_touches() { return; } - // Acknowledge the report so the controller can prepare the next one. - uint8_t ack = TOUCH_ACK; - this->write_register16(REG_TOUCH_DATA, &ack, 1); - - // A valid report carries the ACK marker at offset 6; offset 0 holds the first - // point and must be neither the ACK marker nor empty. Anything else means no - // valid touch data this cycle, which we report as zero touches (not a skip). - if (data[0] == TOUCH_ACK || data[0] == 0x00 || data[6] != TOUCH_ACK) + // A valid report carries the marker at offset 6. Writing an acknowledge back makes the controller return + // invalid frames during a long press, so none is written. An invalid frame keeps the previous points; while + // a finger is down, read again shortly so a lost release frame does not leave the touch held. + if (data[6] != TOUCH_ACK) { + this->skip_update_ = true; + if (!this->touches_.empty()) + this->set_timeout(REREAD_TIMEOUT_ID, REREAD_DELAY_MS, [this] { this->store_.touched = true; }); return; + } uint8_t num_touches = data[5] & 0x7F; if (num_touches > CST9220_MAX_TOUCHES) From 1cd56419007e2ddb269a04f7c0fcf02bcce062c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 06:25:30 -1000 Subject: [PATCH 173/179] [web_server] Keep the remaining ESP8266 web server strings in flash (#21113) --- .../captive_portal/captive_portal.cpp | 8 ++--- esphome/components/web_server/web_server.cpp | 35 +++++-------------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 16ebd407625..d169dcbf6ca 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -58,8 +58,8 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { request->send(stream); } void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { - const auto &ssid = request->arg("ssid"); - const auto &psk = request->arg("psk"); + const auto &ssid = request->arg(ESPHOME_F("ssid")); + const auto &psk = request->arg(ESPHOME_F("psk")); ESP_LOGI(TAG, "Requested WiFi Settings Change:\n" " SSID='%s'\n" @@ -117,11 +117,7 @@ void CaptivePortal::handleRequest(AsyncWebServerRequest *req) { // All other requests get the captive portal page // This includes OS captive portal detection endpoints which will trigger // the captive portal when they don't receive their expected responses -#ifndef USE_ESP8266 auto *response = req->beginResponse(200, ESPHOME_F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); -#else - auto *response = req->beginResponse_P(200, ESPHOME_F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); -#endif #ifdef USE_CAPTIVE_PORTAL_GZIP response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip")); #else diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 48a2ce4a63b..4546ead98b2 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -350,7 +350,7 @@ void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, size_t } void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServerRequest *request) { - DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, "/events"); + DeferredUpdateEventSource *es = new DeferredUpdateEventSource(ws, ESPHOME_F("/events")); this->push_back(es); es->onConnect([this, es](AsyncEventSourceClient *client) { this->on_client_connect_(es); }); @@ -533,11 +533,7 @@ float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f #ifdef USE_WEBSERVER_LOCAL void WebServer::handle_index_request(AsyncWebServerRequest *request) { -#ifndef USE_ESP8266 - AsyncWebServerResponse *response = request->beginResponse(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); -#else - AsyncWebServerResponse *response = request->beginResponse_P(200, "text/html", INDEX_GZ, sizeof(INDEX_GZ)); -#endif + AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("text/html"), INDEX_GZ, sizeof(INDEX_GZ)); #ifdef USE_WEBSERVER_GZIP response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip")); #else @@ -547,13 +543,8 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #elif USE_WEBSERVER_VERSION >= 2 void WebServer::handle_index_request(AsyncWebServerRequest *request) { -#ifndef USE_ESP8266 - AsyncWebServerResponse *response = - request->beginResponse(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); -#else - AsyncWebServerResponse *response = - request->beginResponse_P(200, "text/html", ESPHOME_WEBSERVER_INDEX_HTML, ESPHOME_WEBSERVER_INDEX_HTML_SIZE); -#endif + AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("text/html"), ESPHOME_WEBSERVER_INDEX_HTML, + ESPHOME_WEBSERVER_INDEX_HTML_SIZE); // No gzip header here because the HTML file is so small request->send(response); } @@ -625,13 +616,8 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { #ifdef USE_WEBSERVER_CSS_INCLUDE void WebServer::handle_css_request(AsyncWebServerRequest *request) { -#ifndef USE_ESP8266 - AsyncWebServerResponse *response = - request->beginResponse(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); -#else - AsyncWebServerResponse *response = - request->beginResponse_P(200, "text/css", ESPHOME_WEBSERVER_CSS_INCLUDE, ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); -#endif + AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("text/css"), ESPHOME_WEBSERVER_CSS_INCLUDE, + ESPHOME_WEBSERVER_CSS_INCLUDE_SIZE); response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip")); request->send(response); } @@ -639,13 +625,8 @@ void WebServer::handle_css_request(AsyncWebServerRequest *request) { #ifdef USE_WEBSERVER_JS_INCLUDE void WebServer::handle_js_request(AsyncWebServerRequest *request) { -#ifndef USE_ESP8266 - AsyncWebServerResponse *response = - request->beginResponse(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); -#else - AsyncWebServerResponse *response = - request->beginResponse_P(200, "text/javascript", ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); -#endif + AsyncWebServerResponse *response = request->beginResponse( + 200, ESPHOME_F("text/javascript"), ESPHOME_WEBSERVER_JS_INCLUDE, ESPHOME_WEBSERVER_JS_INCLUDE_SIZE); response->addHeader(ESPHOME_F("Content-Encoding"), ESPHOME_F("gzip")); request->send(response); } From 8ffdbfd4ce1028015e2d8f15529e26afd1fc453d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 07:48:32 -1000 Subject: [PATCH 174/179] [homeassistant] Judge a light report against the transition it arrived during (#21100) --- .../light/homeassistant_light.cpp | 51 +++-- .../homeassistant/light/homeassistant_light.h | 20 +- .../test_api_homeassistant_light.py | 206 ++++++++++-------- 3 files changed, 161 insertions(+), 116 deletions(-) diff --git a/esphome/components/homeassistant/light/homeassistant_light.cpp b/esphome/components/homeassistant/light/homeassistant_light.cpp index 6ebe0cadbfb..21823bb9097 100644 --- a/esphome/components/homeassistant/light/homeassistant_light.cpp +++ b/esphome/components/homeassistant/light/homeassistant_light.cpp @@ -131,9 +131,7 @@ void HomeassistantLight::setup() { ESP_LOGW(TAG, "'%s': Can't parse '%s' as state!", this->entity_id_, value.c_str()); return; } - const bool state = on_off == PARSE_ON; - this->on_report_(this->imported_.state != state); - this->imported_.state = state; + this->import_(this->imported_.state, optional(on_off == PARSE_ON)); }); server->subscribe_home_assistant_state(this->entity_id_, "color_mode", [this](StringRef value) { if (is_unset(value)) { @@ -144,19 +142,16 @@ void HomeassistantLight::setup() { ESP_LOGE(TAG, "'%s': Can't parse 'color_mode' value '%s' as color mode!", this->entity_id_, value.c_str()); return; } - this->on_report_(this->imported_.color_mode != *mode); - this->imported_.color_mode = *mode; + this->import_(this->imported_.color_mode, *mode); }); server->subscribe_home_assistant_state(this->entity_id_, "brightness", [this](StringRef value) { if (auto brightness = this->parse_integer_("brightness", value, 255)) { - this->on_report_(this->imported_.brightness != *brightness); - this->imported_.brightness = *brightness; + this->import_(this->imported_.brightness, static_cast(*brightness)); } }); server->subscribe_home_assistant_state(this->entity_id_, "color_temp_kelvin", [this](StringRef value) { if (auto kelvin = this->parse_integer_("color_temp_kelvin", value, UINT16_MAX)) { - this->on_report_(this->imported_.color_temp_kelvin != *kelvin); - this->imported_.color_temp_kelvin = *kelvin; + this->import_(this->imported_.color_temp_kelvin, *kelvin); } }); server->subscribe_home_assistant_state(this->entity_id_, "rgb_color", [this](StringRef value) { @@ -202,11 +197,19 @@ optional HomeassistantLight::parse_integer_(const char *name, StringRe void HomeassistantLight::on_report_(bool changed) { // A report that changed an attribute answers the change that armed the echo timeout; a repeated // value (or a trait) does not, so a refused change still gets its warning - if (changed && this->echo_pending_) { - this->echo_pending_ = false; - this->cancel_timeout(ECHO_TIMEOUT_ID); + if (changed) { + if (this->echo_pending_) { + this->echo_pending_ = false; + this->cancel_timeout(ECHO_TIMEOUT_ID); + } + // Noted as the report arrives, not when the deferred apply runs: a call read from the same loop + // pass before this report is older than it, one read after it is newer and must not pass for + // the transition the report is held for + this->report_in_transition_ = this->state_->is_transitioning(); + if (this->report_in_transition_) { + this->held_hash_ = state_hash(this->state_->remote_values); + } } - this->report_changed_ |= changed; this->schedule_apply_(); } @@ -255,9 +258,8 @@ void HomeassistantLight::apply_imported_() { if (!this->state_->set_external_state(values)) { // A report that changed something during a transition or flash is applied once it ends, in // place of the values it ends at; a trait or a repeated report has nothing newer to say - if (this->state_->is_transitioning() && this->report_changed_) { + if (this->report_in_transition_) { this->report_held_ = true; - this->held_hash_ = state_hash(this->state_->remote_values); return; } // A local change still waits for LightState::loop() to send it. Home Assistant reports again @@ -266,7 +268,7 @@ void HomeassistantLight::apply_imported_() { ESP_LOGD(TAG, "'%s': Report dropped, a local change is on its way", this->entity_id_); return; } - this->report_changed_ = false; + this->report_in_transition_ = false; ESP_LOGV(TAG, "'%s': Took over the state reported by Home Assistant", this->entity_id_); } @@ -289,15 +291,17 @@ void HomeassistantLight::update_state(LightState *state) { } this->initial_seen_ = true; } - // A call without a transition replaces whatever ran, so a report held for it is older than the call + // A call without a transition replaces whatever ran, so a report held for it, or still waiting to + // be applied, is older than the call if (!state->is_transitioning()) { this->report_held_ = false; + this->report_in_transition_ = false; } } bool HomeassistantLight::is_imported_(const Attributes &outgoing) const { const Attributes &imported = this->imported_; - if (!imported.state.has_value() || !outgoing.state.has_value() || *imported.state != *outgoing.state) { + if (!outgoing.state.has_value() || imported.state != outgoing.state) { return false; } if (!*outgoing.state) { @@ -346,7 +350,7 @@ void HomeassistantLight::write_state(LightState *state) { const bool transitioned = this->transitioned_; this->report_held_ = false; this->transitioned_ = false; - this->report_changed_ = false; // this change is newer than any report so far, held or dropped + this->report_in_transition_ = false; // this change is newer than any report so far, held or dropped // Home Assistant changed meanwhile; its report is newer than what the transition ends at or // what the flash goes back to if (held) { @@ -430,10 +434,10 @@ void HomeassistantLight::write_state(LightState *state) { entity_id.key = ProgmemStringRef::from_lit(ENTITY_ID_KEY); entity_id.value = ProgmemStringRef(StringRef(this->entity_id_)); - auto add_number = [&](ProgmemStringRef key, auto &buf, unsigned number) { + auto add_number = [&](ProgmemStringRef key, char *buf, size_t size, unsigned number) { auto &entry = resp.data.emplace_back(); entry.key = key; - entry.value = ProgmemStringRef(StringRef(buf, buf_append_printf(buf, sizeof(buf), 0, "%u", number))); + entry.value = ProgmemStringRef(StringRef(buf, buf_append_printf(buf, size, 0, "%u", number))); }; if (!*outgoing.state) { resp.service = ProgmemStringRef::from_lit(SERVICE_TURN_OFF); @@ -443,7 +447,7 @@ void HomeassistantLight::write_state(LightState *state) { // The level goes under "white" in that mode, which also selects the mode add_number(outgoing.color_mode == ColorMode::WHITE ? ProgmemStringRef::from_lit(WHITE_KEY) : ProgmemStringRef::from_lit(BRIGHTNESS_KEY), - level_buf, outgoing.brightness); + level_buf, sizeof(level_buf), outgoing.brightness); } // Home Assistant converts the color to the light's own channels, which loses the white balance // of a cold/warm white light, so the color goes only when it or the mode changed (or may have, @@ -451,7 +455,8 @@ void HomeassistantLight::write_state(LightState *state) { const Attributes &imported = this->imported_; const bool new_color = this->echo_pending_ || outgoing.color_mode != imported.color_mode; if (outgoing.color_temp_kelvin != 0 && (new_color || outgoing.color_temp_kelvin != imported.color_temp_kelvin)) { - add_number(ProgmemStringRef::from_lit(COLOR_TEMP_KELVIN_KEY), color_temp_buf, outgoing.color_temp_kelvin); + add_number(ProgmemStringRef::from_lit(COLOR_TEMP_KELVIN_KEY), color_temp_buf, sizeof(color_temp_buf), + outgoing.color_temp_kelvin); } if (outgoing.has_rgb && (new_color || !imported.has_rgb || outgoing.rgb != imported.rgb)) { // A list, which only a template renders as one diff --git a/esphome/components/homeassistant/light/homeassistant_light.h b/esphome/components/homeassistant/light/homeassistant_light.h index a7f316a22f8..a90a39f2c0a 100644 --- a/esphome/components/homeassistant/light/homeassistant_light.h +++ b/esphome/components/homeassistant/light/homeassistant_light.h @@ -4,6 +4,7 @@ #include "esphome/components/light/light_output.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" namespace esphome::homeassistant { @@ -42,6 +43,11 @@ class HomeassistantLight final : public light::LightOutput, public Component { optional parse_integer_(const char *name, StringRef value, uint16_t max); /// A state or attribute report: answers a pending echo when it changed something, and schedules the apply void on_report_(bool changed); + /// Takes over a reported attribute, reporting whether it changed + template void import_(T &field, const T &value) { + this->on_report_(field != value); + field = value; + } /// A state at the resolution Home Assistant gets, to see a transition retargeted by a newer call static uint32_t state_hash(const light::LightColorValues &values); /// Schedules apply_imported_() once for the attributes of one update @@ -56,15 +62,15 @@ class HomeassistantLight final : public light::LightOutput, public Component { light::LightState *state_{nullptr}; light::LightTraits traits_{}; Attributes imported_{}; - uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at bool apply_scheduled_ : 1 {false}; - bool initial_write_ : 1 {true}; // the light core's own first write is not a local change - bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state - bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back - bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends - bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent - bool report_changed_ : 1 {false}; // the reports since the last apply changed an attribute + bool initial_write_ : 1 {true}; // the light core's own first write is not a local change + bool initial_seen_ : 1 {false}; // the light core's setup call has set the initial state + bool echo_pending_ : 1 {false}; // a sent change waits for Home Assistant to report it back + bool report_held_ : 1 {false}; // a report arrived during a transition or flash; applied when it ends + bool transitioned_ : 1 {false}; // a transition or flash ran since the last write sent + bool report_in_transition_ : 1 {false}; // the last changed report, not yet applied, arrived during a transition uint8_t report_timeout_s_{10}; + uint32_t held_hash_{0}; // state_hash() of what the transition a report was held for ends at }; } // namespace esphome::homeassistant diff --git a/tests/integration/test_api_homeassistant_light.py b/tests/integration/test_api_homeassistant_light.py index 2cb9b4d6d05..93eefb8dbb4 100644 --- a/tests/integration/test_api_homeassistant_light.py +++ b/tests/integration/test_api_homeassistant_light.py @@ -12,12 +12,16 @@ from aioesphomeapi import ( EntityState, HomeassistantServiceCall, LightColorCapability, + LightInfo, LightState, UserService, ) +from aioesphomeapi.api_pb2 import HomeAssistantStateResponse, LightCommandRequest +from google.protobuf import message import pytest from .log_utils import LineWaiter +from .state_utils import require_entity from .types import APIClientConnectedFactory, RunCompiledFunction ENTITY = "light.kitchen" @@ -43,6 +47,19 @@ RGB = ( ) +def command_msg(key: int, **fields: float | bool) -> LightCommandRequest: + """A light command with has_ set for each field given.""" + return LightCommandRequest( + key=key, **fields, **{f"has_{name}": True for name in fields} + ) + + +def report_msg(attribute: str, value: str) -> HomeAssistantStateResponse: + return HomeAssistantStateResponse( + entity_id=ENTITY, attribute=attribute, state=value + ) + + @dataclass class FakeHomeAssistant: """The light entity as the device sees it over the API, and what the device sends back.""" @@ -57,6 +74,19 @@ class FakeHomeAssistant: async def next_call(self) -> HomeassistantServiceCall: return await asyncio.wait_for(self.calls.get(), timeout=5.0) + async def expect_call( + self, service: str = "light.turn_on", **data: str + ) -> HomeassistantServiceCall: + """The next action sent; its data is checked in full when given.""" + call = await self.next_call() + assert call.service == service + if data: + assert call.data == {"entity_id": ENTITY, **data} + return call + + def command(self, **kwargs: float | bool | tuple[float, float, float]) -> None: + self.client.light_command(self.key, **kwargs) + async def next_state(self, matches: Callable[[LightState], bool]) -> LightState: async with asyncio.timeout(5.0): while not matches(state := await self.states.get()): @@ -66,6 +96,14 @@ class FakeHomeAssistant: def report(self, attribute: str | None, value: str) -> None: self.client.send_home_assistant_state(ENTITY, attribute, value) + async def report_off(self) -> None: + self.report(None, "off") + await self.next_state(lambda s: not s.state) + + def send_together(self, *messages: message.Message) -> None: + """One write, so the device reads all of them in one loop pass.""" + self.client._get_connection().send_messages(messages) + @asynccontextmanager async def light_entity( @@ -96,7 +134,7 @@ async def light_entity( api_client_connected() as client, ): entities, services = await client.list_entities_services() - key = next(e.key for e in entities if e.name == "HA Light") + key = require_entity(entities, "ha_light", LightInfo).key client.subscribe_service_calls(calls.put_nowait) client.subscribe_states(on_state) client.subscribe_home_assistant_states(on_state_sub) @@ -163,15 +201,11 @@ async def test_api_homeassistant_light( # Local changes go to Home Assistant with its attribute names and scales; the color # temperature it reported is not repeated - ha.client.light_command(ha.key, brightness=0.5) - call = await ha.next_call() - assert call.service == "light.turn_on" - assert call.data == {"entity_id": ENTITY, "brightness": "128"} + ha.command(brightness=0.5) + call = await ha.expect_call(brightness="128") assert not call.data_template - ha.client.light_command(ha.key, rgb=(0.0, 0.0, 1.0)) - call = await ha.next_call() - assert call.service == "light.turn_on" - assert call.data == {"entity_id": ENTITY, "brightness": "128"} + ha.command(rgb=(0.0, 0.0, 1.0)) + call = await ha.expect_call(brightness="128") assert call.data_template == {"rgb_color": "[0,0,255]"} # Home Assistant echoes the result; that is not sent back. Returning to the state it @@ -182,25 +216,24 @@ async def test_api_homeassistant_light( await ha.waiter.wait_for( "turned off", timeout=5.0 ) # a reported state runs the triggers too - ha.client.light_command(ha.key, state=True) - assert (await ha.next_call()).service == "light.turn_on" - ha.client.light_command(ha.key, state=False) - assert (await ha.next_call()).service == "light.turn_off" + ha.command(state=True) + await ha.expect_call() + ha.command(state=False) + await ha.expect_call("light.turn_off") # Without a report of the change within report_timeout, the light goes back to the reported state ha.report(None, "on") await ha.next_state(lambda s: s.state) - ha.client.light_command(ha.key, state=False) - assert (await ha.next_call()).service == "light.turn_off" + ha.command(state=False) + await ha.expect_call("light.turn_off") await ha.next_state(lambda s: s.state) await ha.waiter.wait_for("did not report the change back", timeout=5.0) - ha.report(None, "off") - await ha.next_state(lambda s: not s.state) + await ha.report_off() # The fallback also drops the levels of the unsent change: the light keeps the brightness # Home Assistant reported last, which a later turn_on without a brightness would send - ha.client.light_command(ha.key, state=True, brightness=0.4) - call = await ha.next_call() + ha.command(state=True, brightness=0.4) + call = await ha.expect_call() assert call.data["brightness"] == "102" state = await ha.next_state(lambda s: not s.state) assert state.brightness == pytest.approx(200 / 255) @@ -208,19 +241,18 @@ async def test_api_homeassistant_light( # An automation changing the light while a report is applied is a local change too await ha.client.execute_service(set_force_off, {"value": True}) ha.report(None, "on") - assert (await ha.next_call()).service == "light.turn_off" + await ha.expect_call("light.turn_off") await ha.client.execute_service(set_force_off, {"value": False}) - ha.report(None, "off") - await ha.next_state(lambda s: not s.state) + await ha.report_off() # A change that leaves Home Assistant as it is gets no report, so none is sent - ha.client.light_command(ha.key, state=False) + ha.command(state=False) await ha.waiter.wait_for("already has this state", timeout=5.0) # A trait that arrives while a change waits for its report is not that report, and does # not bring the old state back early either - ha.client.light_command(ha.key, state=True) - assert (await ha.next_call()).service == "light.turn_on" + ha.command(state=True) + await ha.expect_call() await ha.next_state(lambda s: s.state) ha.report("max_color_temp_kelvin", "6500") await ha.next_state(lambda s: not s.state) @@ -230,7 +262,7 @@ async def test_api_homeassistant_light( ) # A report that arrives during a flash is applied when the flash ends instead of being undone - ha.client.light_command(ha.key, state=True, flash_length=1.0) + ha.command(state=True, flash_length=1.0) ha.report(None, "on") ha.report("brightness", "77") state = await ha.next_state(lambda s: s.brightness == pytest.approx(77 / 255)) @@ -239,84 +271,96 @@ async def test_api_homeassistant_light( # A trait or a repeated report during a local transition is nothing new, so the change the # transition ends at is still sent - ha.client.light_command(ha.key, state=False, transition_length=1.0) + ha.command(state=False, transition_length=1.0) ha.report("max_color_temp_kelvin", "6500") ha.report(None, "on") - assert (await ha.next_call()).service == "light.turn_off" - ha.report(None, "off") - await ha.next_state(lambda s: not s.state) + await ha.expect_call("light.turn_off") + await ha.report_off() # A newer local command wins over a report held during a transition - ha.client.light_command(ha.key, state=True, transition_length=1.0) + ha.command(state=True, transition_length=1.0) ha.report("brightness", "50") await asyncio.sleep(0.1) # held before the command arrives - ha.client.light_command(ha.key, state=True, brightness=1.0) - call = await ha.next_call() - assert call.service == "light.turn_on" + ha.command(state=True, brightness=1.0) + call = await ha.expect_call() assert call.data["brightness"] == "255" ha.report(None, "on") ha.report("brightness", "255") await ha.next_state(lambda s: s.state and s.brightness == 1.0) # Also when it changes only the color, at the level the transition was heading for - ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0) + ha.command(brightness=0.5, transition_length=1.0) ha.report("brightness", "50") await asyncio.sleep(0.1) - ha.client.light_command(ha.key, color_temperature=400.0) - call = await ha.next_call() - assert call.service == "light.turn_on" - assert call.data == { - "entity_id": ENTITY, - "brightness": "128", - "color_temp_kelvin": "2500", - } + ha.command(color_temperature=400.0) + await ha.expect_call(brightness="128", color_temp_kelvin="2500") ha.report("brightness", "128") ha.report("color_temp_kelvin", "2500") await ha.next_state(lambda s: s.color_temperature == 400.0) # Or when it is a transition itself, replacing the one the report was held for - ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0) + ha.command(brightness=1.0, transition_length=1.0) ha.report("brightness", "50") await asyncio.sleep(0.1) - ha.client.light_command(ha.key, color_temperature=250.0, transition_length=1.0) - call = await ha.next_call() - assert call.service == "light.turn_on" - assert call.data == { - "entity_id": ENTITY, - "brightness": "255", - "color_temp_kelvin": "4000", - } + ha.command(color_temperature=250.0, transition_length=1.0) + await ha.expect_call(brightness="255", color_temp_kelvin="4000") ha.report("brightness", "255") ha.report("color_temp_kelvin", "4000") await ha.next_state(lambda s: s.color_temperature == 250.0) # Or a transition back to where the one the report was held for started - ha.client.light_command(ha.key, brightness=0.5, transition_length=1.0) + ha.command(brightness=0.5, transition_length=1.0) ha.report("brightness", "50") await asyncio.sleep(0.1) - ha.client.light_command(ha.key, brightness=1.0, transition_length=1.0) - call = await ha.next_call() - assert call.service == "light.turn_on" + ha.command(brightness=1.0, transition_length=1.0) + call = await ha.expect_call() assert call.data["brightness"] == "255" ha.report("brightness", "255") await ha.next_state(lambda s: s.brightness == 1.0) # And when it goes back to the state the transition started from - ha.client.light_command(ha.key, state=False) - assert (await ha.next_call()).service == "light.turn_off" - ha.report(None, "off") - await ha.next_state(lambda s: not s.state) - ha.client.light_command(ha.key, state=True, transition_length=1.0) + ha.command(state=False) + await ha.expect_call("light.turn_off") + await ha.report_off() + ha.command(state=True, transition_length=1.0) ha.report(None, "on") ha.report("brightness", "60") await asyncio.sleep(0.1) - ha.client.light_command(ha.key, state=False) - assert (await ha.next_call()).service == "light.turn_off" - ha.report(None, "off") - await ha.next_state(lambda s: not s.state) + ha.command(state=False) + await ha.expect_call("light.turn_off") + await ha.report_off() + + # Also when the report and the call that replaces the transition are read in the same loop + # pass: the report is applied after the call, but it is older + ha.send_together( + command_msg(ha.key, state=True, brightness=0.5, transition_length=1000), + report_msg("brightness", "50"), + command_msg(ha.key, brightness=1.0, transition_length=1000), + ) + call = await ha.expect_call() + assert call.data["brightness"] == "255" + ha.report(None, "on") + ha.report("brightness", "255") + await ha.next_state(lambda s: s.state and s.brightness == 1.0) + + # And a report read after that call in the same pass is newer than it: the hold follows the + # last changed report, even when a trait opened the batch, so it is applied at the end + ha.send_together( + report_msg("max_color_temp_kelvin", "6500"), + command_msg(ha.key, brightness=0.5, transition_length=1000), + report_msg("brightness", "50"), + command_msg(ha.key, brightness=0.8, transition_length=1000), + report_msg("brightness", "77"), + ) + state = await ha.next_state(lambda s: s.brightness == pytest.approx(77 / 255)) + assert state.state + assert ha.calls.empty() + ha.command(state=False) + await ha.expect_call("light.turn_off") + await ha.report_off() # A flash ends where it began, which Home Assistant has, so nothing is sent and the user is told - ha.client.light_command(ha.key, state=True, flash_length=0.5) + ha.command(state=True, flash_length=0.5) await ha.waiter.wait_for("nothing to send (a flash never is)", timeout=5.0) assert ha.calls.empty() @@ -347,45 +391,35 @@ async def test_api_homeassistant_light_rgbww( # A level change sends the level alone; Home Assistant derived the color it reports from # the white balance and would derive a different balance from the color sent back - ha.client.light_command(ha.key, brightness=0.5) - call = await ha.next_call() - assert call.service == "light.turn_on" - assert call.data == {"entity_id": ENTITY, "brightness": "128"} + ha.command(brightness=0.5) + call = await ha.expect_call(brightness="128") assert not call.data_template ha.report("brightness", "128") await ha.next_state(lambda s: s.brightness == pytest.approx(128 / 255)) # A new color goes along - ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0)) - call = await ha.next_call() - assert call.data == {"entity_id": ENTITY, "brightness": "128"} + ha.command(rgb=(0.0, 1.0, 0.0)) + call = await ha.expect_call(brightness="128") assert call.data_template == {"rgb_color": "[0,255,0]"} ha.report("rgb_color", "(0, 255, 0)") await ha.next_state(lambda s: s.green == 1.0 and s.red == 0.0) # Switching to color temperature sends it, and the level alone once there - ha.client.light_command(ha.key, color_temperature=250.0) - call = await ha.next_call() - assert call.data == { - "entity_id": ENTITY, - "brightness": "128", - "color_temp_kelvin": "4000", - } + ha.command(color_temperature=250.0) + call = await ha.expect_call(brightness="128", color_temp_kelvin="4000") assert not call.data_template ha.report("color_mode", "color_temp") ha.report("color_temp_kelvin", "4000") state = await ha.next_state(lambda s: s.color_mode == COLOR_TEMPERATURE) assert state.color_temperature == 250.0 - ha.client.light_command(ha.key, brightness=1.0) - call = await ha.next_call() - assert call.data == {"entity_id": ENTITY, "brightness": "255"} + ha.command(brightness=1.0) + await ha.expect_call(brightness="255") ha.report("brightness", "255") await ha.next_state(lambda s: s.brightness == 1.0) # Back to the color Home Assistant still reports: the mode changed, so the color is sent - ha.client.light_command(ha.key, rgb=(0.0, 1.0, 0.0)) - call = await ha.next_call() - assert call.data == {"entity_id": ENTITY, "brightness": "255"} + ha.command(rgb=(0.0, 1.0, 0.0)) + call = await ha.expect_call(brightness="255") assert call.data_template == {"rgb_color": "[0,255,0]"} ha.report("color_mode", "rgbww") await ha.next_state(lambda s: s.color_mode == RGB) From 6868011336500574117eab014a0e04f60390f9d2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:49:05 -0400 Subject: [PATCH 175/179] [combination][speaker_source][const] Share CONF_SOURCES from components/const (#21129) --- esphome/components/combination/sensor.py | 2 +- esphome/components/const/__init__.py | 1 + esphome/components/speaker_source/media_player.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/combination/sensor.py b/esphome/components/combination/sensor.py index 608ebac4d6a..a61d88e126e 100644 --- a/esphome/components/combination/sensor.py +++ b/esphome/components/combination/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_SOURCES import esphome.config_validation as cv from esphome.const import ( CONF_ACCURACY_DECIMALS, @@ -59,7 +60,6 @@ CONF_MEDIAN = "median" CONF_MIN = "min" CONF_MOST_RECENTLY_UPDATED = "most_recently_updated" CONF_PROCESS_STD_DEV = "process_std_dev" -CONF_SOURCES = "sources" CONF_STD_DEV = "std_dev" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index a15df707e60..06e8d80103f 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -69,6 +69,7 @@ CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SERIAL_NUMBER = "serial_number" CONF_SHA256 = "sha256" CONF_SLOT = "slot" +CONF_SOURCES = "sources" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index 4f150e87fff..d69ee1881b8 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -2,6 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import audio, media_player, media_source, speaker from esphome.components.const import ( + CONF_SOURCES, CONF_VOLUME_INCREMENT, CONF_VOLUME_INITIAL, CONF_VOLUME_MAX, @@ -29,7 +30,6 @@ CONF_ON_MUTE = "on_mute" CONF_PIPELINE = "pipeline" CONF_ON_UNMUTE = "on_unmute" CONF_ON_VOLUME = "on_volume" -CONF_SOURCES = "sources" speaker_source_ns = cg.esphome_ns.namespace("speaker_source") From 384c2ec26c70ba04c97a3cd0f1d39a2b83703ff3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 07:49:34 -1000 Subject: [PATCH 176/179] [ci] Extend the hardware-test skill with the audio, globals, outgoing connection and Home Assistant light flows (#21104) --- .agents/skills/hardware-test/SKILL.md | 13 +- .../hardware-test/reference/recipes/README.md | 5 +- .../hardware-test/reference/recipes/api.md | 23 +++ .../reference/recipes/globals.md | 15 ++ .../reference/recipes/homeassistant.md | 35 +++++ .../reference/recipes/usb_audio.md | 52 +++++++ .../hardware-test/scripts/api_button.py | 33 ++++ .../scripts/api_outgoing/cycles.py | 73 +++++++++ .../scripts/api_outgoing/wificycles.py | 107 +++++++++++++ .../scripts/homeassistant_light/bulb.py | 78 ++++++++++ .../hardware-test/scripts/serial_capture.py | 30 ++-- .../scripts/usb_audio/playurl.py | 30 ++++ .../templates/aux-2-preprod-stock-dut.yaml | 23 +++ .../aux-2-preprod-usb-audio-dut.yaml | 39 +++++ .../cast-pro-1-sendspin-control.yaml | 27 ++++ .../templates/deep-sleep-outgoing-dut.yaml | 46 ++++++ .../templates/globals-storage-dut.yaml | 66 ++++++++ .../templates/homeassistant-light-dut.yaml | 145 ++++++++++++++++++ .../templates/outgoing-wifi-drop-dut.yaml | 55 +++++++ .../usb-audio-sendspin-s3-eth-dut.yaml | 76 +++++++++ .../usb-audio-waveshare-s3-eth-dut.yaml | 142 +++++++++++++++++ 21 files changed, 1097 insertions(+), 16 deletions(-) create mode 100644 .agents/skills/hardware-test/reference/recipes/globals.md create mode 100644 .agents/skills/hardware-test/reference/recipes/homeassistant.md create mode 100644 .agents/skills/hardware-test/reference/recipes/usb_audio.md create mode 100644 .agents/skills/hardware-test/scripts/api_button.py create mode 100644 .agents/skills/hardware-test/scripts/api_outgoing/cycles.py create mode 100644 .agents/skills/hardware-test/scripts/api_outgoing/wificycles.py create mode 100644 .agents/skills/hardware-test/scripts/homeassistant_light/bulb.py create mode 100644 .agents/skills/hardware-test/scripts/usb_audio/playurl.py create mode 100644 .agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml create mode 100644 .agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/globals-storage-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/homeassistant-light-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml create mode 100644 .agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml diff --git a/.agents/skills/hardware-test/SKILL.md b/.agents/skills/hardware-test/SKILL.md index d5e994a82f0..ec841c97612 100644 --- a/.agents/skills/hardware-test/SKILL.md +++ b/.agents/skills/hardware-test/SKILL.md @@ -93,13 +93,15 @@ ESP-IDF OTA image: `.esphome/build//build/firmware.ota.bin`. LibreTiny OTA - `ports.py`: list ports with VID:PID, product, serial and a board guess; `--wait VID[:PID]`, `--product NAME`, `--serial SN` (one specific board, whatever path it comes back under) and `--serial-of PORT` (the serial number behind a path). -- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append]`: bounded timestamped - capture, ANSI stripped, `LOG_SECRET` text written as ``, reopens a vanished port, exits at the +- `serial_capture.py PORT SECONDS OUT [--baud] [--released] [--reset] [--append] [--ms]`: bounded timestamped + capture (`--ms` adds milliseconds for timing work), ANSI stripped, `LOG_SECRET` text written as ``, reopens a vanished port, exits at the deadline. OUT is truncated unless `--append` is given, so counts over it cover one run. - `flash_rtl87xx_kit.py`: ltchiptool wrapper for the BW15 kit whose CH340 drives CEN (RTS) and the boot strap (DTR). `flash read` needs the family `realtek-ambz2`; `flash write` does not. - `flash_bk72xx.py`: ltchiptool wrapper for BK72xx boards whose RTS drives CEN. It misses the link now and then; retry up to three times. +- `api_button.py HOST KEY list|press NAME [...]`: list a device's template buttons or press them over the API; the + recipes drive their test configs with it. - `wifisave_hammer.sh KIND DUT_PORT DUT_YAML DUT_TREE CLIENT_PORT CLIENT_YAML CLIENT_TREE [SETTLE]`: the fallback AP plus captive portal hammer end to end (compile, flash, capture, counts) with the templates below. KIND is `esp`, `pico`, `bw15` or `bk`. It ends with `RESULT PASS` (exit 0), @@ -113,7 +115,7 @@ Per-topic scripts, each described in the matching `reference/recipes/ (host driver, Zephyr driver probe), `rtsp_server/` (protocol client, raw rate reader, measurement set, API image grab), `cse7766/` (API observer with ping), `zephyr_coredump/` (reader flow, decode with the Zephyr tools), `libretiny_prefs/` (rtc preference harness header), `libretiny_crash/` (BK72xx crash probe header and -capture decoder). +capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `homeassistant_light/` (direct bulb driver), `usb_audio/` (URL player). ## Templates in `$SKILL/templates` @@ -125,7 +127,10 @@ capture decoder). - Per-topic device configs, each described in `reference/recipes/.md`: `online-image-dut.yaml`, `usb-cdc-acm-dongle-dut.yaml`, `nrf52-preferences-dut.yaml`, `rtsp-waveshare-s3-eth-dut.yaml`, `waveshare-s3-eth-camdiag.yaml`, `s31-cse7766-dut.yaml`, `nrf52-coredump-crash-dut.yaml`, - `bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`. + `bk72xx-rtc-prefs-dut.yaml`, `bk72xx-crash-probe-dut.yaml`, `dut-ap-only-wipe-once-bk72xx.yaml`, + `homeassistant-light-dut.yaml`, `deep-sleep-outgoing-dut.yaml`, `outgoing-wifi-drop-dut.yaml`, + `globals-storage-dut.yaml`, `usb-audio-waveshare-s3-eth-dut.yaml`, `usb-audio-sendspin-s3-eth-dut.yaml`, + `cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`. ## When something looks wrong diff --git a/.agents/skills/hardware-test/reference/recipes/README.md b/.agents/skills/hardware-test/reference/recipes/README.md index 7c7871f45ce..3db5e05f05a 100644 --- a/.agents/skills/hardware-test/reference/recipes/README.md +++ b/.agents/skills/hardware-test/reference/recipes/README.md @@ -4,7 +4,7 @@ Each flow names the pull requests it was used on, so their result tables show wh One file per component, named after it: -- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266 +- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop - [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer - [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API - [`espnow.md`](espnow.md): ESP8266 broadcast receive @@ -24,3 +24,6 @@ One file per component, named after it: - [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle - [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards - [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy +- [`globals.md`](globals.md): restoring globals with storage rtc and flash across every reset kind +- [`homeassistant.md`](homeassistant.md): light platform mirrored from a local Home Assistant +- [`usb_audio.md`](usb_audio.md): USB audio speaker and microphone, the usb_host FIFO split, and Music Assistant playback through sendspin diff --git a/.agents/skills/hardware-test/reference/recipes/api.md b/.agents/skills/hardware-test/reference/recipes/api.md index fa6733a72c6..4776fe72dc4 100644 --- a/.agents/skills/hardware-test/reference/recipes/api.md +++ b/.agents/skills/hardware-test/reference/recipes/api.md @@ -5,3 +5,26 @@ Host side timing is useless on a weak link; use an on-device bench with the minimum of five runs over two boots. Provision a key with `noise_encryption_set_key` through aioesphomeapi and upload with `espota2.run_ota(host, 8266, None, bin, noise_psk=key)` when the key is not in the yaml. + +## Outgoing connection wake timing on a deep sleep build (#21077) + +`templates/deep-sleep-outgoing-dut.yaml`: classic ESP32 ESP-IDF, `wifi: fast_connect`, `api: encryption` with a bare +`outgoing_connection:`, `deep_sleep` 15 s run and 15 s sleep, a `homeassistant` text sensor, and `millis()` logged +from `wifi: on_connect`, `api: on_client_connected` and the sensor's first `on_value`. Home Assistant's dial-in +listener is on 6054; reuse a device name it already has. Flash by USB, `serial_capture.py PORT 360 OUT --reset` for +12 cycles, then `scripts/api_outgoing/cycles.py OUT LABEL` prints per cycle the wifi, client and state millis with +the client-wifi and state-client gaps. Wi-Fi takes 4 to 8 s after a wake, so compare the client-wifi gap, and run +the PR twice around one base run because the spread is a few hundred ms. +Results: https://github.com/esphome/esphome/pull/21077#issuecomment-6254098014 + +## Outgoing connection on a mains build after a Wi-Fi drop (#21085) + +`templates/outgoing-wifi-drop-dut.yaml`: classic ESP32, `outgoing_connection: delay: 2s`, a "Wifi cycle" button doing +`wifi.disable`, 4 s, `wifi.enable`, a `debug` loop-time sensor, millis from the wifi and api triggers. Flash by USB, +`serial_capture.py PORT SECONDS OUT --ms`, press the button N times 40 s apart with `api_button.py DEV_IP KEY press +"Wifi cycle"`, then `wificycles.py CAPTURE LABEL` prints per drop the Wi-Fi-to-dial gap, dials and dials closed, who +connected first, and the loop-time maxima. For the loop cost of polling, repeat with an unreachable +`host: 192.0.2.1`. Home Assistant refuses a dial-in while its own session is READY (`Refusing dial-in adoption`), +so after a drop every dial is closed and HA's incoming reconnect wins 11 to 33 s later; the measurable effect is the +first dial landing in the loop the network flag flips. +Results: https://github.com/esphome/esphome/pull/21085#issuecomment-6254742791 diff --git a/.agents/skills/hardware-test/reference/recipes/globals.md b/.agents/skills/hardware-test/reference/recipes/globals.md new file mode 100644 index 00000000000..05617e32606 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/globals.md @@ -0,0 +1,15 @@ +# globals + +## Restoring globals with storage: rtc and flash (#13954) + +`templates/globals-storage-dut.yaml` is the shared package: five restoring globals (rtc int, flash int, default int, +rtc `std::string`, rtc `uint8_t[2]`), bumped and logged with the reset reason on every boot, saved and synced after +2 s, odd boots `App.safe_reboot()`. Platform files add the board (`packages: dut: !include globals-storage-dut.yaml`), the `debug` reset-reason text sensor and, on the +ESP32, `deep_sleep: run_duration: 12s, sleep_duration: 8s` (even boots sleep). No network needed: flash by USB, +`serial_capture.py PORT 110 OUT --reset` gives the reset pulse plus a run of soft restarts and sleeps; the ESP32's +EN pulse is a `POWERON_RESET` that empties RTC memory, the D1 mini's RST pulse (`rst cause:2`) keeps it, so the +ESP8266 needs a real power cut by hand; arm long `--released` captures on both ports first (the capture +reopens the port when it comes back, the very first boot lines are still lost, read the next boot's restored +values). Unchanged-build check: the config with every `storage:` removed, built on the PR and on the dev merge +commit under the PR's own commits (not the merge base, which is older dev), `cmp -l` plus an `nm -S` symbol diff; +only the template names differ. Results: https://github.com/esphome/esphome/pull/13954#issuecomment-6254794712 diff --git a/.agents/skills/hardware-test/reference/recipes/homeassistant.md b/.agents/skills/hardware-test/reference/recipes/homeassistant.md new file mode 100644 index 00000000000..61f0614973b --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/homeassistant.md @@ -0,0 +1,35 @@ +# homeassistant + +## homeassistant light platform against a local Home Assistant (#11354) + +A local Home Assistant dev install (`hass -c config`, https://127.0.0.1:8123 with a self-signed cert); its ESPHome +entries and entity ids are in `config/.storage/`. Two ESPHome RGBCW bulbs with a plaintext API are the lights, so an +"HA side" change is made directly on a bulb with `scripts/homeassistant_light/bulb.py BULB_IP - on|off|state|restart`. +Add the test devices to HA by hand with the shared `api_key` and "Allow the device to perform Home Assistant actions". + +`templates/homeassistant-light-dut.yaml` is a package: two mirrors, buttons for each change, a "Log state" button, a +30 s alive line, an `on_boot` `light.turn_on` for the change-before-first-write path, and VERBOSE logging with the +noisy tags at DEBUG (a tag cannot exceed the global level). A platform file sets the board and includes it: + +```yaml +esphome: + name: ha-light-dut +esp32: + board: esp32dev + framework: + type: esp-idf +packages: + dut: !include homeassistant-light-dut.yaml +``` + +Flash by USB, start a bounded `esphome logs` capture per device, then press buttons with +`scripts/api_button.py DEV_IP KEY press "Button"` and read the bulb with `bulb.py`. Cases: each +button against the bulb and the device log; a direct bulb change that both mirrors follow; restart the bulb, send a +change, expect the 5 s `did not report the change back` and the fallback (with an off and with an on last report); +OTA reboot with a serial capture running to see the boot-time change (the API log client reconnects too late). + +Lessons: the light core clamps mireds to the range HA reported; a flash sends nothing; a change sent while HA is +connected but not yet subscribed is dropped by the api server (`client has not subscribed to actions (yet)`); after a +fallback to an off report the mirror keeps the unsent brightness; a mirror that never saw its light on since boot has +no levels to restore. Results: https://github.com/esphome/esphome/pull/11354#issuecomment-6253630336 and +https://github.com/esphome/esphome/pull/11354#issuecomment-6254161409 diff --git a/.agents/skills/hardware-test/reference/recipes/usb_audio.md b/.agents/skills/hardware-test/reference/recipes/usb_audio.md new file mode 100644 index 00000000000..b990a263326 --- /dev/null +++ b/.agents/skills/hardware-test/reference/recipes/usb_audio.md @@ -0,0 +1,52 @@ +# usb_audio + +## USB audio on the Waveshare ESP32-S3-ETH (#21081, #11728) + +`templates/usb-audio-waveshare-s3-eth-dut.yaml`: ESP-IDF, PoE, logs over the API on Ethernet (the native USB port is +the host port, so there is no console), `usb_audio` hub, a 48 kHz 16-bit microphone, a 48 kHz 24-bit speaker (many +speakerphones offer only 24-bit; a wrong format logs the device's alternates), buttons for capture start and stop, +a non-blocking beep fed from an 8 ms interval with `play(buf, len, 0)` (a blocking `play()` on the main loop trips +the task watchdog), and a status interval with mic bytes, speaker state and heap. Drive the buttons with +`scripts/api_button.py IP KEY press "Beep"`. Expect 192 kB/s microphone bytes and the FIFO split +RX 80 / NPTX 16 / PTX 104 in the build's `defines.h`; with the balanced split the 294-byte OUT pipe is refused +(`EP MPS (294) exceeds supported limit (128)`). A device whose OUT endpoint is larger than 600 bytes cannot be +claimed on an S3 at all. + +## Music Assistant through sendspin + +`templates/usb-audio-sendspin-s3-eth-dut.yaml` overlays the template above with the Apollo CAST-1 wiring: resampler +speakers into a mixer whose inputs never time out, `speaker_source` media player with the sendspin source, and +`sendspin: unpaired_access: true`. A `media_player: platform: speaker` is not a media-source consumer and gives +"Player over its buffer", no audio. Music Assistant finds the device by mDNS within 15 s of boot. + +Read the result from the device, not only by ear: sendspin's sync task logs `Lost sync (N us off)` (DEBUG, tag +`SsSync`) for every correction over 5 ms, a positive N inserts that much silence, and each one is an audible click. +For the chunk lead histogram, edit the managed library in the build directory, +`.esphome/build//managed_components/sendspin__sendspin-cpp/src/sync_task.cpp` (an `SS_LOGD` before the +late-chunk skip in `decode_chunk()` printing the chunk's lead against `new_audio_client_playtime`), update only that +file's `size` and sha256 `hash` in the component's `CHECKSUMS.json`, and compile again; deleting `.component_hash` or +recomputing it makes the manager refuse or refetch. Check the edit is in the image with `strings firmware.ota.bin`. +A lead below -5 ms makes the library skip the whole chunk; an I2S speaker keeps it within 0.1 ms of zero, which is +the figure a USB speaker has to match. + +Gotchas: wait two minutes after a reboot before the measured stream (safe mode's boot counter write stalls the cores +for about 140 ms once, 60 to 75 s after boot); with the serial console on, a DEBUG log line per report saturates +UART0 and trips the task watchdog, so use `logger: baud_rate: 0` and the API log; two overlapping `esphome logs` +captures double every count; and every audio test must include a pause and resume cycle, since a stream end that +leaves less than one transfer parked in the driver was what hung the speaker after #11728 merged. + +## Controls + +Apollo CAST PRO-1 (`templates/cast-pro-1-sendspin-control.yaml`): `Core.yaml` next to it is +`Integrations/ESPHome/Core.yaml` from ApolloAutomation/CAST_PRO-1, which also defines the `version` substitution. +Keep its framework log level at ERROR (DEBUG floods driver output and trips the watchdog); the package reboots itself +into Ethernet-only once it sees a link and appends the MAC suffix to the name. `wifi: enable_on_boot: false` keeps +sendspin off Wi-Fi. + +Nabu Casa Connect AUX-2 (a pre-production unit): build from `packages/aux-2-pre-prod.yaml` at NabuCasa/aux-2 commit +283f2e64 (dropped in bde41e01), not the public `aux-2.yaml`, whose W5500, codec and I2S pins differ (the public one +reads W5500 chip ID 0x00 and fails the codec's I2C writes). Point its local `external_components` source at +`github://NabuCasa/aux-2@main`. Templates `aux-2-preprod-stock-dut.yaml` (I2S codec) and +`aux-2-preprod-usb-audio-dut.yaml` (the USB speaker as the mixer output via `!extend mixing_speaker`). The console is +the CH343 (1a86:55d3); first flash over it, OTA after. Forcing the Wi-Fi flag off with `restore_value: no` boot-loops +when Ethernet fails, so let the package switch itself. diff --git a/.agents/skills/hardware-test/scripts/api_button.py b/.agents/skills/hardware-test/scripts/api_button.py new file mode 100644 index 00000000000..b1f0d962648 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/api_button.py @@ -0,0 +1,33 @@ +"""Press a template button on the test device over the API, or list its buttons. + +usage: api_button.py HOST KEY list | press "Button name" [...] +""" + +import asyncio +import sys + +from aioesphomeapi import APIClient, ButtonInfo + +HOST, KEY = sys.argv[1], sys.argv[2] +CMD, ARGS = sys.argv[3], sys.argv[4:] + + +async def main() -> None: + c = APIClient(HOST, 6053, None, noise_psk=KEY) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)} + if CMD == "list": + print("\n".join(sorted(buttons))) + else: + for name in ARGS: + if name not in buttons: + sys.exit(f"no button named {name!r}; have {sorted(buttons)}") + c.button_command(buttons[name].key) + print("pressed", name) + await asyncio.sleep(0.3) + await asyncio.sleep(0.5) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/api_outgoing/cycles.py b/.agents/skills/hardware-test/scripts/api_outgoing/cycles.py new file mode 100644 index 00000000000..e64eed18b3a --- /dev/null +++ b/.agents/skills/hardware-test/scripts/api_outgoing/cycles.py @@ -0,0 +1,73 @@ +"""Per wake cycle timings from a serial capture of the deep sleep timing config. + +usage: cycles.py CAPTURE [LABEL] +A cycle starts at the boot banner; the numbers are the device's millis() from the timing lines, and the +capture clock gap from the first line of the cycle to the "Dialing" line. +""" + +import pathlib +import re +import statistics +import sys + +cap = ( + pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines() +) +label = sys.argv[2] if len(sys.argv) > 2 else "" +TS = re.compile(r"^\[(\d\d):(\d\d):(\d\d)(?:\.(\d+))?\]") + + +def secs(line): + m = TS.match(line) + if not m: + return None + h, mi, s, f = m.groups() + return int(h) * 3600 + int(mi) * 60 + int(s) + (int(f) / 10 ** len(f) if f else 0) + + +cycles = [] +cur = None +for line in cap: + if "rst:0x" in line or (cur is None and "Log initialized" in line): + cur = {"start": secs(line), "reset": line.split("]", 1)[-1].strip()[:60]} + cycles.append(cur) + continue + if cur is None: + continue + if "Dialing" in line: + cur.setdefault("dial_cap", secs(line)) + m = re.search(r"wifi connected at (\d+) ms", line) + if m: + cur.setdefault("wifi", int(m.group(1))) + m = re.search(r"client connected at (\d+) ms \((.*?)\)", line) + if m and "client" not in cur: + cur["client"] = int(m.group(1)) + cur["who"] = m.group(2) + m = re.search(r"first HA state '(.*?)' at (\d+) ms", line) + if m and "state" not in cur: + cur["state"] = int(m.group(2)) + +print(f"{label} cycles: {len(cycles)}") +print( + f"{'n':>2} {'wifi ms':>8} {'dial +ms':>9} {'client ms':>10} {'state ms':>9} {'client-wifi':>11} {'state-client':>12} client reset" +) +for i, c in enumerate(cycles, 1): + dial = "" + if c.get("dial_cap") is not None and c.get("start") is not None: + dial = f"{(c['dial_cap'] - c['start']) * 1000:.0f}" + cw = c["client"] - c["wifi"] if "client" in c and "wifi" in c else "" + sc = c["state"] - c["client"] if "state" in c and "client" in c else "" + print( + f"{i:>2} {c.get('wifi', ''):>8} {dial:>9} {c.get('client', ''):>10} {c.get('state', ''):>9} {cw:>11} {sc:>12} {c.get('who', '')} {c.get('reset', '')[:28]}" + ) +for c in cycles: + if "client" in c and "wifi" in c: + c["client-wifi"] = c["client"] - c["wifi"] + if "state" in c and "client" in c: + c["state-client"] = c["state"] - c["client"] +for key in ("wifi", "client", "state", "client-wifi", "state-client"): + vals = [c[key] for c in cycles if key in c] + if vals: + print( + f"{key}: n={len(vals)} min={min(vals)} median={statistics.median(vals):.0f} max={max(vals)}" + ) diff --git a/.agents/skills/hardware-test/scripts/api_outgoing/wificycles.py b/.agents/skills/hardware-test/scripts/api_outgoing/wificycles.py new file mode 100644 index 00000000000..db315cc99ea --- /dev/null +++ b/.agents/skills/hardware-test/scripts/api_outgoing/wificycles.py @@ -0,0 +1,107 @@ +"""Per Wi-Fi drop timings from a serial capture of the outgoing connection timing config. + +usage: wificycles.py CAPTURE [LABEL] +A cycle starts at "wifi cycle disabling"; the numbers are the device's millis() from the timing lines. +"dial" says whether the client that connected first came from the device's own dial ("Dialing" seen after the +reconnect and before the hello) or from an incoming connection ("Accept" seen first). +""" + +import pathlib +import re +import statistics +import sys + +cap = ( + pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines() +) +label = sys.argv[2] if len(sys.argv) > 2 else "" +TS = re.compile(r"^\[(\d\d):(\d\d):(\d\d)(?:\.(\d+))?\]") + + +def secs(line): + m = TS.match(line) + if not m: + return None + h, mi, se, f = m.groups() + return int(h) * 3600 + int(mi) * 60 + int(se) + (int(f) / 10 ** len(f) if f else 0) + + +cycles = [] +cur = None +loop_max = [] +for line in cap: + m = re.search(r"loop time max (\d+) ms", line) + if m: + loop_max.append(int(m.group(1))) + if "wifi cycle disabling" in line: + cur = {"order": []} + cycles.append(cur) + continue + if cur is None: + continue + m = re.search(r"wifi connected at (\d+) ms", line) + if m and "wifi" not in cur: + cur["wifi"] = int(m.group(1)) + cur["wifi_cap"] = secs(line) + if "Dialing" in line: + cur["order"].append("dial") + cur["dials"] = cur.get("dials", 0) + 1 + if "dial_cap" not in cur and "wifi_cap" in cur: + cur["dial_cap"] = secs(line) + elif "dial_cap" not in cur and "dial_before_wifi_cap" not in cur: + cur["dial_before_wifi_cap"] = secs(line) + if ( + re.search(r"client disconnected at \d+ ms \(\d+\.\d+\.\d+\.\d+\)", line) + and "wifi" in cur + and "client" not in cur + ): + cur["dial_closed"] = cur.get("dial_closed", 0) + 1 + if "Accept " in line and "wifi" in cur: + cur["order"].append("accept") + m = re.search(r"client connected at (\d+) ms \((.*?)\)", line) + if m and "wifi" in cur and "client" not in cur: + cur["client"] = int(m.group(1)) + cur["who"] = m.group(2).split(" ")[0] + cur["via"] = cur["order"][0] if cur["order"] else "?" + m = re.search(r"Connect (failed|timeout)", line) + if m: + cur.setdefault("connect_errors", 0) + cur["connect_errors"] += 1 + +print(f"{label} cycles: {len(cycles)}") +print( + f"{'n':>2} {'wifi ms':>8} {'dial-wifi cap ms':>16} {'client ms':>10} {'client-wifi':>11} via dials closed client" +) +for i, c in enumerate(cycles, 1): + cw = c["client"] - c["wifi"] if "client" in c and "wifi" in c else "" + c["client-wifi"] = cw + dw = "" + if "wifi_cap" in c and c.get("wifi_cap") is not None: + # the first dial of the cycle, whether it was logged before or after the Wi-Fi line + if c.get("dial_before_wifi_cap") is not None: + dw = f"{(c['dial_before_wifi_cap'] - c['wifi_cap']) * 1000:.0f}" + elif "dial_cap" in c and c["dial_cap"] is not None: + dw = f"{(c['dial_cap'] - c['wifi_cap']) * 1000:.0f}" + c["dial-wifi"] = dw + print( + f"{i:>2} {c.get('wifi', ''):>8} {dw:>16} {c.get('client', ''):>10} {cw:>11} {c.get('via', ''):7s} {c.get('dials', 0):>5} {c.get('dial_closed', 0):>6} {c.get('who', '')}" + ) +for via in ("dial", "accept"): + vals = [ + c["client-wifi"] + for c in cycles + if c.get("via") == via and c["client-wifi"] != "" + ] + if vals: + print( + f"client-wifi via {via}: n={len(vals)} min={min(vals)} median={statistics.median(vals):.0f} max={max(vals)}" + ) +dws = [float(c["dial-wifi"]) for c in cycles if c.get("dial-wifi") not in ("", None)] +if dws: + print( + f"dial after wifi connected (capture clock): n={len(dws)} min={min(dws):.0f} median={statistics.median(dws):.0f} max={max(dws):.0f}" + ) +if loop_max: + print( + f"loop time max per 5 s: n={len(loop_max)} min={min(loop_max)} median={statistics.median(loop_max):.0f} max={max(loop_max)}" + ) diff --git a/.agents/skills/hardware-test/scripts/homeassistant_light/bulb.py b/.agents/skills/hardware-test/scripts/homeassistant_light/bulb.py new file mode 100644 index 00000000000..61efe8f0e21 --- /dev/null +++ b/.agents/skills/hardware-test/scripts/homeassistant_light/bulb.py @@ -0,0 +1,78 @@ +"""Read or drive a bulb directly over its own ESPHome API (an HA-side change, independent of the mirror). + +usage: bulb.py HOST KEY state | on [brightness 0-1] [ct_mireds|rgb r,g,b] | off | restart +""" + +import asyncio +import sys + +from aioesphomeapi import APIClient, ButtonInfo, LightColorCapability, LightInfo + +HOST, KEY, CMD = sys.argv[1], sys.argv[2], sys.argv[3] +ARGS = sys.argv[4:] + + +async def main() -> None: + c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + light = next((e for e in ents if isinstance(e, LightInfo)), None) + if light is None: + sys.exit("no light entity on this device; is HOST a bulb?") + if CMD == "state": + fut = asyncio.get_running_loop().create_future() + + def on_state(st): + if getattr(st, "key", None) == light.key and not fut.done(): + fut.set_result(st) + + c.subscribe_states(on_state) + st = await asyncio.wait_for(fut, 5) + print( + f"{light.name}: on={st.state} mode={st.color_mode} bri={st.brightness:.2f} " + f"ct={st.color_temperature:.1f} rgb={st.red:.2f}/{st.green:.2f}/{st.blue:.2f} " + f"white={st.white:.2f} cold={st.cold_white:.2f} warm={st.warm_white:.2f}" + ) + elif CMD == "on": + kw = {"state": True} + if ARGS: + kw["brightness"] = float(ARGS[0]) + if len(ARGS) > 1: + if "," in ARGS[1]: + r, g, b = (float(x) for x in ARGS[1].split(",")) + kw["rgb"] = (r, g, b) + kw["color_mode"] = ( + LightColorCapability.RGB + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + ) + else: + kw["color_temperature"] = float(ARGS[1]) + kw["color_mode"] = ( + LightColorCapability.COLOR_TEMPERATURE + | LightColorCapability.ON_OFF + | LightColorCapability.BRIGHTNESS + ) + c.light_command(light.key, **kw) + print("bulb on", kw) + elif CMD == "off": + c.light_command(light.key, state=False) + print("bulb off") + elif CMD == "restart": + btn = next( + ( + e + for e in ents + if isinstance(e, ButtonInfo) and "restart" in e.name.lower() + ), + None, + ) + if btn is None: + sys.exit("no restart button on this device") + c.button_command(btn.key) + print("bulb restart pressed") + await asyncio.sleep(0.5) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/scripts/serial_capture.py b/.agents/skills/hardware-test/scripts/serial_capture.py index d93b3ca720f..bf6fad218d1 100644 --- a/.agents/skills/hardware-test/scripts/serial_capture.py +++ b/.agents/skills/hardware-test/scripts/serial_capture.py @@ -1,6 +1,6 @@ """Bounded, timestamped serial capture that always exits and closes the port. - serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append] + serial_capture.py PORT SECONDS OUTFILE [--baud 115200] [--released] [--reset] [--append] [--ms] default open with DTR/RTS as pyserial leaves them (asserted); fine for ESP devkits and the Pico. --released drive DTR and RTS low before opening: the BW15 and BK7238 CH340 kits strap the boot pin @@ -76,14 +76,21 @@ def open_port(args: argparse.Namespace, path: str) -> serial.Serial: return s -def write_line(out, tag: bytes, data: bytes) -> None: - stamp = time.strftime("[%H:%M:%S]").encode() +def write_line(out, tag: bytes, data: bytes, ms: bool = False) -> None: + if ms: + now = time.time() + stamp = ( + time.strftime("[%H:%M:%S", time.localtime(now)) + + f".{int(now * 1000) % 1000:03d}]" + ).encode() + else: + stamp = time.strftime("[%H:%M:%S]").encode() out.write(stamp + tag + ANSI.sub(b"", SECRET.sub(b"", data)) + b"\n") out.flush() -def note(out, text: str) -> None: - write_line(out, b"[capture] ", text.encode()) +def note(out, text: str, ms: bool = False) -> None: + write_line(out, b"[capture] ", text.encode(), ms) def capture(args: argparse.Namespace, out) -> int: @@ -102,18 +109,18 @@ def capture(args: argparse.Namespace, out) -> int: if serial_number is None: serial_number, index = identify(path) if path != args.port: - note(out, f"reopened as {path}") + note(out, f"reopened as {path}", args.ms) except PORT_ERRORS as err: - note(out, f"open failed: {err}") + note(out, f"open failed: {err}", args.ms) time.sleep(1) continue try: data = s.read(4096) except PORT_ERRORS as err: if buf: # keep the cut-off line apart from the next boot's first line - write_line(out, b"[partial]", buf) + write_line(out, b"[partial]", buf, args.ms) buf = b"" - note(out, f"port lost: {err}") + note(out, f"port lost: {err}", args.ms) with contextlib.suppress(*PORT_ERRORS): s.close() s = None @@ -125,9 +132,9 @@ def capture(args: argparse.Namespace, out) -> int: buf += data while b"\n" in buf: line, buf = buf.split(b"\n", 1) - write_line(out, b"", line.rstrip(b"\r")) + write_line(out, b"", line.rstrip(b"\r"), args.ms) if buf: - write_line(out, b"[partial]", buf) + write_line(out, b"[partial]", buf, args.ms) if s is not None: s.close() return received @@ -144,6 +151,7 @@ def main() -> int: ap.add_argument("--released", action="store_true") ap.add_argument("--reset", action="store_true") ap.add_argument("--append", action="store_true") + ap.add_argument("--ms", action="store_true", help="millisecond timestamps") args = ap.parse_args() with pathlib.Path(args.outfile).open("ab" if args.append else "wb") as out: received = capture(args, out) diff --git a/.agents/skills/hardware-test/scripts/usb_audio/playurl.py b/.agents/skills/hardware-test/scripts/usb_audio/playurl.py new file mode 100644 index 00000000000..23afafcb45e --- /dev/null +++ b/.agents/skills/hardware-test/scripts/usb_audio/playurl.py @@ -0,0 +1,30 @@ +"""Play a URL on the speaker_source media player over the API (no sync task involved). + +usage: playurl.py HOST KEY URL [PLAYER_NAME] +""" + +import asyncio +import sys + +from aioesphomeapi import APIClient, MediaPlayerInfo + +HOST, KEY, URL = sys.argv[1], sys.argv[2], sys.argv[3] +PLAYER = sys.argv[4] if len(sys.argv) > 4 else "Player" + + +async def main() -> None: + c = APIClient(HOST, 6053, None, noise_psk=KEY) + await c.connect(login=True) + ents, _ = await c.list_entities_services() + players = {e.name: e for e in ents if isinstance(e, MediaPlayerInfo)} + print("media players:", sorted(players)) + if PLAYER not in players: + sys.exit(f"no media player named {PLAYER!r}; have {sorted(players)}") + p = players[PLAYER] + c.media_player_command(p.key, media_url=URL) + print("sent play_media to", PLAYER) + await asyncio.sleep(1) + await c.disconnect() + + +asyncio.run(main()) diff --git a/.agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml b/.agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml new file mode 100644 index 00000000000..825ca265620 --- /dev/null +++ b/.agents/skills/hardware-test/templates/aux-2-preprod-stock-dut.yaml @@ -0,0 +1,23 @@ +# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file, with its local +# external_components source pointed at github://NabuCasa/aux-2@main (see reference/recipes/usb_audio.md). +# Nabu Casa Connect AUX-2 on the usb_audio PR tree: stock package (I2S PCM5122 output) as a control. +packages: + core: !include aux-2-pre-prod.yaml + +esphome: + name: aux-2-dut + name_add_mac_suffix: false + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + encryption: + key: !secret api_key + +logger: + level: DEBUG + +sendspin: + unpaired_access: true diff --git a/.agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml b/.agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml new file mode 100644 index 00000000000..3b72ba12539 --- /dev/null +++ b/.agents/skills/hardware-test/templates/aux-2-preprod-usb-audio-dut.yaml @@ -0,0 +1,39 @@ +# Needs packages/aux-2-pre-prod.yaml from NabuCasa/aux-2 at commit 283f2e64 next to this file, with its local +# external_components source pointed at github://NabuCasa/aux-2@main (see reference/recipes/usb_audio.md). +# Nabu Casa Connect AUX-2 on the usb_audio PR tree: the Anker S330 on the USB host port as the mixer output. +packages: + core: !include aux-2-pre-prod.yaml + +esphome: + name: aux-2-usb-dut + name_add_mac_suffix: false + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + encryption: + key: !secret api_key + +logger: + level: DEBUG + baud_rate: 0 + +sendspin: + unpaired_access: true + +usb_audio: + id: usb_audio_id + +speaker: + - platform: usb_audio + id: usb_speaker + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 24bit + num_channels: 2 + buffer_size: 28800 + write_timeout: 50ms + - id: !extend mixing_speaker + output_speaker: usb_speaker diff --git a/.agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml b/.agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml new file mode 100644 index 00000000000..9d7f71df4a1 --- /dev/null +++ b/.agents/skills/hardware-test/templates/cast-pro-1-sendspin-control.yaml @@ -0,0 +1,27 @@ +# CAST PRO-1 control for the sendspin sync comparison: Apollo's current package, DEBUG logging as on the S3. +esphome: + name: "cast-pro-1-dut" + friendly_name: CAST PRO-1 test + project: + name: "ApolloAutomation.CAST_PRO-1" + version: ${version} + +packages: + core: !include Core.yaml + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + enable_on_boot: false + +logger: + level: DEBUG + +api: + encryption: + key: !secret api_key + +sendspin: + manufacturer: Apollo + model: CAST PRO-1 control + unpaired_access: true diff --git a/.agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml b/.agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml new file mode 100644 index 00000000000..775936c0b6d --- /dev/null +++ b/.agents/skills/hardware-test/templates/deep-sleep-outgoing-dut.yaml @@ -0,0 +1,46 @@ +# Deep sleep wake timing for the outgoing API connection: boot -> dial -> client hello -> first HA state. +esphome: + name: deep-sleep-dut + on_boot: + priority: -100 + then: + - lambda: ESP_LOGI("timing", "setup done at %u ms", (unsigned) millis()); + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + level: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + fast_connect: true + on_connect: + - lambda: ESP_LOGI("timing", "wifi connected at %u ms", (unsigned) millis()); + +api: + encryption: + key: !secret api_key + outgoing_connection: + on_client_connected: + - lambda: ESP_LOGI("timing", "client connected at %u ms (%s %s)", (unsigned) millis(), client_info.c_str(), client_address.c_str()); + on_client_disconnected: + - lambda: ESP_LOGI("timing", "client disconnected at %u ms", (unsigned) millis()); + +ota: + - platform: esphome + +text_sensor: + - platform: homeassistant + id: ha_state + entity_id: light.ENTITY + on_value: + - lambda: ESP_LOGI("timing", "first HA state '%s' at %u ms", x.c_str(), (unsigned) millis()); + +deep_sleep: + id: ds + run_duration: 15s + sleep_duration: 15s diff --git a/.agents/skills/hardware-test/templates/globals-storage-dut.yaml b/.agents/skills/hardware-test/templates/globals-storage-dut.yaml new file mode 100644 index 00000000000..4cd6c639170 --- /dev/null +++ b/.agents/skills/hardware-test/templates/globals-storage-dut.yaml @@ -0,0 +1,66 @@ +# Restoring globals with every storage choice; each boot bumps the counters, logs them with the reset reason, saves +# and syncs; odd boots soft restart, even boots deep sleep (ESP32) or idle (D1 mini). The host's reset pulse and a +# power cut give the other reset kinds. +logger: + level: DEBUG + +preferences: + flash_write_interval: 1s + +globals: + - id: cnt_rtc + type: int + restore_value: true + storage: rtc + initial_value: "0" + - id: cnt_flash + type: int + restore_value: true + storage: flash + initial_value: "0" + - id: cnt_default + type: int + restore_value: true + initial_value: "0" + - id: str_rtc + type: std::string + restore_value: true + storage: rtc + max_restore_data_length: 24 + initial_value: '"fresh"' + - id: arr_rtc + type: uint8_t[2] + restore_value: true + storage: rtc + initial_value: "{0, 0}" + +esphome: + on_boot: + priority: -100 + then: + - lambda: |- + id(cnt_rtc) += 1; + id(cnt_flash) += 1; + id(cnt_default) += 1; + id(arr_rtc)[0] += 1; + id(arr_rtc)[1] += 2; + char buf[24]; + snprintf(buf, sizeof(buf), "boot%d", id(cnt_rtc)); + ESP_LOGI("gl", "restored rtc=%d flash=%d default=%d str='%s' arr=%u/%u (values before this boot's bump: one less)", + id(cnt_rtc) - 1, id(cnt_flash) - 1, id(cnt_default) - 1, id(str_rtc).c_str(), id(arr_rtc)[0] - 1, id(arr_rtc)[1] - 2); + id(str_rtc) = buf; + - delay: 2s + - lambda: |- + global_preferences->sync(); + ESP_LOGI("gl", "saved rtc=%d flash=%d default=%d str='%s' arr=%u/%u", id(cnt_rtc), id(cnt_flash), id(cnt_default), + id(str_rtc).c_str(), id(arr_rtc)[0], id(arr_rtc)[1]); + - if: + condition: + lambda: 'return id(cnt_rtc) % 2 == 1;' + then: + - delay: 3s + - lambda: 'ESP_LOGI("gl", "odd boot, soft restart now"); App.safe_reboot();' + +button: + - platform: restart + name: Restart diff --git a/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml b/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml new file mode 100644 index 00000000000..9681a41de7a --- /dev/null +++ b/.agents/skills/hardware-test/templates/homeassistant-light-dut.yaml @@ -0,0 +1,145 @@ +# shared part: two Home Assistant lights mirrored on the device plus buttons that drive them +esphome: + # A local change made before the light core's first write (b19a776 sends it instead of merging it). + on_boot: + priority: -100 + then: + - lambda: 'ESP_LOGI("halight", "on_boot turning B on at 35 percent before the first write");' + - light.turn_on: + id: bulb_b + brightness: 35% +logger: + level: VERBOSE + logs: + api: DEBUG + api.connection: DEBUG + api.service: DEBUG + wifi: DEBUG + scheduler: DEBUG + component: DEBUG + sensor: DEBUG + mdns: DEBUG + ota: DEBUG + socket: DEBUG + esp-idf: DEBUG + preferences: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + +ota: + - platform: esphome + +light: + - platform: homeassistant + id: bulb_a + name: Mirror A + entity_id: light.ENTITY_A + report_timeout: 5s + on_turn_on: + - logger.log: "bulb_a on_turn_on" + on_turn_off: + - logger.log: "bulb_a on_turn_off" + - platform: homeassistant + id: bulb_b + name: Mirror B + entity_id: light.ENTITY_B + report_timeout: 5s + +button: + - platform: template + name: A warm 40 + id: a_warm_40 + on_press: + - light.turn_on: + id: bulb_a + brightness: 40% + color_temperature: 370 mireds + - platform: template + name: A cool 100 + on_press: + - light.turn_on: + id: bulb_a + brightness: 100% + color_temperature: 153 mireds + - platform: template + name: A red + on_press: + - light.turn_on: + id: bulb_a + brightness: 80% + red: 100% + green: 0% + blue: 0% + - platform: template + name: A green dim + on_press: + - light.turn_on: + id: bulb_a + brightness: 20% + red: 0% + green: 100% + blue: 0% + - platform: template + name: A brightness only 60 + on_press: + - light.turn_on: + id: bulb_a + brightness: 60% + - platform: template + name: A transition 100 + on_press: + - light.turn_on: + id: bulb_a + brightness: 100% + transition_length: 3s + - platform: template + name: A flash + on_press: + - light.turn_on: + id: bulb_a + flash_length: 1s + - platform: template + name: A off + on_press: + - light.turn_off: bulb_a + - platform: template + name: A toggle + on_press: + - light.toggle: bulb_a + - platform: template + name: B warm 50 + on_press: + - light.turn_on: + id: bulb_b + brightness: 50% + color_temperature: 300 mireds + - platform: template + name: B off + on_press: + - light.turn_off: bulb_b + - platform: template + name: Log state + on_press: + - lambda: |- + auto v = id(bulb_a).current_values; + ESP_LOGI("halight", "A: on=%d mode=%d bri=%.2f ct=%.1f rgb=%.2f/%.2f/%.2f B: on=%d bri=%.2f", + v.is_on(), (int) v.get_color_mode(), v.get_brightness(), v.get_color_temperature(), + v.get_red(), v.get_green(), v.get_blue(), id(bulb_b).current_values.is_on(), + id(bulb_b).current_values.get_brightness()); + +interval: + - interval: 30s + then: + - lambda: |- + auto v = id(bulb_a).current_values; + ESP_LOGI("halight", "alive A: on=%d mode=%d bri=%.2f ct=%.1f rgb=%.2f/%.2f/%.2f B: on=%d bri=%.2f", + v.is_on(), (int) v.get_color_mode(), v.get_brightness(), v.get_color_temperature(), + v.get_red(), v.get_green(), v.get_blue(), id(bulb_b).current_values.is_on(), + id(bulb_b).current_values.get_brightness()); diff --git a/.agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml b/.agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml new file mode 100644 index 00000000000..4f6ca276b5d --- /dev/null +++ b/.agents/skills/hardware-test/templates/outgoing-wifi-drop-dut.yaml @@ -0,0 +1,55 @@ +# Mains build (no deep sleep): outgoing API connection timing after a Wi-Fi drop, and the loop cost while a +# connect is pending or the network is down. +esphome: + name: wifi-drop-dut + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + level: DEBUG + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + fast_connect: true + on_connect: + - lambda: ESP_LOGI("timing", "wifi connected at %u ms", (unsigned) millis()); + on_disconnect: + - lambda: ESP_LOGI("timing", "wifi disconnected at %u ms", (unsigned) millis()); + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + outgoing_connection: + delay: 2s + on_client_connected: + - lambda: ESP_LOGI("timing", "client connected at %u ms (%s %s)", (unsigned) millis(), client_info.c_str(), client_address.c_str()); + on_client_disconnected: + - lambda: ESP_LOGI("timing", "client disconnected at %u ms (%s)", (unsigned) millis(), client_info.c_str()); + +ota: + - platform: esphome + +debug: + update_interval: 5s + +sensor: + - platform: debug + loop_time: + name: Loop Time + on_value: + - lambda: ESP_LOGI("timing", "loop time max %.0f ms over the last 5 s at %u ms", x, (unsigned) millis()); + +button: + - platform: template + name: Wifi cycle + on_press: + - lambda: ESP_LOGI("timing", "wifi cycle disabling at %u ms", (unsigned) millis()); + - wifi.disable: + - delay: 4s + - lambda: ESP_LOGI("timing", "wifi cycle enabling at %u ms", (unsigned) millis()); + - wifi.enable: diff --git a/.agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml b/.agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml new file mode 100644 index 00000000000..079ffab7fde --- /dev/null +++ b/.agents/skills/hardware-test/templates/usb-audio-sendspin-s3-eth-dut.yaml @@ -0,0 +1,76 @@ +# Music Assistant target: the plain USB audio template plus the CAST-1 style sendspin wiring (resampler speakers +# into a mixer whose inputs never time out, speaker_source media player, sendspin hub and sources). +packages: + base: !include usb-audio-waveshare-s3-eth-dut.yaml + +# Logs over the API only: a DEBUG line per report would saturate UART0 and trip the task watchdog. +logger: + baud_rate: 0 + +speaker: + - id: !extend usb_speaker + buffer_size: 28800 + write_timeout: 50ms + - platform: mixer + id: mixer_speaker_id + output_speaker: usb_speaker + num_channels: 2 + task_stack_in_psram: true + source_speakers: + - id: announcement_mixer_input + timeout: never + - id: media_mixer_input + timeout: never + - platform: resampler + id: announcement_resampling_speaker + output_speaker: announcement_mixer_input + sample_rate: 48000 + bits_per_sample: 16 + - platform: resampler + id: media_resampling_speaker + output_speaker: media_mixer_input + sample_rate: 48000 + bits_per_sample: 16 + +sendspin: + id: sendspin_hub + # PSRAM stack skews time sync; keep internal + task_stack_in_psram: false + manufacturer: Waveshare + model: ESP32-S3-ETH USB audio + firmware_version: "0.1" + unpaired_access: true + +media_source: + - platform: sendspin + id: sendspin_source + - platform: audio_http + id: http_media_source + buffer_size: 500000 + - platform: audio_http + id: http_announcement_source + buffer_size: 250000 + +media_player: + - platform: sendspin + id: sendspin_group_media_player + name: Sendspin Player + - platform: speaker_source + id: external_media_player + name: Player + volume_increment: 0.05 + announcement_pipeline: + format: FLAC + num_channels: 1 + sample_rate: 48000 + speaker: announcement_resampling_speaker + sources: + - http_announcement_source + media_pipeline: + speaker: media_resampling_speaker + format: FLAC + num_channels: 2 + sample_rate: 48000 + sources: + - sendspin_source + - http_media_source diff --git a/.agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml b/.agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml new file mode 100644 index 00000000000..229f5147bd4 --- /dev/null +++ b/.agents/skills/hardware-test/templates/usb-audio-waveshare-s3-eth-dut.yaml @@ -0,0 +1,142 @@ +# Waveshare ESP32-S3-ETH: USB host for a speakerphone (usb_audio), logs over Ethernet, the USB port is the host port +esphome: + name: usb-audio-dut + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + +psram: + mode: octal + speed: 80MHz + +logger: + level: DEBUG + hardware_uart: UART0 + logs: + usb_host: DEBUG + +ethernet: + type: W5500 + clk_pin: GPIO13 + mosi_pin: GPIO11 + miso_pin: GPIO12 + cs_pin: GPIO14 + interrupt_pin: GPIO10 + reset_pin: GPIO9 + +api: + encryption: + key: !secret api_key + reboot_timeout: 0s + +ota: + - platform: esphome + +usb_audio: + id: usb_audio_id + +microphone: + - platform: usb_audio + id: usb_mic + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 16bit + num_channels: 2 + buffer_size: 9600 + on_data: + - lambda: |- + id(mic_bytes) += x.size(); + id(mic_chunks) += 1; + +speaker: + - platform: usb_audio + id: usb_speaker + usb_audio_id: usb_audio_id + sample_rate: 48000 + bits_per_sample: 24bit + num_channels: 2 + buffer_size: 9600 + write_timeout: 20ms + +globals: + - id: beep_left + type: int + initial_value: "0" + - id: beep_written + type: uint32_t + initial_value: "0" + - id: beep_short + type: uint32_t + initial_value: "0" + - id: beep_phase + type: float + initial_value: "0" + - id: mic_bytes + type: uint32_t + initial_value: "0" + - id: mic_chunks + type: uint32_t + initial_value: "0" + +button: + - platform: template + name: Mic start + on_press: + - microphone.capture: usb_mic + - platform: template + name: Mic stop + on_press: + - microphone.stop_capture: usb_mic + - platform: template + name: Beep + on_press: + - lambda: |- + // 2 s of a 1 kHz tone at 48 kHz 24-bit stereo, fed 10 ms at a time from the interval below + id(beep_left) = 200; + id(beep_written) = 0; + id(beep_short) = 0; + id(beep_phase) = 0.0f; + id(usb_speaker).start(); + ESP_LOGI("uatest", "beep started"); + - platform: template + name: Speaker stop + on_press: + - speaker.stop: usb_speaker + - platform: template + name: Restart + on_press: + - lambda: App.safe_reboot(); + +interval: + - interval: 8ms + then: + - lambda: |- + if (id(beep_left) <= 0) return; + // 10 ms of 48 kHz stereo 24-bit samples, 3 bytes little-endian per sample + static uint8_t buf[480 * 2 * 3]; + float phase = id(beep_phase); + for (int i = 0; i < 480; i++) { + int32_t s = (int32_t) (1500000.0f * sinf(phase)); + phase += 2.0f * 3.14159265f * 1000.0f / 48000.0f; + if (phase > 6.2831853f) phase -= 6.2831853f; + for (int c = 0; c < 2; c++) { + uint8_t *o = buf + (i * 2 + c) * 3; + o[0] = s & 0xFF; o[1] = (s >> 8) & 0xFF; o[2] = (s >> 16) & 0xFF; + } + } + size_t n = id(usb_speaker).play(buf, sizeof(buf), 0); + if (n == 0) { id(beep_short) += 1; return; } // not accepted yet, try the same chunk next time + id(beep_phase) = phase; + id(beep_written) += n; + id(beep_left) -= 1; + if (id(beep_left) == 0) ESP_LOGI("uatest", "beep done: %u bytes queued, %u retries", (unsigned) id(beep_written), (unsigned) id(beep_short)); + - interval: 5s + then: + - lambda: |- + ESP_LOGI("uatest", "mic capturing=%d bytes=%u chunks=%u | speaker running=%d | heap free %u", + id(usb_mic).is_running(), (unsigned) id(mic_bytes), (unsigned) id(mic_chunks), + id(usb_speaker).is_running(), (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); From dc56b27f33c81bd13376b42c819d25c3ab12fcfd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 07:50:21 -1000 Subject: [PATCH 177/179] [api] Send the device info strings from flash and keep the version out of RAM (#21106) --- esphome/components/api/api.proto | 12 ++--- esphome/components/api/api_connection.cpp | 52 +++++-------------- esphome/components/api/api_pb2.cpp | 12 ++--- esphome/components/api/api_pb2.h | 12 ++--- esphome/components/api/api_pb2_dump.cpp | 12 ++--- esphome/core/application.cpp | 2 +- esphome/core/build_info_data.h | 7 +-- esphome/writer.py | 6 +++ .../components/api/bench_proto_encode.cpp | 8 +-- tests/unit_tests/test_writer.py | 8 +++ 10 files changed, 60 insertions(+), 71 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 679aff06b5e..73455926b33 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -142,7 +142,7 @@ message HelloResponse { // A string identifying the server (ESP); like client info this may be empty // and only exists for debugging/logging purposes. // Currently set to ESPHOME_VERSION string literal. - string server_info = 3 [(max_data_length) = 32, (force) = true]; + string server_info = 3 [(max_data_length) = 32, (force) = true, (progmem) = true]; // The name of the server (App.get_name() - device hostname) // max_data_length matches ESPHOME_DEVICE_NAME_MAX_LEN (validated by validate_hostname) @@ -278,7 +278,7 @@ message DeviceInfoResponse { string mac_address = 3 [(max_data_length) = 17, (force) = true]; // A string describing the ESPHome version. For example "1.10.0" - string esphome_version = 4 [(max_data_length) = 32, (force) = true]; + string esphome_version = 4 [(max_data_length) = 32, (force) = true, (progmem) = true]; // A string describing the date of compilation, this is generated by the compiler // and therefore may not be in the same format all the time. @@ -287,14 +287,14 @@ message DeviceInfoResponse { // The model of the board. For example NodeMCU // max_data_length matches core/config.BOARD_MAX_LENGTH (validated in platform schemas) - string model = 6 [(max_data_length) = 127, (force) = true]; + string model = 6 [(max_data_length) = 127, (force) = true, (progmem) = true]; bool has_deep_sleep = 7 [(field_ifdef) = "USE_DEEP_SLEEP"]; // The esphome project details if set // max_data_length matches core/config.PROJECT_MAX_LENGTH - string project_name = 8 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"]; - string project_version = 9 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"]; + string project_name = 8 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME", (progmem) = true]; + string project_version = 9 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME", (progmem) = true]; uint32 webserver_port = 10 [(field_ifdef) = "USE_WEBSERVER"]; @@ -304,7 +304,7 @@ message DeviceInfoResponse { // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15. uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; - string manufacturer = 12 [(max_data_length) = 20, (force) = true]; + string manufacturer = 12 [(max_data_length) = 20, (force) = true, (progmem) = true]; string friendly_name = 13 [(max_data_length) = 120, (force) = true]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index bcb6486a84d..83269d07653 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -28,6 +28,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/version.h" #ifdef USE_PROVISIONING #include "esphome/components/provisioning/provisioning.h" @@ -84,8 +85,6 @@ static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * // 28-30s. See https://github.com/esphome/esphome/issues/14999 static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 60000; -static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); - // Cross-validate C++ constants against proto max_data_length annotations in api.proto static_assert(MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1 == 17, "Update max_data_length for mac_address/bluetooth_mac_address in api.proto"); @@ -1899,6 +1898,11 @@ void APIConnection::complete_authentication_() { #endif } +// A flash string on ESP8266, a mergeable literal elsewhere; #undef after the device info reply +#define PROGMEM_STRING_REF(s) ProgmemStringRef(ESPHOME_PSTR(s), sizeof(s) - 1) +// One flash copy for the hello and device info replies +static ProgmemStringRef esphome_version_ref() { return PROGMEM_STRING_REF(ESPHOME_VERSION); } + bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Copy client name with truncation if needed (set_client_name handles truncation) this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size()); @@ -1914,7 +1918,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { resp.api_version_major = 1; resp.api_version_minor = 19; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise - resp.server_info = ESPHOME_VERSION_REF; + resp.server_info = esphome_version_ref(); resp.name = StringRef(App.get_name()); #ifdef USE_PROVISIONING @@ -1969,14 +1973,13 @@ bool APIConnection::send_device_info_response_() { format_mac_addr_upper(mac, mac_address); resp.mac_address = StringRef(mac_address); - resp.esphome_version = ESPHOME_VERSION_REF; + resp.esphome_version = esphome_version_ref(); // Stack buffer for build time string char build_time_str[Application::BUILD_TIME_STR_SIZE]; App.get_build_time_string(build_time_str); resp.compilation_time = StringRef(build_time_str); - // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) #define ESPHOME_MANUFACTURER "Espressif" #elif defined(USE_RP2) @@ -1993,48 +1996,19 @@ bool APIConnection::send_device_info_response_() { #define ESPHOME_MANUFACTURER "Host" #endif -#ifdef USE_ESP8266 - // ESP8266 requires PROGMEM for flash storage, copy to stack for memcpy compatibility - static const char MANUFACTURER_PROGMEM[] PROGMEM = ESPHOME_MANUFACTURER; - char manufacturer_buf[sizeof(MANUFACTURER_PROGMEM)]; - memcpy_P(manufacturer_buf, MANUFACTURER_PROGMEM, sizeof(MANUFACTURER_PROGMEM)); - resp.manufacturer = StringRef(manufacturer_buf, sizeof(MANUFACTURER_PROGMEM) - 1); -#else - static constexpr auto MANUFACTURER = StringRef::from_lit(ESPHOME_MANUFACTURER); - resp.manufacturer = MANUFACTURER; -#endif + resp.manufacturer = PROGMEM_STRING_REF(ESPHOME_MANUFACTURER); static_assert(sizeof(ESPHOME_MANUFACTURER) - 1 <= 20, "Update max_data_length for manufacturer in api.proto"); #undef ESPHOME_MANUFACTURER -#ifdef USE_ESP8266 - static const char MODEL_PROGMEM[] PROGMEM = ESPHOME_BOARD; - char model_buf[sizeof(MODEL_PROGMEM)]; - memcpy_P(model_buf, MODEL_PROGMEM, sizeof(MODEL_PROGMEM)); - resp.model = StringRef(model_buf, sizeof(MODEL_PROGMEM) - 1); -#else - static constexpr auto MODEL = StringRef::from_lit(ESPHOME_BOARD); - resp.model = MODEL; -#endif + resp.model = PROGMEM_STRING_REF(ESPHOME_BOARD); #ifdef USE_DEEP_SLEEP resp.has_deep_sleep = deep_sleep::global_has_deep_sleep; #endif #ifdef ESPHOME_PROJECT_NAME -#ifdef USE_ESP8266 - static const char PROJECT_NAME_PROGMEM[] PROGMEM = ESPHOME_PROJECT_NAME; - static const char PROJECT_VERSION_PROGMEM[] PROGMEM = ESPHOME_PROJECT_VERSION; - char project_name_buf[sizeof(PROJECT_NAME_PROGMEM)]; - char project_version_buf[sizeof(PROJECT_VERSION_PROGMEM)]; - memcpy_P(project_name_buf, PROJECT_NAME_PROGMEM, sizeof(PROJECT_NAME_PROGMEM)); - memcpy_P(project_version_buf, PROJECT_VERSION_PROGMEM, sizeof(PROJECT_VERSION_PROGMEM)); - resp.project_name = StringRef(project_name_buf, sizeof(PROJECT_NAME_PROGMEM) - 1); - resp.project_version = StringRef(project_version_buf, sizeof(PROJECT_VERSION_PROGMEM) - 1); -#else - static constexpr auto PROJECT_NAME = StringRef::from_lit(ESPHOME_PROJECT_NAME); - static constexpr auto PROJECT_VERSION = StringRef::from_lit(ESPHOME_PROJECT_VERSION); - resp.project_name = PROJECT_NAME; - resp.project_version = PROJECT_VERSION; -#endif + resp.project_name = PROGMEM_STRING_REF(ESPHOME_PROJECT_NAME); + resp.project_version = PROGMEM_STRING_REF(ESPHOME_PROJECT_VERSION); #endif +#undef PROGMEM_STRING_REF #ifdef USE_WEBSERVER resp.webserver_port = USE_WEBSERVER_PORT; #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 8f7b622bc96..57cf7e70f61 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -31,7 +31,7 @@ uint8_t *HelloResponse::encode_msg(const void *self, uint8_t *__restrict__ pos P const auto &msg = *static_cast(self); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.api_version_major); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.api_version_minor); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.server_info); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.server_info); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.name); return pos; } @@ -117,17 +117,17 @@ uint8_t *DeviceInfoResponse::encode_msg(const void *self, uint8_t *__restrict__ const auto &msg = *static_cast(self); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.mac_address); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.esphome_version); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.esphome_version); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, msg.compilation_time); - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, msg.model); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, msg.model); #ifdef USE_DEEP_SLEEP pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, msg.project_name); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, msg.project_name); #endif #ifdef ESPHOME_PROJECT_NAME - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, msg.project_version); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, msg.project_version); #endif #ifdef USE_WEBSERVER pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.webserver_port); @@ -135,7 +135,7 @@ uint8_t *DeviceInfoResponse::encode_msg(const void *self, uint8_t *__restrict__ #ifdef USE_BLUETOOTH_PROXY pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.bluetooth_proxy_feature_flags); #endif - pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, msg.manufacturer); + pos = ProtoEncode::encode_short_progmem_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, msg.manufacturer); pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, msg.friendly_name); #ifdef USE_VOICE_ASSISTANT pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, msg.voice_assistant_feature_flags); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index fd12882dc9a..4c3dbf6df7c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -466,7 +466,7 @@ class HelloResponse final : public ProtoMessage { #endif uint32_t api_version_major{0}; uint32_t api_version_minor{0}; - StringRef server_info{}; + ProgmemStringRef server_info{}; StringRef name{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -608,17 +608,17 @@ class DeviceInfoResponse final : public ProtoMessage { #endif StringRef name{}; StringRef mac_address{}; - StringRef esphome_version{}; + ProgmemStringRef esphome_version{}; StringRef compilation_time{}; - StringRef model{}; + ProgmemStringRef model{}; #ifdef USE_DEEP_SLEEP bool has_deep_sleep{false}; #endif #ifdef ESPHOME_PROJECT_NAME - StringRef project_name{}; + ProgmemStringRef project_name{}; #endif #ifdef ESPHOME_PROJECT_NAME - StringRef project_version{}; + ProgmemStringRef project_version{}; #endif #ifdef USE_WEBSERVER uint32_t webserver_port{0}; @@ -626,7 +626,7 @@ class DeviceInfoResponse final : public ProtoMessage { #ifdef USE_BLUETOOTH_PROXY uint32_t bluetooth_proxy_feature_flags{0}; #endif - StringRef manufacturer{}; + ProgmemStringRef manufacturer{}; StringRef friendly_name{}; #ifdef USE_VOICE_ASSISTANT uint32_t voice_assistant_feature_flags{0}; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 688ec48e6e6..757c569cc89 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -970,7 +970,7 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HelloResponse")); dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major); dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor); - dump_field(out, ESPHOME_PSTR("server_info"), this->server_info); + dump_progmem_string_field(out, ESPHOME_PSTR("server_info"), this->server_info); dump_field(out, ESPHOME_PSTR("name"), this->name); return out.c_str(); } @@ -1021,17 +1021,17 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceInfoResponse")); dump_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address); - dump_field(out, ESPHOME_PSTR("esphome_version"), this->esphome_version); + dump_progmem_string_field(out, ESPHOME_PSTR("esphome_version"), this->esphome_version); dump_field(out, ESPHOME_PSTR("compilation_time"), this->compilation_time); - dump_field(out, ESPHOME_PSTR("model"), this->model); + dump_progmem_string_field(out, ESPHOME_PSTR("model"), this->model); #ifdef USE_DEEP_SLEEP dump_field(out, ESPHOME_PSTR("has_deep_sleep"), this->has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - dump_field(out, ESPHOME_PSTR("project_name"), this->project_name); + dump_progmem_string_field(out, ESPHOME_PSTR("project_name"), this->project_name); #endif #ifdef ESPHOME_PROJECT_NAME - dump_field(out, ESPHOME_PSTR("project_version"), this->project_version); + dump_progmem_string_field(out, ESPHOME_PSTR("project_version"), this->project_version); #endif #ifdef USE_WEBSERVER dump_field(out, ESPHOME_PSTR("webserver_port"), this->webserver_port); @@ -1039,7 +1039,7 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #ifdef USE_BLUETOOTH_PROXY dump_field(out, ESPHOME_PSTR("bluetooth_proxy_feature_flags"), this->bluetooth_proxy_feature_flags); #endif - dump_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer); + dump_progmem_string_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer); dump_field(out, ESPHOME_PSTR("friendly_name"), this->friendly_name); #ifdef USE_VOICE_ASSISTANT dump_field(out, ESPHOME_PSTR("voice_assistant_feature_flags"), this->voice_assistant_feature_flags); diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 2ddc97efb5f..d9f29259d03 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -559,7 +559,7 @@ void Application::get_comment_string(std::span b uint32_t Application::get_config_hash() { return ESPHOME_CONFIG_HASH; } -uint32_t Application::get_config_version_hash() { return fnv1a_hash_extend(ESPHOME_CONFIG_HASH, ESPHOME_VERSION); } +uint32_t Application::get_config_version_hash() { return ESPHOME_CONFIG_VERSION_HASH; } time_t Application::get_build_time() { return ESPHOME_BUILD_TIME; } diff --git a/esphome/core/build_info_data.h b/esphome/core/build_info_data.h index 02bb465e44f..c33edbdeeee 100644 --- a/esphome/core/build_info_data.h +++ b/esphome/core/build_info_data.h @@ -5,8 +5,9 @@ // // This file is only used by static analyzers and IDEs. -#define ESPHOME_CONFIG_HASH 0x12345678U // NOLINT -#define ESPHOME_BUILD_TIME 1700000000 // NOLINT -#define ESPHOME_COMMENT_SIZE 1 // NOLINT +#define ESPHOME_CONFIG_HASH 0x12345678U // NOLINT +#define ESPHOME_CONFIG_VERSION_HASH 0x9abcdef0U // NOLINT +#define ESPHOME_BUILD_TIME 1700000000 // NOLINT +#define ESPHOME_COMMENT_SIZE 1 // NOLINT static const char ESPHOME_BUILD_TIME_STR[] = "2024-01-01 00:00:00 +0000"; static const char ESPHOME_COMMENT_STR[] = ""; diff --git a/esphome/writer.py b/esphome/writer.py index e6c0c0e2e90..d5cf622c2c1 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -472,6 +472,7 @@ def generate_build_info_data_h() -> str: namespace esphome { extern const uint32_t ESPHOME_CONFIG_HASH; +extern const uint32_t ESPHOME_CONFIG_VERSION_HASH; extern const time_t ESPHOME_BUILD_TIME; extern const size_t ESPHOME_COMMENT_SIZE; #ifdef USE_ESP8266 @@ -500,9 +501,14 @@ def generate_build_info_data_cpp( comment_size = len(comment.encode("utf-8")) + 1 # +1 for NUL return f"""// Auto-generated build_info data #include "esphome/core/build_info_data.h" +#include "esphome/core/helpers.h" +#include "esphome/core/version.h" namespace esphome {{ const uint32_t ESPHOME_CONFIG_HASH = 0x{config_hash:08x}U; // NOLINT +// Folded at compile time, so the version string is not kept in RAM +static constexpr uint32_t CONFIG_VERSION_HASH = fnv1a_hash_extend(0x{config_hash:08x}U, ESPHOME_VERSION); +const uint32_t ESPHOME_CONFIG_VERSION_HASH = CONFIG_VERSION_HASH; // NOLINT const time_t ESPHOME_BUILD_TIME = {build_time}; // NOLINT const size_t ESPHOME_COMMENT_SIZE = {comment_size}; // NOLINT #ifdef USE_ESP8266 diff --git a/tests/benchmarks/components/api/bench_proto_encode.cpp b/tests/benchmarks/components/api/bench_proto_encode.cpp index e1383e89900..f8d836489f2 100644 --- a/tests/benchmarks/components/api/bench_proto_encode.cpp +++ b/tests/benchmarks/components/api/bench_proto_encode.cpp @@ -123,7 +123,7 @@ static void Encode_HelloResponse(benchmark::State &state) { HelloResponse msg; msg.api_version_major = 1; msg.api_version_minor = 10; - msg.server_info = StringRef::from_lit("esphome v2026.3.0"); + msg.server_info = ProgmemStringRef::from_lit("esphome v2026.3.0"); msg.name = StringRef::from_lit("living-room-sensor"); uint32_t size = msg.calculate_size(); (void) buffer.resize(size); @@ -204,10 +204,10 @@ static DeviceInfoResponse make_device_info_response() { DeviceInfoResponse msg; msg.name = StringRef::from_lit("living-room-sensor"); msg.mac_address = StringRef::from_lit("AA:BB:CC:DD:EE:FF"); - msg.esphome_version = StringRef::from_lit("2026.3.0"); + msg.esphome_version = ProgmemStringRef::from_lit("2026.3.0"); msg.compilation_time = StringRef::from_lit("Mar 16 2026, 12:00:00"); - msg.model = StringRef::from_lit("esp32-poe-iso"); - msg.manufacturer = StringRef::from_lit("Olimex"); + msg.model = ProgmemStringRef::from_lit("esp32-poe-iso"); + msg.manufacturer = ProgmemStringRef::from_lit("Olimex"); msg.friendly_name = StringRef::from_lit("Living Room Sensor"); #ifdef USE_DEVICES for (uint32_t i = 0; i < ESPHOME_DEVICE_COUNT && i < 20; i++) { diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index f03a42b6e8d..4575371822a 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -2190,6 +2190,7 @@ def test_generate_build_info_data_h_format() -> None: assert "#pragma once" in result assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in result + assert "extern const uint32_t ESPHOME_CONFIG_VERSION_HASH;" in result assert "extern const time_t ESPHOME_BUILD_TIME;" in result assert "extern const size_t ESPHOME_COMMENT_SIZE;" in result assert "extern const char ESPHOME_BUILD_TIME_STR[]" in result @@ -2206,6 +2207,13 @@ def test_generate_build_info_data_h_esp8266_progmem() -> None: assert "PROGMEM" in result +def test_generate_build_info_data_cpp_config_version_hash() -> None: + """The config version hash is folded from the config hash and ESPHOME_VERSION.""" + result = generate_build_info_data_cpp(0x12345678, 1700000000, "", "") + assert "fnv1a_hash_extend(0x12345678U, ESPHOME_VERSION);" in result + assert "ESPHOME_CONFIG_VERSION_HASH = CONFIG_VERSION_HASH;" in result + + def test_generate_build_info_data_cpp_format() -> None: """Test generate_build_info_data_cpp produces correct data definitions.""" result = generate_build_info_data_cpp( From 7cf72c287db67fa7a518bfa05991b8ef33739e69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 07:50:39 -1000 Subject: [PATCH 178/179] [web_server] Pass the listing flag as a bool and share the state event send (#21112) --- .../components/web_server/list_entities.cpp | 51 +++---- esphome/components/web_server/list_entities.h | 7 + esphome/components/web_server/web_server.cpp | 136 ++++-------------- esphome/components/web_server/web_server.h | 16 ++- .../web_server_idf/web_server_idf.cpp | 24 +--- .../web_server_idf/web_server_idf.h | 9 +- 6 files changed, 92 insertions(+), 151 deletions(-) diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 869ed3ea173..07f2ce205a9 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -14,155 +14,158 @@ ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, AsyncEventSource ListEntitiesIterator::ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es) : web_server_(ws), events_(es) {} #endif +void ListEntitiesIterator::send_all_(void *source, message_generator_t *generator) { + this->events_->deferrable_send_state(source, true, generator); +} + #ifdef USE_BINARY_SENSOR bool ListEntitiesIterator::on_binary_sensor(binary_sensor::BinarySensor *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::binary_sensor_all_json_generator); + this->send_all_(obj, WebServer::binary_sensor_all_json_generator); return true; } #endif #ifdef USE_COVER bool ListEntitiesIterator::on_cover(cover::Cover *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::cover_all_json_generator); + this->send_all_(obj, WebServer::cover_all_json_generator); return true; } #endif #ifdef USE_FAN bool ListEntitiesIterator::on_fan(fan::Fan *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::fan_all_json_generator); + this->send_all_(obj, WebServer::fan_all_json_generator); return true; } #endif #ifdef USE_LIGHT bool ListEntitiesIterator::on_light(light::LightState *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::light_all_json_generator); + this->send_all_(obj, WebServer::light_all_json_generator); return true; } #endif #ifdef USE_SENSOR bool ListEntitiesIterator::on_sensor(sensor::Sensor *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::sensor_all_json_generator); + this->send_all_(obj, WebServer::sensor_all_json_generator); return true; } #endif #ifdef USE_SWITCH bool ListEntitiesIterator::on_switch(switch_::Switch *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::switch_all_json_generator); + this->send_all_(obj, WebServer::switch_all_json_generator); return true; } #endif #ifdef USE_BUTTON bool ListEntitiesIterator::on_button(button::Button *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::button_all_json_generator); + this->send_all_(obj, WebServer::button_all_json_generator); return true; } #endif #ifdef USE_TEXT_SENSOR bool ListEntitiesIterator::on_text_sensor(text_sensor::TextSensor *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::text_sensor_all_json_generator); + this->send_all_(obj, WebServer::text_sensor_all_json_generator); return true; } #endif #ifdef USE_LOCK bool ListEntitiesIterator::on_lock(lock::Lock *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::lock_all_json_generator); + this->send_all_(obj, WebServer::lock_all_json_generator); return true; } #endif #ifdef USE_VALVE bool ListEntitiesIterator::on_valve(valve::Valve *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::valve_all_json_generator); + this->send_all_(obj, WebServer::valve_all_json_generator); return true; } #endif #ifdef USE_CLIMATE bool ListEntitiesIterator::on_climate(climate::Climate *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::climate_all_json_generator); + this->send_all_(obj, WebServer::climate_all_json_generator); return true; } #endif #ifdef USE_NUMBER bool ListEntitiesIterator::on_number(number::Number *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::number_all_json_generator); + this->send_all_(obj, WebServer::number_all_json_generator); return true; } #endif #ifdef USE_DATETIME_DATE bool ListEntitiesIterator::on_date(datetime::DateEntity *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::date_all_json_generator); + this->send_all_(obj, WebServer::date_all_json_generator); return true; } #endif #ifdef USE_DATETIME_TIME bool ListEntitiesIterator::on_time(datetime::TimeEntity *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::time_all_json_generator); + this->send_all_(obj, WebServer::time_all_json_generator); return true; } #endif #ifdef USE_DATETIME_DATETIME bool ListEntitiesIterator::on_datetime(datetime::DateTimeEntity *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::datetime_all_json_generator); + this->send_all_(obj, WebServer::datetime_all_json_generator); return true; } #endif #ifdef USE_TEXT bool ListEntitiesIterator::on_text(text::Text *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::text_all_json_generator); + this->send_all_(obj, WebServer::text_all_json_generator); return true; } #endif #ifdef USE_SELECT bool ListEntitiesIterator::on_select(select::Select *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::select_all_json_generator); + this->send_all_(obj, WebServer::select_all_json_generator); return true; } #endif #ifdef USE_ALARM_CONTROL_PANEL bool ListEntitiesIterator::on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::alarm_control_panel_all_json_generator); + this->send_all_(obj, WebServer::alarm_control_panel_all_json_generator); return true; } #endif #ifdef USE_WATER_HEATER bool ListEntitiesIterator::on_water_heater(water_heater::WaterHeater *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::water_heater_all_json_generator); + this->send_all_(obj, WebServer::water_heater_all_json_generator); return true; } #endif #ifdef USE_INFRARED bool ListEntitiesIterator::on_infrared(infrared::Infrared *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::infrared_all_json_generator); + this->send_all_(obj, WebServer::infrared_all_json_generator); return true; } #endif #ifdef USE_RADIO_FREQUENCY bool ListEntitiesIterator::on_radio_frequency(radio_frequency::RadioFrequency *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::radio_frequency_all_json_generator); + this->send_all_(obj, WebServer::radio_frequency_all_json_generator); return true; } #endif #ifdef USE_EVENT bool ListEntitiesIterator::on_event(event::Event *obj) { - // Null event type, since we are just iterating over entities - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::event_all_json_generator); + this->send_all_(obj, WebServer::event_all_json_generator); return true; } #endif #ifdef USE_UPDATE bool ListEntitiesIterator::on_update(update::UpdateEntity *obj) { - this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::update_all_json_generator); + this->send_all_(obj, WebServer::update_all_json_generator); return true; } #endif diff --git a/esphome/components/web_server/list_entities.h b/esphome/components/web_server/list_entities.h index dc32cbd1ad6..4323c7e3695 100644 --- a/esphome/components/web_server/list_entities.h +++ b/esphome/components/web_server/list_entities.h @@ -4,6 +4,10 @@ #ifdef USE_WEBSERVER #include "esphome/core/component.h" #include "esphome/core/component_iterator.h" +namespace esphome::json { +class JsonBuilder; +} // namespace esphome::json + namespace esphome::web_server_idf { #ifdef USE_ESP32 class AsyncEventSource; @@ -37,6 +41,9 @@ class ListEntitiesIterator final : public ComponentIterator { // NOLINTEND(bugprone-macro-parentheses) protected: + // Same type as message_generator_t, which this header cannot include + void send_all_(void *source, void (*generator)(WebServer *, void *, json::JsonBuilder &)); + const WebServer *web_server_; #ifdef USE_ESP32 esphome::web_server_idf::AsyncEventSource *events_; diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 4546ead98b2..613f2f854dd 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -278,28 +278,17 @@ void DeferredUpdateEventSource::loop() { this->entities_iterator_.try_advance(1); } -void DeferredUpdateEventSource::deferrable_send_state(void *source, const char *event_type, +void DeferredUpdateEventSource::deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator) { // Skip if no connected clients to avoid unnecessary deferred queue processing if (this->count() == 0) return; - // allow all json "details_all" to go through before publishing bare state events, this avoids unnamed entries showing - // up in the web GUI and reduces event load during initial connect - if (!entities_iterator_.completed() && 0 != strcmp(event_type, "state_detail_all")) + // Let the detail_all listing finish before bare state events, so the web UI never shows unnamed entries and + // initial connect sends less + if (!detail_all && !this->entities_iterator_.completed()) return; - if (source == nullptr) - return; - if (event_type == nullptr) - return; - if (message_generator == nullptr) - return; - - if (0 != strcmp(event_type, "state_detail_all") && 0 != strcmp(event_type, "state")) { - ESP_LOGE(TAG, "Can't defer non-state event"); - } - if (!deferred_queue_.empty()) process_deferred_queue_(); if (!deferred_queue_.empty()) { @@ -332,13 +321,13 @@ bool DeferredUpdateEventSourceList::loop() { return !this->empty(); } -void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const char *event_type, +void DeferredUpdateEventSourceList::deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator) { // Skip if no event sources (no connected clients) to avoid unnecessary iteration if (this->empty()) return; for (DeferredUpdateEventSource *dues : *this) { - dues->deferrable_send_state(source, event_type, message_generator); + dues->deferrable_send_state(source, detail_all, message_generator); } } @@ -711,12 +700,13 @@ static void set_json_icon_state_value(JsonObject root, EntityBase *obj, const ch return request->arg(ESPHOME_F("detail")) == "all" ? DETAIL_ALL : DETAIL_STATE; } -#ifdef USE_SENSOR -void WebServer::on_sensor_update(sensor::Sensor *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", sensor_state_json_generator); +void WebServer::send_state_event_(void *source, const EntityBase *entity, message_generator_t *generator) { + if (!entity->is_internal() || this->include_internal_) + this->events_.deferrable_send_state(source, false, generator); } + +#ifdef USE_SENSOR +void WebServer::on_sensor_update(sensor::Sensor *obj) { this->send_state_event_(obj, sensor_state_json_generator); } void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (sensor::Sensor *obj : App.get_sensors()) { auto entity_match = match.match_entity(obj); @@ -758,9 +748,7 @@ void WebServer::sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_ #ifdef USE_TEXT_SENSOR void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", text_sensor_state_json_generator); + this->send_state_event_(obj, text_sensor_state_json_generator); } void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (text_sensor::TextSensor *obj : App.get_text_sensors()) { @@ -818,11 +806,7 @@ static void execute_switch_action(switch_::Switch *obj, SwitchAction action) { } } -void WebServer::on_switch_update(switch_::Switch *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", switch_state_json_generator); -} +void WebServer::on_switch_update(switch_::Switch *obj) { this->send_state_event_(obj, switch_state_json_generator); } void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (switch_::Switch *obj : App.get_switches()) { auto entity_match = match.match_entity(obj); @@ -911,9 +895,7 @@ void WebServer::button_json_(button::Button *obj, JsonDetail start_config, json: #ifdef USE_BINARY_SENSOR void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", binary_sensor_state_json_generator); + this->send_state_event_(obj, binary_sensor_state_json_generator); } void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (binary_sensor::BinarySensor *obj : App.get_binary_sensors()) { @@ -951,11 +933,7 @@ void WebServer::binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value #endif #ifdef USE_FAN -void WebServer::on_fan_update(fan::Fan *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", fan_state_json_generator); -} +void WebServer::on_fan_update(fan::Fan *obj) { this->send_state_event_(obj, fan_state_json_generator); } void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (fan::Fan *obj : App.get_fans()) { auto entity_match = match.match_entity(obj); @@ -1030,11 +1008,7 @@ void WebServer::fan_json_(fan::Fan *obj, JsonDetail start_config, json::JsonBuil #endif #ifdef USE_LIGHT -void WebServer::on_light_update(light::LightState *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", light_state_json_generator); -} +void WebServer::on_light_update(light::LightState *obj) { this->send_state_event_(obj, light_state_json_generator); } void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (light::LightState *obj : App.get_lights()) { auto entity_match = match.match_entity(obj); @@ -1109,11 +1083,7 @@ void WebServer::light_json_(light::LightState *obj, JsonDetail start_config, jso #endif #ifdef USE_COVER -void WebServer::on_cover_update(cover::Cover *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", cover_state_json_generator); -} +void WebServer::on_cover_update(cover::Cover *obj) { this->send_state_event_(obj, cover_state_json_generator); } void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (cover::Cover *obj : App.get_covers()) { auto entity_match = match.match_entity(obj); @@ -1183,11 +1153,7 @@ void WebServer::cover_json_(cover::Cover *obj, JsonDetail start_config, json::Js #endif #ifdef USE_NUMBER -void WebServer::on_number_update(number::Number *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", number_state_json_generator); -} +void WebServer::on_number_update(number::Number *obj) { this->send_state_event_(obj, number_state_json_generator); } void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_numbers()) { auto entity_match = match.match_entity(obj); @@ -1257,11 +1223,7 @@ void WebServer::number_json_(number::Number *obj, JsonDetail start_config, json: #endif #ifdef USE_DATETIME_DATE -void WebServer::on_date_update(datetime::DateEntity *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", date_state_json_generator); -} +void WebServer::on_date_update(datetime::DateEntity *obj) { this->send_state_event_(obj, date_state_json_generator); } void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_dates()) { auto entity_match = match.match_entity(obj); @@ -1316,11 +1278,7 @@ void WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_config, j #endif // USE_DATETIME_DATE #ifdef USE_DATETIME_TIME -void WebServer::on_time_update(datetime::TimeEntity *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", time_state_json_generator); -} +void WebServer::on_time_update(datetime::TimeEntity *obj) { this->send_state_event_(obj, time_state_json_generator); } void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_times()) { auto entity_match = match.match_entity(obj); @@ -1375,9 +1333,7 @@ void WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_config, j #ifdef USE_DATETIME_DATETIME void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", datetime_state_json_generator); + this->send_state_event_(obj, datetime_state_json_generator); } void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_datetimes()) { @@ -1433,11 +1389,7 @@ void WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_c #endif // USE_DATETIME_DATETIME #ifdef USE_TEXT -void WebServer::on_text_update(text::Text *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", text_state_json_generator); -} +void WebServer::on_text_update(text::Text *obj) { this->send_state_event_(obj, text_state_json_generator); } void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_texts()) { auto entity_match = match.match_entity(obj); @@ -1496,11 +1448,7 @@ void WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail #endif #ifdef USE_SELECT -void WebServer::on_select_update(select::Select *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", select_state_json_generator); -} +void WebServer::on_select_update(select::Select *obj) { this->send_state_event_(obj, select_state_json_generator); } void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_selects()) { auto entity_match = match.match_entity(obj); @@ -1556,11 +1504,7 @@ void WebServer::select_json_(select::Select *obj, StringRef value, JsonDetail st #endif #ifdef USE_CLIMATE -void WebServer::on_climate_update(climate::Climate *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", climate_state_json_generator); -} +void WebServer::on_climate_update(climate::Climate *obj) { this->send_state_event_(obj, climate_state_json_generator); } void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (auto *obj : App.get_climates()) { auto entity_match = match.match_entity(obj); @@ -1743,11 +1687,7 @@ static void execute_lock_action(lock::Lock *obj, LockAction action) { } } -void WebServer::on_lock_update(lock::Lock *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", lock_state_json_generator); -} +void WebServer::on_lock_update(lock::Lock *obj) { this->send_state_event_(obj, lock_state_json_generator); } void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (lock::Lock *obj : App.get_locks()) { auto entity_match = match.match_entity(obj); @@ -1800,11 +1740,7 @@ void WebServer::lock_json_(lock::Lock *obj, lock::LockState value, JsonDetail st #endif #ifdef USE_VALVE -void WebServer::on_valve_update(valve::Valve *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", valve_state_json_generator); -} +void WebServer::on_valve_update(valve::Valve *obj) { this->send_state_event_(obj, valve_state_json_generator); } void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (valve::Valve *obj : App.get_valves()) { auto entity_match = match.match_entity(obj); @@ -1870,9 +1806,7 @@ void WebServer::valve_json_(valve::Valve *obj, JsonDetail start_config, json::Js #ifdef USE_ALARM_CONTROL_PANEL void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", alarm_control_panel_state_json_generator); + this->send_state_event_(obj, alarm_control_panel_state_json_generator); } void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (alarm_control_panel::AlarmControlPanel *obj : App.get_alarm_control_panels()) { @@ -1940,9 +1874,7 @@ void WebServer::alarm_control_panel_json_(alarm_control_panel::AlarmControlPanel #ifdef USE_WATER_HEATER void WebServer::on_water_heater_update(water_heater::WaterHeater *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", water_heater_state_json_generator); + this->send_state_event_(obj, water_heater_state_json_generator); } void WebServer::handle_water_heater_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (water_heater::WaterHeater *obj : App.get_water_heaters()) { @@ -2235,11 +2167,7 @@ void WebServer::radio_frequency_json_(radio_frequency::RadioFrequency *obj, Json #endif #ifdef USE_EVENT -void WebServer::on_event(event::Event *obj) { - if (!this->include_internal_ && obj->is_internal()) - return; - this->events_.deferrable_send_state(obj, "state", event_state_json_generator); -} +void WebServer::on_event(event::Event *obj) { this->send_state_event_(obj, event_state_json_generator); } void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (event::Event *obj : App.get_events()) { @@ -2292,9 +2220,7 @@ void WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail #endif #ifdef USE_UPDATE -void WebServer::on_update(update::UpdateEntity *obj) { - this->events_.deferrable_send_state(obj, "state", update_state_json_generator); -} +void WebServer::on_update(update::UpdateEntity *obj) { this->send_state_event_(obj, update_state_json_generator); } void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) { for (update::UpdateEntity *obj : App.get_updates()) { auto entity_match = match.match_entity(obj); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index d545fbf95dd..13695e033ec 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -162,7 +162,10 @@ class DeferredUpdateEventSource final : public AsyncEventSource { void loop(); - void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); + // detail_all events go out while entities are still being listed; state events wait until listing is done + void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator); + // The old event name argument would otherwise convert to true + void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete; void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); }; @@ -176,7 +179,9 @@ class DeferredUpdateEventSourceList final : public std::list void send_state_event_(T *obj, message_generator_t *generator) { + this->send_state_event_(obj, static_cast(obj), generator); + } + void send_state_event_(void *source, const EntityBase *entity, message_generator_t *generator); + void add_sorting_info_(JsonObject &root, EntityBase *entity); #ifdef USE_LIGHT diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 456c9be2c9a..b2792d14d3e 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -797,14 +797,13 @@ void AsyncEventSource::try_send_nodefer(const char *message, size_t message_len, } } -void AsyncEventSource::deferrable_send_state(void *source, const char *event_type, - message_generator_t *message_generator) { +void AsyncEventSource::deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator) { // Skip if no connected clients to avoid unnecessary processing if (this->empty()) return; for (auto *ses : this->sessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions - ses->deferrable_send_state(source, event_type, message_generator); + ses->deferrable_send_state(source, detail_all, message_generator); } } } @@ -1268,24 +1267,13 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t mess return this->stash_chunk_(prefix, prefix_len, message, message_len, g.total, sent); } -void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *event_type, +void AsyncEventSourceResponse::deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator) { - // allow all json "details_all" to go through before publishing bare state events, this avoids unnamed entries showing - // up in the web GUI and reduces event load during initial connect - if (!this->entities_iterator_.completed() && 0 != strcmp(event_type, "state_detail_all")) + // Let the detail_all listing finish before bare state events, so the web UI never shows unnamed entries and + // initial connect sends less + if (!detail_all && !this->entities_iterator_.completed()) return; - if (source == nullptr) - return; - if (event_type == nullptr) - return; - if (message_generator == nullptr) - return; - - if (0 != strcmp(event_type, "state_detail_all") && 0 != strcmp(event_type, "state")) { - ESP_LOGE(TAG, "Can't defer non-state event"); - } - drain_tail_(); process_deferred_queue_(); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index c25dd8d609f..c87aca0aade 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -301,7 +301,10 @@ class AsyncEventSourceResponse { public: bool try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); - void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); + // detail_all events go out while entities are still being listed; state events wait until listing is done + void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator); + // The old event name argument would otherwise convert to true + void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete; void loop(); protected: @@ -413,7 +416,9 @@ class AsyncEventSource : public AsyncWebHandler { void handleRequest(AsyncWebServerRequest *request) override; void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); - void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); + void deferrable_send_state(void *source, bool detail_all, message_generator_t *message_generator); + // The old event name argument would otherwise convert to true + void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) = delete; /// Returns true if there are sessions remaining (including pending cleanup). bool loop(); bool empty() { return this->count() == 0; } From 85dd0f71b3797b10d07cc45bd23d8bc9ac04e3ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 19 Oct 2026 07:50:56 -1000 Subject: [PATCH 179/179] [core] Add Apply entities so hub forwarding buttons, switches, numbers and selects need no class (#20935) --- esphome/automation.py | 14 +- esphome/components/button/__init__.py | 9 +- esphome/components/button/button.h | 10 + esphome/components/number/__init__.py | 13 +- esphome/components/number/number.h | 10 + esphome/components/select/__init__.py | 14 +- esphome/components/select/select.h | 10 + esphome/components/switch/__init__.py | 13 +- esphome/components/switch/switch.h | 10 + esphome/core/entity_helpers.py | 103 +++++- tests/components/button/apply_button_test.cpp | 27 ++ tests/components/number/apply_number_test.cpp | 31 ++ tests/components/select/apply_select_test.cpp | 27 ++ tests/components/switch/apply_switch_test.cpp | 37 ++ tests/unit_tests/core/test_entity_helpers.py | 320 +++++++++++++++++- 15 files changed, 625 insertions(+), 23 deletions(-) create mode 100644 tests/components/button/apply_button_test.cpp create mode 100644 tests/components/number/apply_number_test.cpp create mode 100644 tests/components/select/apply_select_test.cpp create mode 100644 tests/components/switch/apply_switch_test.cpp diff --git a/esphome/automation.py b/esphome/automation.py index 91cbf0daab1..d1d504f9121 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -430,12 +430,20 @@ def _apply_function( ) -> MockObj: """Emit the generated function and declare ``id_`` as the ``ApplyAction`` or ``ApplyCondition`` templated on it, so ``play()`` calls it directly.""" - fn = cg.static_function( - f"esphome__{id_.id}__fn", return_type, lambda_args, statements - ) + fn = apply_static_function(id_, return_type, lambda_args, statements) return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg)) +def apply_static_function( + id_: ID, + return_type: SafeExpType, + params: TemplateArgsType, + statements: list[str], +) -> MockObj: + """Emit the static function generated for ``id_`` and return an expression naming it.""" + return cg.static_function(f"esphome__{id_.id}__fn", return_type, params, statements) + + async def _render_values( name: str, target: str, diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index a4543c06c07..b42b5a9be61 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -23,6 +23,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + ApplyEntity, SubEntities, entity_duplicate_validator, queue_entity_register, @@ -46,6 +47,7 @@ DEVICE_CLASSES = [ button_ns = cg.esphome_ns.namespace("button") Button = button_ns.class_("Button", cg.EntityBase) ButtonPtr = Button.operator("ptr") +ApplyButton = button_ns.class_("ApplyButton", Button) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") @@ -155,11 +157,14 @@ async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj: return var +APPLY_ENTITY = ApplyEntity(ApplyButton, ((ButtonPtr, "entity"),)) + + def sub_buttons( config: ConfigType, *, parent: MockObj | ID | None = None ) -> SubEntities: - """Return a SubEntities bound to new_button.""" - return SubEntities(new_button, config, parent) + """Return a SubEntities bound to new_button; ``apply`` statements can use ``entity``.""" + return SubEntities(new_button, config, parent, APPLY_ENTITY) BUTTON_PRESS_SCHEMA = maybe_simple_id( diff --git a/esphome/components/button/button.h b/esphome/components/button/button.h index 96e9107532b..81e55e99038 100644 --- a/esphome/components/button/button.h +++ b/esphome/components/button/button.h @@ -46,4 +46,14 @@ class Button : public EntityBase { LazyCallbackManager press_callback_{}; }; +/// Calls the codegen-generated Fn(this) on a press; the hub and any publish are baked into Fn. +template class ApplyButton final : public Button { + public: + // Not "= default": placement new would zero-fill .bss that is already zero. + ApplyButton() {} + + protected: + void press_action() override { Fn(this); } +}; + } // namespace esphome::button diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 96352a8f1a6..c863caa51af 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -82,6 +82,7 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( + ApplyEntity, SubEntities, entity_duplicate_validator, queue_entity_register, @@ -160,6 +161,7 @@ IS_PLATFORM_COMPONENT = True number_ns = cg.esphome_ns.namespace("number") Number = number_ns.class_("Number", cg.EntityBase) NumberPtr = Number.operator("ptr") +ApplyNumber = number_ns.class_("ApplyNumber", Number) # Triggers ValueRangeTrigger = number_ns.class_( @@ -321,11 +323,18 @@ async def new_number(config, *args, min_value: float, max_value: float, step: fl return var +APPLY_ENTITY = ApplyEntity( + ApplyNumber, + ((NumberPtr, "entity"), (cg.float_, "value")), + "entity->publish_state(value);", +) + + def sub_numbers( config: ConfigType, *, parent: MockObj | ID | None = None ) -> SubEntities: - """Return a SubEntities bound to new_number.""" - return SubEntities(new_number, config, parent) + """Return a SubEntities bound to new_number; ``apply`` statements can use ``entity`` and ``value``.""" + return SubEntities(new_number, config, parent, APPLY_ENTITY) NUMBER_IN_RANGE_CONDITION_SCHEMA = cv.All( diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index b697e770be0..b18b93e4b86 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -54,4 +54,14 @@ class Number : public EntityBase { LazyCallbackManager state_callback_; }; +/// Calls the codegen-generated Fn(this, value) on a new value; the hub and any publish are baked into Fn. +template class ApplyNumber final : public Number { + public: + // Not "= default": placement new would zero-fill .bss that is already zero. + ApplyNumber() {} + + protected: + void control(float value) override { Fn(this, value); } +}; + } // namespace esphome::number diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 68fe39b6bcb..3d23dc14066 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + ApplyEntity, SubEntities, entity_duplicate_validator, queue_entity_register, @@ -35,6 +36,7 @@ IS_PLATFORM_COMPONENT = True select_ns = cg.esphome_ns.namespace("select") Select = select_ns.class_("Select", cg.EntityBase) SelectPtr = Select.operator("ptr") +ApplySelect = select_ns.class_("ApplySelect", Select) # Conditions SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) @@ -121,11 +123,19 @@ async def new_select(config, *args, options: list[str]): return var +APPLY_ENTITY = ApplyEntity( + ApplySelect, + ((SelectPtr, "entity"), (cg.size_t, "index")), + "entity->publish_state(index);", + (("option", "const char *option = entity->option_at(index);"),), +) + + def sub_selects( config: ConfigType, *, parent: MockObj | ID | None = None ) -> SubEntities: - """Return a SubEntities bound to new_select.""" - return SubEntities(new_select, config, parent) + """Return a SubEntities bound to new_select; ``apply`` statements can use ``entity``, ``index`` and ``option``.""" + return SubEntities(new_select, config, parent, APPLY_ENTITY) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 2294f34e624..3bbf9344e37 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -112,4 +112,14 @@ class Select : public EntityBase { LazyCallbackManager state_callback_; }; +/// Calls the codegen-generated Fn(this, index) on a new index; the hub and any publish are baked into Fn. +template class ApplySelect final : public Select { + public: + // Not "= default": placement new would zero-fill .bss that is already zero. + ApplySelect() {} + + protected: + void control(size_t index) override { Fn(this, index); } +}; + } // namespace esphome::select diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index e965b552f48..51deaaac166 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -22,6 +22,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + ApplyEntity, SubEntities, entity_duplicate_validator, queue_entity_register, @@ -43,6 +44,7 @@ DEVICE_CLASSES = [ switch_ns = cg.esphome_ns.namespace("switch_") Switch = switch_ns.class_("Switch", cg.EntityBase) SwitchPtr = Switch.operator("ptr") +ApplySwitch = switch_ns.class_("ApplySwitch", Switch) SwitchRestoreMode = switch_ns.enum("SwitchRestoreMode") @@ -176,11 +178,18 @@ async def new_switch(config, *args): return var +APPLY_ENTITY = ApplyEntity( + ApplySwitch, + ((SwitchPtr, "entity"), (cg.bool_, "state")), + "entity->publish_state(state);", +) + + def sub_switches( config: ConfigType, *, parent: MockObj | ID | None = None ) -> SubEntities: - """Return a SubEntities bound to new_switch.""" - return SubEntities(new_switch, config, parent) + """Return a SubEntities bound to new_switch; ``apply`` statements can use ``entity`` and ``state``.""" + return SubEntities(new_switch, config, parent, APPLY_ENTITY) SWITCH_ACTION_SCHEMA = maybe_simple_id( diff --git a/esphome/components/switch/switch.h b/esphome/components/switch/switch.h index 0564c3efd28..1449d22b819 100644 --- a/esphome/components/switch/switch.h +++ b/esphome/components/switch/switch.h @@ -147,4 +147,14 @@ class Switch : public EntityBase { #define LOG_SWITCH(prefix, type, obj) log_switch((TAG), (prefix), LOG_STR_LITERAL(type), (obj)) void log_switch(const char *tag, const char *prefix, const char *type, Switch *obj); +/// Calls the codegen-generated Fn(this, state) on a state write; the hub and any publish are baked into Fn. +template class ApplySwitch final : public Switch { + public: + // Not "= default": placement new would zero-fill .bss that is already zero. + ApplySwitch() {} + + protected: + void write_state(bool state) override { Fn(this, state); } +}; + } // namespace esphome::switch_ diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 4f446050253..9ac6286443e 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -2,8 +2,10 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field import functools import logging -from typing import Any +import re +from typing import Any, Literal +from esphome.automation import apply_static_function, parent_ref import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -23,7 +25,7 @@ from esphome.core.config import ( ICON_MAX_LENGTH, UNIT_OF_MEASUREMENT_MAX_LENGTH, ) -from esphome.cpp_generator import MockObj, RawStatement, add, get_variable +from esphome.cpp_generator import MockObj, MockObjClass, RawStatement, add, get_variable from esphome.cpp_types import App import esphome.final_validate as fv from esphome.helpers import ( @@ -468,33 +470,116 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> config[_KEY_ICON_IDX] = icon_idx +ApplyPublish = Literal["before", "after"] | None + + +@dataclass(frozen=True, slots=True) +class ApplyEntity: + """A domain's ``Apply*`` class, its generated function's parameters and its publish statement. + + ``locals`` are (name, declaration) pairs, declared only when the body uses the name. + """ + + cls: MockObjClass + params: tuple[tuple[SafeExpType, str], ...] + publish: str | None = None + locals: tuple[tuple[str, str], ...] = () + + +_APPLY_DOMAIN = "entity_apply" + + @dataclass(frozen=True, slots=True) class SubEntities: """Create the child entity configured under key, set parent (if bound) and pass it to setter. - Extra arguments go to new_entity. Returns None only when key is absent, so the result can be - used directly as a condition. + Returns None only when key is absent. ``apply``/``publish`` are documented on ``create``. """ new_entity: Callable[..., Awaitable[MockObj]] config: ConfigType parent: MockObj | ID | None = None + apply_entity: ApplyEntity | None = None async def __call__( self, key: str, - setter: Callable[[MockObj], Expression], + setter: Callable[[MockObj], Expression] | None, *args: SafeExpType, **kwargs: Any, ) -> MockObj | None: if (conf := self.config.get(key)) is None: return None - var = await self.new_entity(conf, *args, **kwargs) - if self.parent is not None: - await cg.register_parented(var, self.parent) - add(setter(var)) + return await self.create(conf, setter, *args, **kwargs) + + async def create( + self, + conf: ConfigType, + setter: Callable[[MockObj], Expression] | None, + *args: SafeExpType, + apply: str | None = None, + publish: ApplyPublish = None, + **kwargs: Any, + ) -> MockObj: + """Create the entity from ``conf``; ``setter`` may be None when the hub keeps no pointer. + + With ``apply`` the entity is the domain's ``Apply*`` class and its control runs + ``parent->apply``, with ``publish`` ("before"/"after") placing the domain's publish. + """ + entity_id: ID = conf[CONF_ID] + is_apply_class = self.apply_entity is not None and str(entity_id.type) == str( + self.apply_entity.cls + ) + if apply is None and is_apply_class: + raise ValueError(f"{entity_id}: {entity_id.type} needs apply=") + if apply is not None and not is_apply_class: + raise ValueError( + f"{entity_id}: apply= needs the domain's Apply class, not {entity_id.type}" + ) + if apply is not None and self.parent is None: + raise ValueError(f"{entity_id}: apply= needs a parent") + if publish is not None and (apply is None or self.apply_entity.publish is None): + raise ValueError(f"{entity_id}: publish= needs apply= on a stateful domain") + if publish not in (None, "before", "after"): + raise ValueError(f'{entity_id}: publish= must be "before" or "after"') + + if apply is None: + var = await self.new_entity(conf, *args, **kwargs) + if self.parent is not None: + await cg.register_parented(var, self.parent) + else: + fn = await self._apply_function(entity_id, apply, publish) + var = await self.new_entity(conf, cg.TemplateArguments(fn), *args, **kwargs) + if setter is not None: + add(setter(var)) return var + async def _apply_function( + self, entity_id: ID, apply: str, publish: ApplyPublish + ) -> SafeExpType: + """Emit the control function once per distinct body, so equal entities share one class.""" + parent = self.parent + if isinstance(parent, ID): + parent = await get_variable(parent) + apply_entity = self.apply_entity + body = [f"{parent_ref(parent)}->{apply};"] + if publish == "before": + body.insert(0, apply_entity.publish) + elif publish == "after": + body.append(apply_entity.publish) + for name, decl in apply_entity.locals: + # Declared right before its first use, so calls keep their order. + uses = (i for i, line in enumerate(body) if re.search(rf"\b{name}\b", line)) + if (first := next(uses, None)) is not None: + body.insert(first, decl) + functions: dict[str, SafeExpType] = CORE.data.setdefault(_APPLY_DOMAIN, {}) + key = "\n".join([*(f"{t} {n}" for t, n in apply_entity.params), *body]) + if (fn := functions.get(key)) is None: + fn = functions[key] = apply_static_function( + entity_id, cg.void, list(apply_entity.params), body + ) + return fn + def inherit_property_from(property_to_inherit, parent_id_property, transform=None): """Validator that inherits a configuration property from another entity, for use with FINAL_VALIDATE_SCHEMA. diff --git a/tests/components/button/apply_button_test.cpp b/tests/components/button/apply_button_test.cpp new file mode 100644 index 00000000000..225427f6689 --- /dev/null +++ b/tests/components/button/apply_button_test.cpp @@ -0,0 +1,27 @@ +#include + +#include "esphome/components/button/button.h" + +namespace esphome::button::testing { + +static Button *last_entity = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static int presses = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static void on_press(Button *entity) { + last_entity = entity; + presses++; +} + +TEST(ApplyButton, PressRunsTheFunctionWithTheEntity) { + last_entity = nullptr; + presses = 0; + ApplyButton button; + button.press(); + button.press(); + EXPECT_EQ(presses, 2); + EXPECT_EQ(last_entity, &button); +} + +TEST(ApplyButton, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplyButton), sizeof(Button)); } + +} // namespace esphome::button::testing diff --git a/tests/components/number/apply_number_test.cpp b/tests/components/number/apply_number_test.cpp new file mode 100644 index 00000000000..bc412a5688b --- /dev/null +++ b/tests/components/number/apply_number_test.cpp @@ -0,0 +1,31 @@ +#include + +#include + +#include "esphome/components/number/number.h" + +namespace esphome::number::testing { + +static Number *last_entity = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static std::vector values; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static void on_control(Number *entity, float value) { + last_entity = entity; + values.push_back(value); +} + +TEST(ApplyNumber, ControlRunsTheFunctionWithTheValue) { + last_entity = nullptr; + values.clear(); + ApplyNumber number; + number.traits.set_min_value(0); + number.traits.set_max_value(10); + number.traits.set_step(1); + number.make_call().set_value(4).perform(); + EXPECT_EQ(values, (std::vector{4})); + EXPECT_EQ(last_entity, &number); +} + +TEST(ApplyNumber, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplyNumber), sizeof(Number)); } + +} // namespace esphome::number::testing diff --git a/tests/components/select/apply_select_test.cpp b/tests/components/select/apply_select_test.cpp new file mode 100644 index 00000000000..083fba742d1 --- /dev/null +++ b/tests/components/select/apply_select_test.cpp @@ -0,0 +1,27 @@ +#include + +#include +#include + +#include "esphome/components/select/select.h" + +namespace esphome::select::testing { + +static constexpr const char *const APPLY_OPTIONS[] = {"low", "medium", "high"}; + +static std::vector chosen; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static void on_control(Select *entity, size_t index) { chosen.emplace_back(entity->option_at(index)); } + +TEST(ApplySelect, ControlRunsTheFunctionWithTheIndex) { + chosen.clear(); + ApplySelect select; + select.traits.set_options_static(APPLY_OPTIONS, 3); + select.make_call().set_index(2).perform(); + select.make_call().set_option("low").perform(); + EXPECT_EQ(chosen, (std::vector{"high", "low"})); +} + +TEST(ApplySelect, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplySelect), sizeof(Select)); } + +} // namespace esphome::select::testing diff --git a/tests/components/switch/apply_switch_test.cpp b/tests/components/switch/apply_switch_test.cpp new file mode 100644 index 00000000000..14304f1f4c3 --- /dev/null +++ b/tests/components/switch/apply_switch_test.cpp @@ -0,0 +1,37 @@ +#include + +#include + +#include "esphome/components/switch/switch.h" + +namespace esphome::switch_::testing { + +static Switch *last_entity = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static std::vector writes; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static void on_write(Switch *entity, bool state) { + last_entity = entity; + writes.push_back(state); +} + +TEST(ApplySwitch, WriteRunsTheFunctionWithTheState) { + last_entity = nullptr; + writes.clear(); + ApplySwitch sw; + sw.turn_on(); + sw.turn_off(); + EXPECT_EQ(writes, (std::vector{true, false})); + EXPECT_EQ(last_entity, &sw); +} + +TEST(ApplySwitch, InversionIsAppliedBeforeTheFunction) { + writes.clear(); + ApplySwitch sw; + sw.set_inverted(true); + sw.turn_on(); + EXPECT_EQ(writes, (std::vector{false})); +} + +TEST(ApplySwitch, HoldsNoMoreThanTheBase) { EXPECT_EQ(sizeof(ApplySwitch), sizeof(Switch)); } + +} // namespace esphome::switch_::testing diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index cd6b3315178..e4f2ec0b8e4 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -7,6 +7,8 @@ from typing import Any import pytest +import esphome.codegen as cg +from esphome.components import button, number, select, switch from esphome.config_validation import Invalid from esphome.const import ( CONF_DEVICE_CLASS, @@ -33,7 +35,7 @@ from esphome.core.entity_helpers import ( setup_entity, setup_unit_of_measurement, ) -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, MockObjClass, RawExpression from esphome.helpers import fnv1_hash, sanitize, snake_case from .common import load_config_from_fixture @@ -1255,10 +1257,11 @@ async def test_sub_entities_creates_and_sets( monkeypatch.setattr(entity_helpers.cg, "register_parented", fake_register_parented) hub = MockObj("hub", "->") - sub = entity_helpers.SubEntities(new_entity, {"child": {}}, parent=hub) + conf = {CONF_ID: ID("child")} + sub = entity_helpers.SubEntities(new_entity, {"child": conf}, parent=hub) assert await sub("child", hub.set_child, 1, step=2) is created - assert calls == [({}, (1,), {"step": 2})] + assert calls == [(conf, (1,), {"step": 2})] assert setup_test_environment == ["parent child hub", "hub->set_child(child)"] @@ -1294,3 +1297,314 @@ async def test_sub_entities_without_parent( assert await sub("child", hub.set_child) is not None assert setup_test_environment == ["hub->set_child(child)"] + + +@pytest.fixture +def apply_functions(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str, str]]: + """Record every generated apply function as (name, parameters, body).""" + emitted: list[tuple[str, str, str]] = [] + + def fake_static_function( + name: str, return_type: Any, parameters: list[tuple[Any, str]], body: list[str] + ) -> RawExpression: + assert str(return_type) == "void" + params = ", ".join(f"{type_} {arg}" for type_, arg in parameters) + emitted.append((name, params, "\n".join(body))) + return RawExpression(name) + + monkeypatch.setattr(entity_helpers.cg, "static_function", fake_static_function) + CORE.data.pop(entity_helpers._APPLY_DOMAIN, None) + return emitted + + +_HUB = MockObj("hub", "->") +_APPLY_SWITCH_CLS = cg.esphome_ns.namespace("switch_").class_("ApplySwitch") +_SWITCH_APPLY = entity_helpers.ApplyEntity( + _APPLY_SWITCH_CLS, + ((RawExpression("switch_::Switch *"), "entity"), (RawExpression("bool"), "state")), + "entity->publish_state(state);", +) +_BUTTON_APPLY = entity_helpers.ApplyEntity( + cg.esphome_ns.namespace("button").class_("ApplyButton"), + ((RawExpression("button::Button *"), "entity"),), +) + + +def _apply_conf(name: str, type_: MockObjClass = _APPLY_SWITCH_CLS) -> dict[str, ID]: + return {CONF_ID: ID(name, type=type_)} + + +def _apply_sub( + calls: list[tuple[Any, ...]], + config: dict[str, Any], + apply_entity: entity_helpers.ApplyEntity = _SWITCH_APPLY, + parent: MockObj | ID | None = _HUB, +) -> entity_helpers.SubEntities: + async def new_entity(conf: dict[str, Any], *args: Any, **kwargs: Any) -> MockObj: + calls.append((conf[CONF_ID].id, *(str(a) for a in args))) + return MockObj(conf[CONF_ID].id) + + return entity_helpers.SubEntities(new_entity, config, parent, apply_entity) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("publish", "body"), + [ + (None, "::hub->set_bluetooth(state);"), + ( + "before", + "entity->publish_state(state);\n::hub->set_bluetooth(state);", + ), + ("after", "::hub->set_bluetooth(state);\nentity->publish_state(state);"), + ], +) +async def test_sub_entities_apply_generates_function( + monkeypatch: pytest.MonkeyPatch, + setup_test_environment: list[str], + apply_functions: list[tuple[str, str, str]], + publish: str | None, + body: str, +) -> None: + """With apply the entity is templated on a generated function and stores no parent.""" + + async def fail_register_parented(var: MockObj, parent: MockObj) -> None: + raise AssertionError("must not be called") + + monkeypatch.setattr(entity_helpers.cg, "register_parented", fail_register_parented) + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {"bt": _apply_conf("bt_sw")}) + + var = await sub( + "bt", _HUB.set_bt_switch, apply="set_bluetooth(state)", publish=publish + ) + assert str(var) == "bt_sw" + assert calls == [("bt_sw", "")] + assert apply_functions == [ + ("esphome__bt_sw__fn", "switch_::Switch * entity, bool state", body) + ] + assert setup_test_environment == ["hub->set_bt_switch(bt_sw)"] + + +_SELECT_APPLY = entity_helpers.ApplyEntity( + cg.esphome_ns.namespace("select").class_("ApplySelect"), + ((RawExpression("select::Select *"), "entity"), (RawExpression("size_t"), "index")), + "entity->publish_state(index);", + (("option", "const char *option = entity->option_at(index);"),), +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("apply", "publish", "body"), + [ + ( + "set_mode(option)", + "after", + ( + "const char *option = entity->option_at(index);\n" + "::hub->set_mode(option);\nentity->publish_state(index);" + ), + ), + ( + "set_mode(option)", + "before", + ( + "entity->publish_state(index);\n" + "const char *option = entity->option_at(index);\n" + "::hub->set_mode(option);" + ), + ), + ( + "set_mode(index, options_)", + "after", + "::hub->set_mode(index, options_);\nentity->publish_state(index);", + ), + ], +) +async def test_sub_entities_apply_locals( + setup_test_environment: list[str], + apply_functions: list[tuple[str, str, str]], + apply: str, + publish: str, + body: str, +) -> None: + """A local is declared before its first use, and only when named as a whole identifier.""" + conf = _apply_conf("m_sel", _SELECT_APPLY.cls) + sub = _apply_sub([], {"m": conf}, _SELECT_APPLY) + await sub("m", None, apply=apply, publish=publish) + assert apply_functions == [ + ("esphome__m_sel__fn", "select::Select * entity, size_t index", body) + ] + + +@pytest.mark.asyncio +async def test_sub_entities_apply_shares_equal_functions( + setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]] +) -> None: + """Entities with the same statement share one function, so one class instantiation.""" + calls: list[tuple[Any, ...]] = [] + config = { + "a": _apply_conf("a_sw"), + "b": _apply_conf("b_sw"), + "c": _apply_conf("c_sw"), + } + sub = _apply_sub(calls, config) + await sub("a", _HUB.set_a, apply="refresh()", publish="before") + await sub("b", _HUB.set_b, apply="refresh()", publish="before") + await sub("c", _HUB.set_c, apply="refresh()") + assert calls == [ + ("a_sw", ""), + ("b_sw", ""), + ("c_sw", ""), + ] + assert [name for name, _, _ in apply_functions] == [ + "esphome__a_sw__fn", + "esphome__c_sw__fn", + ] + + +@pytest.mark.asyncio +async def test_sub_entities_apply_parent_id( + monkeypatch: pytest.MonkeyPatch, + setup_test_environment: list[str], + apply_functions: list[tuple[str, str, str]], +) -> None: + """A parent given as an ID is looked up once and named from global scope.""" + + async def fake_get_variable(id_: ID) -> MockObj: + assert id_ == ID("my_hub") + return MockObj("my_hub", "->") + + monkeypatch.setattr(entity_helpers, "get_variable", fake_get_variable) + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {"r": _apply_conf("r_sw")}, parent=ID("my_hub")) + await sub("r", MockObj("my_hub", "->").set_r, apply="restart()") + assert apply_functions[0][2] == "::my_hub->restart();" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("apply_entity", "conf", "kwargs", "match"), + [ + (_SWITCH_APPLY, _apply_conf("k_id"), {}, "needs apply="), + ( + _SWITCH_APPLY, + _apply_conf("k_id", cg.esphome_ns.namespace("x").class_("MySwitch")), + {"apply": "x()"}, + "needs the domain's Apply class", + ), + ( + _BUTTON_APPLY, + _apply_conf( + "k_id", cg.esphome_ns.namespace("button").class_("ApplyButton") + ), + {"apply": "restart()", "publish": "before"}, + "publish= needs", + ), + ( + _SWITCH_APPLY, + _apply_conf("k_id", cg.esphome_ns.namespace("x").class_("MySwitch")), + {"publish": "before"}, + "publish= needs", + ), + ( + _SWITCH_APPLY, + _apply_conf("k_id"), + {"apply": "x()", "publish": "Before"}, + "must be", + ), + ], +) +async def test_sub_entities_apply_errors( + setup_test_environment: list[str], + apply_functions: list[tuple[str, str, str]], + apply_entity: entity_helpers.ApplyEntity, + conf: dict[str, ID], + kwargs: dict[str, str], + match: str, +) -> None: + """A mismatch between the declared class and apply= fails in Python, not in C++.""" + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {"k": conf}, apply_entity) + with pytest.raises(ValueError, match=match): + await sub("k", MockObj("hub", "->").set_k, **kwargs) + assert calls == [] + assert apply_functions == [] + + +@pytest.mark.asyncio +async def test_sub_entities_apply_needs_parent( + setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]] +) -> None: + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {"k": _apply_conf("k_id")}, parent=None) + with pytest.raises(ValueError, match="needs a parent"): + await sub("k", None, apply="x()") + + +@pytest.mark.parametrize( + ("sub_fn", "cls", "params", "publish"), + [ + (button.sub_buttons, button.ApplyButton, "button::Button * entity", None), + ( + switch.sub_switches, + switch.ApplySwitch, + "switch_::Switch * entity, bool state", + "entity->publish_state(state);", + ), + ( + number.sub_numbers, + number.ApplyNumber, + "number::Number * entity, float value", + "entity->publish_state(value);", + ), + ( + select.sub_selects, + select.ApplySelect, + "select::Select * entity, size_t index", + "entity->publish_state(index);", + ), + ], +) +def test_domain_apply_entities( + sub_fn: Callable[..., entity_helpers.SubEntities], + cls: MockObjClass, + params: str, + publish: str | None, +) -> None: + """Each domain's sub helper declares its Apply class and the function it calls.""" + apply_entity = sub_fn({}).apply_entity + assert apply_entity.cls is cls + assert ", ".join(f"{type_} {arg}" for type_, arg in apply_entity.params) == params + assert apply_entity.publish == publish + + +@pytest.mark.asyncio +async def test_sub_entities_without_setter( + setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]] +) -> None: + """A hub that does not keep the entity passes no setter; nothing else is emitted.""" + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {"vent": _apply_conf("vent_sw")}) + assert await sub("vent", None, apply="vent_door()") is not None + assert calls == [("vent_sw", "")] + assert setup_test_environment == [] + + +@pytest.mark.asyncio +async def test_sub_entities_create_from_whole_config( + setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]] +) -> None: + """create() builds the entity from a config that is the entity itself.""" + calls: list[tuple[Any, ...]] = [] + sub = _apply_sub(calls, {}) + var = await sub.create( + _apply_conf("rf_sw"), + _HUB.set_rf_switch, + apply="set_rf_mode(state)", + publish="before", + ) + assert str(var) == "rf_sw" + assert calls == [("rf_sw", "")] + assert setup_test_environment == ["hub->set_rf_switch(rf_sw)"]