From b764b18733c74d95460288762d84d3044e00111a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 10:03:21 -0600 Subject: [PATCH 01/55] [ld2410][ld2412] Drop signal_strength device class from the gate threshold numbers (#22259) --- esphome/components/ld2410/number.py | 3 --- esphome/components/ld2412/number.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/esphome/components/ld2410/number.py b/esphome/components/ld2410/number.py index 85bf8647fbd..ff5f1964647 100644 --- a/esphome/components/ld2410/number.py +++ b/esphome/components/ld2410/number.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_TIMEOUT, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_SIGNAL_STRENGTH, ENTITY_CATEGORY_CONFIG, ICON_LIGHTBULB, ICON_MOTION_SENSOR, @@ -65,14 +64,12 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Required(CONF_MOVE_THRESHOLD): number.number_schema( number.ApplyNumber, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, unit_of_measurement=UNIT_PERCENT, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, ), cv.Required(CONF_STILL_THRESHOLD): number.number_schema( number.ApplyNumber, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, unit_of_measurement=UNIT_PERCENT, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, diff --git a/esphome/components/ld2412/number.py b/esphome/components/ld2412/number.py index b1e556d9d2d..98ee135121b 100644 --- a/esphome/components/ld2412/number.py +++ b/esphome/components/ld2412/number.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_TIMEOUT, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_SIGNAL_STRENGTH, ENTITY_CATEGORY_CONFIG, ICON_LIGHTBULB, ICON_MOTION_SENSOR, @@ -65,14 +64,12 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Required(CONF_MOVE_THRESHOLD): number.number_schema( number.ApplyNumber, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Required(CONF_STILL_THRESHOLD): number.number_schema( number.ApplyNumber, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, From d20e3b3bb551a54358a99e8eb377b344dfbf632c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 10:08:18 -0600 Subject: [PATCH 02/55] [api] Make the event order backpressure test wait until the pipe is full (#22112) --- .../api_event_order_backpressure.yaml | 22 +++++++++-- .../sndbuf_pin_component.cpp | 37 +++++++++++++++++++ .../sndbuf_pin_component.h | 10 +++++ .../test_api_event_order_backpressure.py | 3 ++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/tests/integration/fixtures/api_event_order_backpressure.yaml b/tests/integration/fixtures/api_event_order_backpressure.yaml index 7b441883d98..cad66474f19 100644 --- a/tests/integration/fixtures/api_event_order_backpressure.yaml +++ b/tests/integration/fixtures/api_event_order_backpressure.yaml @@ -11,11 +11,27 @@ api: - action: fire_first_then_second then: - lambda: |- - // The client is not reading: about 110 KB of log lines fill the TCP pipe, then the overflow buffer + // The client is not reading: log lines fill the TCP pipe, then the overflow buffer. Until the + // client's receive window is closed, a late acknowledgement can still free send buffer space + // and let the overflow buffer drain, so keep going until it holds data with the window closed + auto *client = sndbuf_pin::api_log_client(); const std::string padding(250, 'x'); - for (int i = 0; i < 400; i++) { - ESP_LOGD("fill", "fill line %s", padding.c_str()); + const uint32_t start = millis(); + bool full = false; + while (client != nullptr && millis() - start < 5000) { + const bool window_closed = sndbuf_pin::api_peer_window_closed(); + if (client->try_to_clear_buffer(false)) { + ESP_LOGD("fill", "fill line %s", padding.c_str()); + } else if (window_closed) { + full = true; + break; + } else { + delay(1); + } } + // The api drops this line while the pipe is blocked; the test reads it from the console + if (!full) + ESP_LOGE("fill", "fill timed out"); // The test checks that this line was dropped, which means the overflow buffer still holds data ESP_LOGD("fill", "last fill line"); # So this event waits in the batch diff --git a/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.cpp b/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.cpp index dbcf5922c2b..59a75f3b044 100644 --- a/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.cpp +++ b/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.cpp @@ -7,6 +7,12 @@ #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" +// Host only: clang-tidy also checks this file for ESP32, which has no Linux socket headers +#if defined(USE_HOST) && defined(__linux__) +#include +#include +#endif + namespace esphome::sndbuf_pin { static const char *const TAG = "sndbuf_pin"; @@ -52,4 +58,35 @@ void SndbufPinComponent::setup() { } } +api::APIConnection *api_log_client() { + for (const auto &conn : api::global_api_server->active_clients()) { + if (conn->get_log_subscription_level() > 0) + return conn.get(); + } + return nullptr; +} + +bool api_peer_window_closed() { +#if defined(USE_HOST) && defined(__linux__) + for (int fd = FIRST_USER_FD; fd < MAX_FD_SCAN; fd++) { + struct sockaddr_in addr{}; + socklen_t addr_len = sizeof(addr); + if (::getsockname(fd, reinterpret_cast(&addr), &addr_len) != 0 || + ntohs(addr.sin_port) != api::global_api_server->get_port()) + continue; + // Skips the listener: only the accepted client socket has a peer + addr_len = sizeof(addr); + if (::getpeername(fd, reinterpret_cast(&addr), &addr_len) != 0) + continue; + int queued = 0; + int not_sent = 0; + if (::ioctl(fd, SIOCOUTQ, &queued) != 0 || ::ioctl(fd, SIOCOUTQNSD, ¬_sent) != 0) + return false; + return not_sent > 0 && queued == not_sent; + } +#endif + // Unknown elsewhere: a fill loop waiting on this times out and says so + return false; +} + } // namespace esphome::sndbuf_pin diff --git a/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.h b/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.h index b0af226d143..cc5f6096c88 100644 --- a/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.h +++ b/tests/integration/fixtures/external_components/sndbuf_pin_component/sndbuf_pin_component.h @@ -1,5 +1,6 @@ #pragma once +#include "esphome/components/api/api_connection.h" #include "esphome/core/component.h" namespace esphome::sndbuf_pin { @@ -18,4 +19,13 @@ class SndbufPinComponent : public Component { int buffer_size_; }; +/// The api client subscribed to logs, or nullptr. A fixture lambda cannot name the api namespace: +/// the test harness rewrites every "api" key followed by a colon in the YAML. +api::APIConnection *api_log_client(); + +/// True once the api client's receive window is closed: everything sent is acknowledged and data still +/// waits to be sent, so no acknowledgement can free send buffer space until the client reads again. +/// Linux host only; always false elsewhere. +bool api_peer_window_closed(); + } // namespace esphome::sndbuf_pin diff --git a/tests/integration/test_api_event_order_backpressure.py b/tests/integration/test_api_event_order_backpressure.py index eed42b50dcc..6c06f846937 100644 --- a/tests/integration/test_api_event_order_backpressure.py +++ b/tests/integration/test_api_event_order_backpressure.py @@ -72,6 +72,9 @@ async def test_api_event_order_backpressure( # The client stops reading here, so the device console tells when "first" fired await client.send_message(api_pb2.ExecuteServiceRequest(key=action_key)) await waiter.wait_for("first fired", timeout=10) + assert not any("fill timed out" in line for line in waiter.lines), ( + "the client's receive window never closed while the overflow buffer held data" + ) # The client reads again while the action still runs. # On a timeout the asserts below show which event is missing From 781f4330ba637d4ee1b655ccd35c41acb7ace443 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 10:08:42 -0600 Subject: [PATCH 03/55] [core] Dump typed_schema types that have no keys of their own (#22258) --- script/build_language_schema.py | 3 ++- tests/script/test_build_language_schema.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 881733215b6..d18052d4f52 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1109,7 +1109,8 @@ def convert(schema, config_var, path): config_var["typed_key"] = typed_schema[1].get("key", CONF_TYPE) for schema_key, schema_type in typed_schema[0][0].items(): config = convert_config(schema_type, path + "/type_" + schema_key) - types[schema_key] = config["schema"] + # A type with no keys of its own converts to nothing. + types[schema_key] = config.get("schema", {S_CONFIG_VARS: {}}) elif DUMP_UNKNOWN and S_TYPE not in config_var: config_var["unknown"] = repr_schema diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 56ded92bcd8..0769f5c11b9 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -439,3 +439,25 @@ def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None: _count(lvgl_schema) assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}" + + +def test_convert_typed_schema_with_an_empty_type( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A typed_schema type with no keys of its own is dumped with no config vars.""" + types = { + "with_keys": cv.Schema({cv.Required("byte"): cv.hex_uint8_t}), + "no_keys": cv.Schema({}), + } + schema = cv.typed_schema(types, key="mode") + # cv.typed_schema was decorated before the script enabled extraction, so register it by hand + monkeypatch.setitem( + _bls.ejs.typed_schemas, repr(schema), ((types,), {"key": "mode"}) + ) + config_var: dict = {} + _bls.convert(schema, config_var, "/test") + + assert config_var["type"] == "typed" + assert config_var["typed_key"] == "mode" + assert "byte" in config_var["types"]["with_keys"]["config_vars"] + assert config_var["types"]["no_keys"] == {"config_vars": {}} From c4100d5b3527051097524e3f5044252c85bde645 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 10:09:00 -0600 Subject: [PATCH 04/55] [components] Use setup_check() in the remaining setup failure paths (#22255) --- .../beken_spi_led_strip/led_strip.cpp | 21 +++++------------- .../components/bmp3xx_base/bmp3xx_base.cpp | 14 ++++-------- esphome/components/dac7678/dac7678_output.cpp | 15 +++++-------- .../components/ens160_base/ens160_base.cpp | 19 +++++----------- esphome/components/epaper_spi/epaper_spi.cpp | 4 +--- .../camera_web_server.cpp | 4 +--- .../esp32_rmt_led_strip/led_strip.cpp | 22 ++++++------------- esphome/components/fusb302b/fusb302b.cpp | 6 ++--- .../grove_tb6612fng/grove_tb6612fng.cpp | 7 +----- .../i2s_clockless_led_strip.cpp | 4 +--- esphome/components/it8951/it8951.cpp | 4 +--- esphome/components/ld600x/ld600x.cpp | 4 +--- .../ln882x_spi_led_strip/led_strip.cpp | 5 ++--- esphome/components/modem/modem_component.cpp | 4 +--- .../packet_interface/hdlc_reliability.cpp | 8 ++----- .../packet_interface/xxtea_encryption.cpp | 8 ++----- esphome/components/pixoo/pixoo.cpp | 4 +--- .../rp2040_pio_led_strip/led_strip.cpp | 14 +++++------- esphome/components/sgp4x/sgp4x.cpp | 4 +--- esphome/components/sht3xd/sht3xd.cpp | 7 +++--- esphome/components/sht4x/sht4x.cpp | 4 +--- .../spi_led_strip/spi_led_strip.cpp | 4 +--- esphome/components/statsd/statsd.cpp | 4 +--- esphome/components/tca9555/tca9555.cpp | 6 +---- 24 files changed, 57 insertions(+), 139 deletions(-) diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index a42166353d4..183382653e4 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -160,17 +160,14 @@ void spi_dma_tx_finish_callback(unsigned int param) { void BekenSPILEDStripLightOutput::setup() { RAMAllocator allocator; - if (!this->internal_accessor_.allocate(allocator, this->num_leds_)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->internal_accessor_.allocate(allocator, this->num_leds_), + LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } const size_t dma_buffer_size = (this->get_buffer_size_() * 8) + (2 * 64); this->dma_buf_ = allocator.allocate(dma_buffer_size); - if (this->dma_buf_ == nullptr) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->dma_buf_ != nullptr, LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } memset(this->dma_buf_, 0, dma_buffer_size); @@ -180,22 +177,16 @@ void BekenSPILEDStripLightOutput::setup() { value = PWD_SPI_CLK_BIT; sddev_control(ICU_DEV_NAME, CMD_CLK_PWR_UP, &value); - if (spi_data != nullptr) { - this->mark_failed(LOG_STR("SPI already initialized")); + if (!this->setup_check(spi_data == nullptr, LOG_STR("SPI already initialized"))) return; - } spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc) - if (spi_data == nullptr) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(spi_data != nullptr, LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } spi_data->dma_tx_semaphore = xSemaphoreCreateBinary(); - if (spi_data->dma_tx_semaphore == nullptr) { - this->mark_failed(LOG_STR("TX semaphore init failed")); + if (!this->setup_check(spi_data->dma_tx_semaphore != nullptr, LOG_STR("TX semaphore init failed"))) return; - } spi_data->first_run = true; diff --git a/esphome/components/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index e5089acb641..5e0b2bddde2 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -45,10 +45,8 @@ static const LogString *iir_filter_to_str(IIRFilter filter) { void BMP3XXComponent::setup() { // Call the Device base class "initialise" function - if (!reset()) { - this->mark_failed(LOG_STR("Failed to reset")); + if (!this->setup_check(this->reset(), LOG_STR("Failed to reset"))) return; - } if (!this->setup_check(read_byte(BMP388_CHIP_ID, &this->chip_id_.reg))) return; @@ -82,10 +80,8 @@ void BMP3XXComponent::setup() { compensation_float_params_.param_P11 = (float) compensation_params_.param_P11 / powf(2.0f, 65.0f); // Initialise the BMP388 IIR filter register - if (!set_iir_filter(this->iir_filter_)) { - this->mark_failed(LOG_STR("Failed to set IIR filter")); + if (!this->setup_check(this->set_iir_filter(this->iir_filter_), LOG_STR("Failed to set IIR filter"))) return; - } // Set power control registers pwr_ctrl_.bit.press_en = 1; @@ -101,10 +97,8 @@ void BMP3XXComponent::setup() { this->temperature_oversampling_ = OVERSAMPLING_NONE; } // Initialise the BMP388 oversampling register - if (!set_oversampling_register(this->pressure_oversampling_, this->temperature_oversampling_)) { - this->mark_failed(LOG_STR("Failed to set oversampling register")); - return; - } + this->setup_check(this->set_oversampling_register(this->pressure_oversampling_, this->temperature_oversampling_), + LOG_STR("Failed to set oversampling register")); } void BMP3XXComponent::dump_config() { diff --git a/esphome/components/dac7678/dac7678_output.cpp b/esphome/components/dac7678/dac7678_output.cpp index 2a47a4e2f6b..303c0e14a0b 100644 --- a/esphome/components/dac7678/dac7678_output.cpp +++ b/esphome/components/dac7678/dac7678_output.cpp @@ -22,23 +22,18 @@ void DAC7678Output::setup() { ESP_LOGV(TAG, "Resetting device"); // Reset device - if (!this->write_byte_16(DAC7678_REG_SOFTWARE_RESET, 0x0000)) { - this->mark_failed(LOG_STR("Reset failed")); + if (!this->setup_check(this->write_byte_16(DAC7678_REG_SOFTWARE_RESET, 0x0000), LOG_STR("Reset failed"))) return; - } else { - ESP_LOGV(TAG, "Reset succeeded"); - } + ESP_LOGV(TAG, "Reset succeeded"); delayMicroseconds(1000); // Set internal reference if (this->internal_reference_) { - if (!this->write_byte_16(DAC7678_REG_INTERNAL_REF_0, 1 << 4)) { - this->mark_failed(LOG_STR("Set internal reference failed")); + if (!this->setup_check(this->write_byte_16(DAC7678_REG_INTERNAL_REF_0, 1 << 4), + LOG_STR("Set internal reference failed"))) return; - } else { - ESP_LOGV(TAG, "Internal reference enabled"); - } + ESP_LOGV(TAG, "Internal reference enabled"); } } diff --git a/esphome/components/ens160_base/ens160_base.cpp b/esphome/components/ens160_base/ens160_base.cpp index eecde9343a0..17df598e4a3 100644 --- a/esphome/components/ens160_base/ens160_base.cpp +++ b/esphome/components/ens160_base/ens160_base.cpp @@ -64,10 +64,8 @@ void ENS160Component::setup() { uint16_t part_id; if (!this->setup_check(this->read_bytes(ENS160_REG_PART_ID, reinterpret_cast(&part_id), 2))) return; - if (part_id != ENS160_PART_ID) { - this->mark_failed(LOG_STR("Sensor reported an invalid ID. Is this a ENS160?")); + if (!this->setup_check(part_id == ENS160_PART_ID, LOG_STR("Sensor reported an invalid ID. Is this a ENS160?"))) return; - } // set mode to reset if (!this->setup_check(this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_RESET))) @@ -80,10 +78,8 @@ void ENS160Component::setup() { return; this->validity_flag_ = static_cast((ENS160_DATA_STATUS_VALIDITY & status_value) >> 2); - if (this->validity_flag_ == INVALID_OUTPUT) { - this->mark_failed(LOG_STR("Invalid Device Status - No valid output")); + if (!this->setup_check(this->validity_flag_ != INVALID_OUTPUT, LOG_STR("Invalid Device Status - No valid output"))) return; - } // set mode to idle if (!this->setup_check(this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_IDLE))) @@ -110,20 +106,15 @@ void ENS160Component::setup() { this->firmware_ver_build_ = version_data[2]; // set mode to standard - if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_STD)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->setup_check(this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_STD))) return; - } // read opmode and check standard mode is achieved before finishing Setup uint8_t op_mode; - if (!this->read_byte(ENS160_REG_OPMODE, &op_mode)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->setup_check(this->read_byte(ENS160_REG_OPMODE, &op_mode))) return; - } - if (!this->setup_check(op_mode == ENS160_OPMODE_STD, LOG_STR("Device failed to achieve Standard Operating Mode"))) - return; + this->setup_check(op_mode == ENS160_OPMODE_STD, LOG_STR("Device failed to achieve Standard Operating Mode")); } void ENS160Component::update() { diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 5f00fa59e90..e5bca4c0178 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -19,10 +19,8 @@ const LogString *EPaperBase::epaper_state_to_string_() { } void EPaperBase::setup() { - if (!this->init_buffer_(this->buffer_length_)) { - this->mark_failed(LOG_STR("Failed to initialise buffer")); + if (!this->setup_check(this->init_buffer_(this->buffer_length_), LOG_STR("Failed to initialise buffer"))) return; - } this->setup_pins_(); this->spi_setup(); } diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index a248b901204..1597074c404 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -55,10 +55,8 @@ void CameraWebServer::setup() { config.backlog_conn = 2; config.lru_purge_enable = true; - if (httpd_start(&this->httpd_, &config) != ESP_OK) { - this->mark_failed(LOG_STR("Failed to start the web server")); + if (!this->setup_check(httpd_start(&this->httpd_, &config) == ESP_OK, LOG_STR("Failed to start the web server"))) return; - } httpd_uri_t uri = { .uri = "/", diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index ac0bce3c603..213bb3086d8 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -69,10 +69,9 @@ void ESP32RMTLEDStripLightOutput::setup() { size_t buffer_size = this->get_buffer_size_(); RAMAllocator allocator(this->use_psram_ ? 0 : RAMAllocator::ALLOC_INTERNAL); - if (!this->internal_accessor_.allocate(allocator, this->num_leds_)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->internal_accessor_.allocate(allocator, this->num_leds_), + LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) // copy of the led buffer @@ -104,10 +103,8 @@ void ESP32RMTLEDStripLightOutput::setup() { channel.flags.invert_out = this->invert_out_; channel.flags.with_dma = this->use_dma_; channel.intr_priority = 0; - if (rmt_new_tx_channel(&channel, &this->channel_) != ESP_OK) { - this->mark_failed(LOG_STR("Channel creation failed")); + if (!this->setup_check(rmt_new_tx_channel(&channel, &this->channel_) == ESP_OK, LOG_STR("Channel creation failed"))) return; - } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) rmt_simple_encoder_config_t encoder; @@ -115,23 +112,18 @@ void ESP32RMTLEDStripLightOutput::setup() { encoder.callback = encoder_callback; encoder.arg = &this->params_; encoder.min_chunk_size = RMT_SYMBOLS_PER_BYTE; - if (rmt_new_simple_encoder(&encoder, &this->encoder_) != ESP_OK) { - this->mark_failed(LOG_STR("Encoder creation failed")); + if (!this->setup_check(rmt_new_simple_encoder(&encoder, &this->encoder_) == ESP_OK, + LOG_STR("Encoder creation failed"))) return; - } #else rmt_copy_encoder_config_t encoder; memset(&encoder, 0, sizeof(encoder)); - if (rmt_new_copy_encoder(&encoder, &this->encoder_) != ESP_OK) { - this->mark_failed(LOG_STR("Encoder creation failed")); + if (!this->setup_check(rmt_new_copy_encoder(&encoder, &this->encoder_) == ESP_OK, LOG_STR("Encoder creation failed"))) return; - } #endif - if (rmt_enable(this->channel_) != ESP_OK) { - this->mark_failed(LOG_STR("Enabling channel failed")); + if (!this->setup_check(rmt_enable(this->channel_) == ESP_OK, LOG_STR("Enabling channel failed"))) return; - } this->accessor_ = &this->internal_accessor_; } diff --git a/esphome/components/fusb302b/fusb302b.cpp b/esphome/components/fusb302b/fusb302b.cpp index 0c2f1b25a78..735ae76d552 100644 --- a/esphome/components/fusb302b/fusb302b.cpp +++ b/esphome/components/fusb302b/fusb302b.cpp @@ -66,10 +66,10 @@ void FUSB302B::setup() { this->interrupt_pin_->setup(); // The task must exist before the interrupt is attached, since the ISR notifies it - if (!this->task_.create(FUSB302B::task_func, "fusb302b", TASK_STACK_SIZE, this, TASK_PRIORITY, false)) { - this->mark_failed(LOG_STR("Failed to create task")); + if (!this->setup_check( + this->task_.create(FUSB302B::task_func, "fusb302b", TASK_STACK_SIZE, this, TASK_PRIORITY, false), + LOG_STR("Failed to create task"))) return; - } this->interrupt_pin_->attach_interrupt(&FUSB302B::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); } diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp index 7233bbee950..52126b16082 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp @@ -23,12 +23,7 @@ void GroveMotorDriveTB6612FNG::dump_config() { LOG_I2C_DEVICE(this); } -void GroveMotorDriveTB6612FNG::setup() { - if (!this->standby()) { - this->mark_failed(); - return; - } -} +void GroveMotorDriveTB6612FNG::setup() { this->setup_check(this->standby()); } bool GroveMotorDriveTB6612FNG::standby() { uint8_t status = 0; diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp index ff2fbde18f7..55f1e1a0ec5 100644 --- a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp @@ -68,10 +68,8 @@ void I2SClocklessLedStrip::dump_config() { float I2SClocklessLedStrip::get_setup_priority() const { return setup_priority::IO; } void I2SClocklessLedStrip::setup() { - if (!this->allocate_buffers_()) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->allocate_buffers_(), LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } i2s_chan_config_t chan_config = { .id = I2S_NUM_AUTO, diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 276f91eed59..094a49faf01 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -330,10 +330,8 @@ void IT8951Display::setup() { this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); RAMAllocator allocator{}; this->buffer_ = allocator.allocate(this->buffer_length_); - if (this->buffer_ == nullptr) { - this->mark_failed(LOG_STR("Failed to allocate IT8951 framebuffer")); + if (!this->setup_check(this->buffer_ != nullptr, LOG_STR("Failed to allocate IT8951 framebuffer"))) return; - } // The allocator does not zero memory; start blank (white) so undrawn regions // (e.g. with auto_clear disabled) don't show garbage on the first update. this->fill(Color::WHITE); diff --git a/esphome/components/ld600x/ld600x.cpp b/esphome/components/ld600x/ld600x.cpp index e025f0dc50c..5f76097bc19 100644 --- a/esphome/components/ld600x/ld600x.cpp +++ b/esphome/components/ld600x/ld600x.cpp @@ -187,10 +187,8 @@ void LD600XComponent::setup() { // One allocation for the component lifetime; the parser reuses it for every payload. RAMAllocator allocator; uint8_t *buffer = allocator.allocate(capacity); - if (buffer == nullptr) { - this->mark_failed(LOG_STR("Failed to allocate frame buffer")); + if (!this->setup_check(buffer != nullptr, LOG_STR("Failed to allocate frame buffer"))) return; - } this->parser_.init(buffer, capacity); this->set_timeout(SETUP_DELAY_MS, [this]() { diff --git a/esphome/components/ln882x_spi_led_strip/led_strip.cpp b/esphome/components/ln882x_spi_led_strip/led_strip.cpp index 42d4d831a3e..b00f99d1aea 100644 --- a/esphome/components/ln882x_spi_led_strip/led_strip.cpp +++ b/esphome/components/ln882x_spi_led_strip/led_strip.cpp @@ -110,10 +110,9 @@ void LN882xSPILEDStripLightOutput::setup() { // One block for the color, effect and DMA data. RAMAllocator allocator; - if (!this->internal_accessor_.allocate(allocator, this->num_leds_, this->dma_data_bytes_)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->internal_accessor_.allocate(allocator, this->num_leds_, this->dma_data_bytes_), + LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } this->dma_data_ = this->internal_accessor_.effect_data + this->num_leds_; spi_dma_init(this->pin_, this->prescaler_); diff --git a/esphome/components/modem/modem_component.cpp b/esphome/components/modem/modem_component.cpp index e4edcee5125..31899e9c775 100644 --- a/esphome/components/modem/modem_component.cpp +++ b/esphome/components/modem/modem_component.cpp @@ -327,10 +327,8 @@ void ModemComponent::setup() { break; } } - if (this->uart_port_ == UART_NUM_MAX) { - this->mark_failed(LOG_STR("No free UART port for modem")); + if (!this->setup_check(this->uart_port_ != UART_NUM_MAX, LOG_STR("No free UART port for modem"))) return; - } esp_netif_config_t netif_ppp_config = ESP_NETIF_DEFAULT_PPP(); this->ppp_netif_ = esp_netif_new(&netif_ppp_config); diff --git a/esphome/components/packet_interface/hdlc_reliability.cpp b/esphome/components/packet_interface/hdlc_reliability.cpp index cb7fc24b146..0979ef001dd 100644 --- a/esphome/components/packet_interface/hdlc_reliability.cpp +++ b/esphome/components/packet_interface/hdlc_reliability.cpp @@ -34,16 +34,12 @@ HdlcReliability::HdlcReliability(PacketInterface *lower) } void HdlcReliability::setup() { - if (this->max_packet_size_ == 0) { - this->mark_failed(LOG_STR("max_packet_size too small for the configured layers")); + if (!this->setup_check(this->max_packet_size_ != 0, LOG_STR("max_packet_size too small for the configured layers"))) return; - } RAMAllocator allocator; this->tx_slots_ = allocator.allocate(size_t{this->max_queue_length_} * this->max_packet_size_); - if (this->tx_slots_ == nullptr) { - this->mark_failed(LOG_STR("Not enough memory for the send queue")); + if (!this->setup_check(this->tx_slots_ != nullptr, LOG_STR("Not enough memory for the send queue"))) return; - } this->lower_->add_packet_interface_listener( [this](const PacketBuffer &data, const PacketMetaData &meta_data) { this->receive_(data, meta_data); }); } diff --git a/esphome/components/packet_interface/xxtea_encryption.cpp b/esphome/components/packet_interface/xxtea_encryption.cpp index 62afdad5a28..8057fc7dd1c 100644 --- a/esphome/components/packet_interface/xxtea_encryption.cpp +++ b/esphome/components/packet_interface/xxtea_encryption.cpp @@ -41,17 +41,13 @@ void XxteaEncryption::set_key(std::initializer_list key) { } void XxteaEncryption::setup() { - if (this->max_packet_size_ == 0) { - this->mark_failed(LOG_STR("max_packet_size too small for the configured layers")); + if (!this->setup_check(this->max_packet_size_ != 0, LOG_STR("max_packet_size too small for the configured layers"))) return; - } size_t words = this->lower_->get_max_packet_size() / 4; RAMAllocator allocator; this->tx_buffer_ = allocator.allocate(words * 2); - if (this->tx_buffer_ == nullptr) { - this->mark_failed(LOG_STR("Not enough memory for the packet buffers")); + if (!this->setup_check(this->tx_buffer_ != nullptr, LOG_STR("Not enough memory for the packet buffers"))) return; - } this->rx_buffer_ = this->tx_buffer_ + words; if (this->rolling_code_) { this->pref_ = global_preferences->make_preference(fnv1a_hash_extend(PREF_HASH, this->name_), true); diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index 99d0058e7f6..1c27126a2cf 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -51,10 +51,8 @@ void Pixoo::setup() { // packet, so the LED board completes its final DMA block. this->frame_size_ = this->data_size_ + PACKET_STATIC_LEN + DMA_CHUNK; - if (!this->buffer_.init(this->data_size_)) { - this->mark_failed(LOG_STR("Failed to allocate draw buffer")); + if (!this->setup_check(this->buffer_.init(this->data_size_), LOG_STR("Failed to allocate draw buffer"))) return; - } // The frame is shipped in one SPI transfer, so keep it in DMA-capable internal RAM. RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index c7d8fa713b1..d94497489a2 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -29,27 +29,23 @@ void RP2040PIOLEDStripLightOutput::dma_write_complete_handler() { void RP2040PIOLEDStripLightOutput::setup() { RAMAllocator allocator; - if (!this->internal_accessor_.allocate(allocator, this->num_leds_)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(this->internal_accessor_.allocate(allocator, this->num_leds_), + LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } // Initialize the PIO program // Select PIO instance to use (0 or 1) - if (this->pio_ == nullptr) { - this->mark_failed(LOG_STR("Failed to claim PIO instance")); + if (!this->setup_check(this->pio_ != nullptr, LOG_STR("Failed to claim PIO instance"))) return; - } // if there are multiple strips, we can reuse the same PIO program and save space // but there are only 4 state machines on each PIO so we can only have 4 strips per PIO uint offset = 0; - if (RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1] >= 4) { - this->mark_failed(LOG_STR("Too many instances of PIO program")); + if (!this->setup_check(RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1] < 4, + LOG_STR("Too many instances of PIO program"))) return; - } // keep track of how many instances of the PIO program are running on each PIO RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1]++; diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index abe72becb8c..7fdad74deaf 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -107,10 +107,8 @@ void SGP4xComponent::setup() { void SGP4xComponent::self_test_() { ESP_LOGD(TAG, "Starting self-test"); - if (!this->write_command(SGP4X_CMD_SELF_TEST)) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->setup_check(this->write_command(SGP4X_CMD_SELF_TEST))) return; - } this->set_timeout(SGP4X_SELF_TEST_TIME, [this]() { uint16_t reply = 0; diff --git a/esphome/components/sht3xd/sht3xd.cpp b/esphome/components/sht3xd/sht3xd.cpp index 5d05d2c8da9..d98b6b9fa0d 100644 --- a/esphome/components/sht3xd/sht3xd.cpp +++ b/esphome/components/sht3xd/sht3xd.cpp @@ -34,10 +34,9 @@ void SHT3XDComponent::setup() { } this->serial_number_ = (uint32_t(raw_serial_number[0]) << 16) | uint32_t(raw_serial_number[1]); - if (!this->write_command(this->heater_enabled_ ? SHT3XD_COMMAND_HEATER_ENABLE : SHT3XD_COMMAND_HEATER_DISABLE)) { - this->mark_failed(LOG_STR("Failed to set heater mode")); - return; - } + this->setup_check( + this->write_command(this->heater_enabled_ ? SHT3XD_COMMAND_HEATER_ENABLE : SHT3XD_COMMAND_HEATER_DISABLE), + LOG_STR("Failed to set heater mode")); } void SHT3XDComponent::dump_config() { diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 2ff194e0b26..76de3cae4a8 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -37,10 +37,8 @@ void SHT4XComponent::read_serial_number_() { void SHT4XComponent::setup() { auto err = this->write(nullptr, 0); - if (err != i2c::ERROR_OK) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + if (!this->setup_check(err == i2c::ERROR_OK)) return; - } this->read_serial_number_(); diff --git a/esphome/components/spi_led_strip/spi_led_strip.cpp b/esphome/components/spi_led_strip/spi_led_strip.cpp index 1a1d749dd28..e5bb13265b7 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.cpp +++ b/esphome/components/spi_led_strip/spi_led_strip.cpp @@ -10,10 +10,8 @@ SpiLedStrip::SpiLedStrip(uint16_t num_leds) : buffer_size_(num_leds * 4 + BUFFER void SpiLedStrip::setup() { RAMAllocator allocator; uint8_t *buf = allocator.allocate(this->buffer_size_ + this->num_leds_); - if (buf == nullptr) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); + if (!this->setup_check(buf != nullptr, LOG_STR(ESP_LOG_MSG_ALLOC_FAIL))) return; - } memset(buf, 0xFF, this->buffer_size_); memset(buf, 0, 4); this->internal_accessor_.color_data = buf + COLOR_DATA_OFFSET; diff --git a/esphome/components/statsd/statsd.cpp b/esphome/components/statsd/statsd.cpp index 1c9474bdb7b..ca24b53b603 100644 --- a/esphome/components/statsd/statsd.cpp +++ b/esphome/components/statsd/statsd.cpp @@ -14,10 +14,8 @@ ESPHOME_LOG_TAG(TAG, "statsD"); void StatsdComponent::setup() { this->sock_ = esphome::socket::socket_udp_send(AF_INET, 0); - if (!this->sock_) { - this->mark_failed(LOG_STR("Could not create socket")); + if (!this->setup_check(this->sock_ != nullptr, LOG_STR("Could not create socket"))) return; - } this->destination_.sin_family = AF_INET; this->destination_.sin_port = htons(this->port_); diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 62a739a9e63..f5943fe4a02 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -16,12 +16,8 @@ namespace esphome::tca9555 { ESPHOME_LOG_TAG(TAG, "tca9555"); void TCA9555Component::setup() { - if (!this->setup_check(this->read_gpio_modes_())) + if (!this->setup_check(this->read_gpio_modes_() && this->read_gpio_outputs_())) return; - if (!this->read_gpio_outputs_()) { - this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return; - } this->setup_interrupt_pin_(); } From e07be6bef591bf564e4c22a7f8411058fcc9667d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 10:09:31 -0600 Subject: [PATCH 05/55] [tfluna] Drop signal_strength device class from the signal strength sensor (#22260) --- esphome/components/tfluna/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/tfluna/sensor.py b/esphome/components/tfluna/sensor.py index 3fb9ed20a66..53663a5d737 100644 --- a/esphome/components/tfluna/sensor.py +++ b/esphome/components/tfluna/sensor.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_TEMPERATURE, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_DURATION, - DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, ENTITY_CATEGORY_DIAGNOSTIC, ICON_ARROW_EXPAND_VERTICAL, @@ -45,7 +44,6 @@ CONFIG_SCHEMA = { cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( icon=ICON_SIGNAL, accuracy_decimals=0, - device_class=DEVICE_CLASS_SIGNAL_STRENGTH, state_class=STATE_CLASS_MEASUREMENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), From bffe06211ab97dbe8e432387fd306ac9d01049f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 10:31:04 -0600 Subject: [PATCH 06/55] [core] Add push_front to StaticRingBuffer (#22262) --- esphome/core/helpers.h | 11 ++++ .../core/test_static_ring_buffer.cpp | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/components/core/test_static_ring_buffer.cpp diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 3adf38ce011..2608154d404 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -448,6 +448,17 @@ template class StaticRingBuffer { return true; } + /// Insert `value` so it is popped next; returns false when full. + bool push_front(const T &value) { + if (this->count_ >= N) { + return false; + } + this->head_ = (this->head_ + N - 1) % N; + this->data_[this->head_] = value; + ++this->count_; + return true; + } + void pop() { if (this->count_ > 0) { this->head_ = (this->head_ + 1) % N; diff --git a/tests/components/core/test_static_ring_buffer.cpp b/tests/components/core/test_static_ring_buffer.cpp new file mode 100644 index 00000000000..92fb27190a8 --- /dev/null +++ b/tests/components/core/test_static_ring_buffer.cpp @@ -0,0 +1,50 @@ +#include + +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +template static std::vector drain(StaticRingBuffer &buf) { + std::vector out; + while (!buf.empty()) { + out.push_back(buf.front()); + buf.pop(); + } + return out; +} + +TEST(StaticRingBufferPushFront, PoppedFirst) { + StaticRingBuffer buf; + buf.push(1); + buf.push(2); + EXPECT_TRUE(buf.push_front(0)); + EXPECT_EQ(buf.size(), 3); + EXPECT_EQ(drain(buf), (std::vector{0, 1, 2})); +} + +TEST(StaticRingBufferPushFront, WrapsBelowZeroAndRejectsWhenFull) { + StaticRingBuffer buf; + EXPECT_TRUE(buf.push_front(3)); + EXPECT_TRUE(buf.push_front(2)); + EXPECT_TRUE(buf.push_front(1)); + EXPECT_TRUE(buf.push(4)); + EXPECT_FALSE(buf.push_front(0)); + EXPECT_EQ(drain(buf), (std::vector{1, 2, 3, 4})); +} + +TEST(StaticRingBufferPushFront, AfterTailWraps) { + StaticRingBuffer buf; + buf.push(1); + buf.push(2); + buf.push(3); + buf.pop(); + buf.pop(); + buf.push(4); + buf.push(5); // tail wrapped to index 1, head at 2 + EXPECT_TRUE(buf.push_front(0)); + EXPECT_EQ(drain(buf), (std::vector{0, 3, 4, 5})); +} + +} // namespace esphome::core::testing From c94cb79f658e40356e376684145e7c7f59f05550 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 10:31:04 -0600 Subject: [PATCH 07/55] [it8951] Use StaticRingBuffer for the op queue (#22263) --- esphome/components/it8951/it8951.cpp | 4 +- esphome/components/it8951/it8951.h | 56 +--------------------------- 2 files changed, 4 insertions(+), 56 deletions(-) diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 094a49faf01..27db82d557f 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -19,7 +19,7 @@ static constexpr uint32_t MAX_TRANSFER_TIME_MS = 20; // --- Loop / scheduling ------------------------------------------------------- void IT8951Display::enqueue_(OpType type, uint16_t a, uint16_t b) { - if (!this->queue_.push_back(Op{type, a, b})) { + if (!this->queue_.push(Op{type, a, b})) { ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), static_cast(type)); } @@ -74,7 +74,7 @@ void IT8951Display::loop() { return; } - this->queue_.pop_front(); + this->queue_.pop(); this->process_op_(queued_op); } diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index 2b5f8d299fc..6f7088b7e3b 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -15,59 +15,7 @@ namespace esphome::it8951 { using namespace display; -// --- Bounded op queue -------------------------------------------------------- -// Fixed-capacity ring buffer used by the loop scheduler. Replaces std::deque -// to comply with ESPHome's STL container guidelines (std::deque allocates in -// 512-byte blocks regardless of element size). Size analysis: the deepest -// observed scenario is UPDATE_REFRESH (10 enqueued ops) + CHECK_LUT_IDLE's -// 5 push_front rescheduling = 14 simultaneous entries. We use 32 for a -// comfortable margin while keeping RAM cost low (~192 bytes per instance vs -// 512+ bytes for std::deque). -template class StaticOpQueue { - public: - bool empty() const { return this->count_ == 0; } - size_t size() const { return this->count_; } - static constexpr size_t capacity() { return N; } - - bool push_back(const T &value) { - if (this->count_ >= N) - return false; - this->data_[(this->head_ + this->count_) % N] = value; - ++this->count_; - return true; - } - - bool push_front(const T &value) { - if (this->count_ >= N) - return false; - this->head_ = (this->head_ + N - 1) % N; - this->data_[this->head_] = value; - ++this->count_; - return true; - } - - void pop_front() { - if (this->count_ == 0) - return; - this->head_ = (this->head_ + 1) % N; - --this->count_; - } - - const T &front() const { return this->data_[this->head_]; } - T &front() { return this->data_[this->head_]; } - - void clear() { - this->head_ = 0; - this->count_ = 0; - } - - private: - T data_[N]{}; - size_t head_{0}; - size_t count_{0}; -}; - -// Op queue capacity. See StaticOpQueue comment for sizing analysis. +// Deepest observed: UPDATE_REFRESH (10 ops) + 5 CHECK_LUT_IDLE reschedules = 14; 32 for margin static constexpr size_t OP_QUEUE_SIZE = 32; // --- Op queue --------------------------------------------------------------- @@ -278,7 +226,7 @@ class IT8951Display : public Display, // --- State --- static constexpr uint32_t BUSY_TIMEOUT_MS = 5000; - StaticOpQueue queue_; + StaticRingBuffer queue_; Phase phase_{Phase::IDLE}; uint32_t delay_until_{0}; uint32_t phase_started_at_{0}; From 3b862f03f1711d60a29ce66d9a9792961f055fce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 10:31:12 -0600 Subject: [PATCH 08/55] [rp2040_pio_led_strip] Load each PIO program once per PIO without a heap map (#22151) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .../rp2040_pio_led_strip/led_strip.cpp | 37 +++++----- .../rp2040_pio_led_strip/led_strip.h | 23 ++---- .../components/rp2040_pio_led_strip/light.py | 71 +++++++------------ .../rp2040_pio_led_strip/common.yaml | 27 +++++++ 4 files changed, 81 insertions(+), 77 deletions(-) diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index d94497489a2..368b33b3410 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -39,25 +39,30 @@ void RP2040PIOLEDStripLightOutput::setup() { if (!this->setup_check(this->pio_ != nullptr, LOG_STR("Failed to claim PIO instance"))) return; - // if there are multiple strips, we can reuse the same PIO program and save space - // but there are only 4 state machines on each PIO so we can only have 4 strips per PIO - uint offset = 0; - - if (!this->setup_check(RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1] < 4, - LOG_STR("Too many instances of PIO program"))) + // There are only 4 state machines on each PIO, so at most 4 strips per PIO + auto &loaded = RP2040PIOLEDStripLightOutput::loaded_programs[pio_get_index(this->pio_)]; + if (!this->setup_check(loaded.strips < NUM_PIO_STATE_MACHINES, LOG_STR("Too many instances of PIO program"))) return; - // keep track of how many instances of the PIO program are running on each PIO - RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1]++; - // if there are multiple strips of the same chipset, we can reuse the same PIO program and save space - if (RP2040PIOLEDStripLightOutput::conf_count[this->chipset_]) { - offset = RP2040PIOLEDStripLightOutput::chipset_offsets[this->chipset_]; - } else { - // Load the assembled program into the PIO and get its location in the PIO's instruction memory and save it - offset = pio_add_program(this->pio_, this->program_); - RP2040PIOLEDStripLightOutput::chipset_offsets[this->chipset_] = offset; - RP2040PIOLEDStripLightOutput::conf_count[this->chipset_] = true; + // Strips with the same program on the same PIO share one copy of it in instruction memory + uint offset = 0; + for (uint8_t i = 0; i < NUM_PIO_STATE_MACHINES; i++) { + if (loaded.program[i] == nullptr) { + // The SDK panics when instruction memory is full + if (!this->setup_check(pio_can_add_program(this->pio_, this->program_), + LOG_STR("No PIO instruction memory left"))) + return; + offset = pio_add_program(this->pio_, this->program_); + loaded.program[i] = this->program_; + loaded.offset[i] = offset; + break; + } + if (loaded.program[i] == this->program_) { + offset = loaded.offset[i]; + break; + } } + loaded.strips++; // Configure the state machine's PIO, and start it this->sm_ = pio_claim_unused_sm(this->pio_, true); diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index ff291da0048..d8c02af96be 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -15,19 +15,9 @@ #include #include #include -#include namespace esphome::rp2040_pio_led_strip { -enum Chipset : uint8_t { - CHIPSET_WS2812, - CHIPSET_WS2812B, - CHIPSET_SK6812, - CHIPSET_SM16703, - CHIPSET_APA102, - CHIPSET_CUSTOM = 0xFF, -}; - using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { @@ -50,7 +40,6 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { void set_program(const pio_program_t *program) { this->program_ = program; } void set_init_function(init_fn init) { this->init_ = init; } - void set_chipset(Chipset chipset) { this->chipset_ = chipset; }; void clear_effect_data() override { this->internal_accessor_.clear_effect_data(this->num_leds_); } void dump_config() override; @@ -70,17 +59,19 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { uint dma_chan_; dma_channel_config dma_config_; - Chipset chipset_{CHIPSET_CUSTOM}; - float max_refresh_rate_; const pio_program_t *program_; init_fn init_; private: - inline static int num_instance[2]; - inline static std::map conf_count; - inline static std::map chipset_offsets; + // Programs loaded into each PIO; strips with the same timings share one program pointer + struct LoadedPrograms { + const pio_program_t *program[NUM_PIO_STATE_MACHINES]; + uint8_t offset[NUM_PIO_STATE_MACHINES]; + uint8_t strips; + }; + inline static LoadedPrograms loaded_programs[2]; inline static bool dma_chan_active[12]; inline static struct semaphore dma_write_complete_sem[12]; }; diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 53a9d438292..5d0508caf87 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -7,7 +7,6 @@ from esphome.components.const import CONF_CHANNEL_COLORS import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, - CONF_ID, CONF_IS_RGBW, CONF_NUM_LEDS, CONF_OUTPUT_ID, @@ -15,7 +14,6 @@ from esphome.const import ( CONF_RGB_ORDER, ) from esphome.types import ConfigType -from esphome.util import _LOGGER def get_nops(timing: float) -> list[float | str]: @@ -39,7 +37,7 @@ def get_nops(timing: float) -> list[float | str]: return nops -def generate_assembly_code(id: str, t0h: int, t0l: int, t1h: int, t1l: int) -> str: +def generate_assembly_code(name: str, t0h: int, t0l: int, t1h: int, t1l: int) -> str: """ Generate assembly code with the given timing values. """ @@ -62,11 +60,11 @@ def generate_assembly_code(id: str, t0h: int, t0l: int, t1h: int, t1l: int) -> s % c-sdk {{ #include "hardware/clocks.h" -static inline void rp2040_pio_led_strip_driver_{id}_init(PIO pio, uint sm, uint offset, uint pin, float freq) {{ +static inline void rp2040_pio_led_strip_driver_{name}_init(PIO pio, uint sm, uint offset, uint pin, float freq) {{ pio_gpio_init(pio, pin); pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true); - pio_sm_config c = rp2040_pio_led_strip_{id}_program_get_default_config(offset); + pio_sm_config c = rp2040_pio_led_strip_{name}_program_get_default_config(offset); sm_config_set_set_pins(&c, pin, 1); sm_config_set_out_shift(&c, false, true, 8); sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX); @@ -84,7 +82,7 @@ static inline void rp2040_pio_led_strip_driver_{id}_init(PIO pio, uint sm, uint }} %}}""" - assembly_template = f""".program rp2040_pio_led_strip_{id} + assembly_template = f""".program rp2040_pio_led_strip_{name} .wrap_target awaiting_data: @@ -141,10 +139,6 @@ RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( "RP2040PIOLEDStripLightOutput", light.AddressableLight ) -Chipset = rp2040_pio_led_strip_ns.enum("Chipset") - -CHIPSETS = Chipset.options("WS2812", "WS2812B", "SK6812", "SM16703", prefix="CHIPSET_") - @dataclass class LEDStripTimings: @@ -190,7 +184,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), cv.Optional(CONF_IS_RGBW): cv.boolean, cv.Required(CONF_PIO): cv.one_of(0, 1, int=True), - cv.Optional(CONF_CHIPSET): cv.enum(CHIPSETS, upper=True), + cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSET_TIMINGS, upper=True), cv.Inclusive( CONF_BIT0_HIGH, "custom", @@ -218,7 +212,6 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) - id = config[CONF_ID].id await light.register_light(var, config) await cg.register_component(var, config) @@ -230,40 +223,28 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_pio(config[CONF_PIO])) - cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program"))) - cg.add( - var.set_init_function( - cg.RawExpression(f"rp2040_pio_led_strip_driver_{id}_init") - ) - ) - - key = f"led_strip_{id}" if chipset := config.get(CONF_CHIPSET): - cg.add(var.set_chipset(chipset)) - _LOGGER.info("Generating PIO assembly code") - rp2.add_pio_file( - __name__, - key, - generate_assembly_code( - id, - CHIPSET_TIMINGS[chipset].T0H, - CHIPSET_TIMINGS[chipset].T0L, - CHIPSET_TIMINGS[chipset].T1H, - CHIPSET_TIMINGS[chipset].T1L, - ), - ) + timings = CHIPSET_TIMINGS[chipset] else: - cg.add(var.set_chipset(Chipset.CHIPSET_CUSTOM)) - _LOGGER.info("Generating custom PIO assembly code") - rp2.add_pio_file( - __name__, - key, - generate_assembly_code( - id, - time_to_cycles(config[CONF_BIT0_HIGH]), - time_to_cycles(config[CONF_BIT0_LOW]), - time_to_cycles(config[CONF_BIT1_HIGH]), - time_to_cycles(config[CONF_BIT1_LOW]), - ), + timings = LEDStripTimings( + time_to_cycles(config[CONF_BIT0_HIGH]), + time_to_cycles(config[CONF_BIT0_LOW]), + time_to_cycles(config[CONF_BIT1_HIGH]), + time_to_cycles(config[CONF_BIT1_LOW]), ) + # Strips with the same timings share one program, so the C++ side loads it once per PIO + name = f"{timings.T0H}_{timings.T0L}_{timings.T1H}_{timings.T1L}" + cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{name}_program"))) + cg.add( + var.set_init_function( + cg.RawExpression(f"rp2040_pio_led_strip_driver_{name}_init") + ) + ) + rp2.add_pio_file( + __name__, + f"led_strip_{name}", + generate_assembly_code( + name, timings.T0H, timings.T0L, timings.T1H, timings.T1L + ), + ) diff --git a/tests/components/rp2040_pio_led_strip/common.yaml b/tests/components/rp2040_pio_led_strip/common.yaml index 1cb5fe0737b..60b810de9b2 100644 --- a/tests/components/rp2040_pio_led_strip/common.yaml +++ b/tests/components/rp2040_pio_led_strip/common.yaml @@ -16,3 +16,30 @@ light: bit0_low: 1.2us bit1_high: .69us bit1_low: .4us + # Same chipset as the first strip, on the other PIO + - platform: rp2040_pio_led_strip + id: led_strip_ws2812_pio1 + pin: 6 + num_leds: 60 + pio: 1 + channel_colors: GRB + chipset: WS2812 + # Custom timings that differ from the first custom strip + - platform: rp2040_pio_led_strip + id: led_strip_custom_timings_2 + pin: 7 + num_leds: 60 + pio: 0 + channel_colors: GRB + bit0_high: .4us + bit0_low: .85us + bit1_high: .8us + bit1_low: .45us + # Same chipset and PIO as the first strip, so it reuses the loaded program + - platform: rp2040_pio_led_strip + id: led_strip_ws2812_pio0_second + pin: 8 + num_leds: 60 + pio: 0 + channel_colors: GRB + chipset: WS2812 From 20bd5f53168953d0aca18f84a97d642fcebab222 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 10:32:03 -0600 Subject: [PATCH 09/55] [rp2040_pio_led_strip] Fail setup instead of panicking when no state machine or DMA channel is free (#22152) --- .../components/rp2040_pio_led_strip/led_strip.cpp | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index 368b33b3410..117bbc281a7 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -65,20 +65,21 @@ void RP2040PIOLEDStripLightOutput::setup() { loaded.strips++; // Configure the state machine's PIO, and start it - this->sm_ = pio_claim_unused_sm(this->pio_, true); - if (this->sm_ < 0) { - // in theory this code should never be reached + int sm = pio_claim_unused_sm(this->pio_, false); + if (sm < 0) { this->mark_failed(LOG_STR("Failed to claim PIO state machine")); return; } + this->sm_ = sm; - // Initalize the DMA channel (Note: There are 12 DMA channels and 8 state machines so we won't run out) - - this->dma_chan_ = dma_claim_unused_channel(true); - if (this->dma_chan_ < 0) { + // Other code may already hold every DMA channel + int dma_chan = dma_claim_unused_channel(false); + if (dma_chan < 0) { + pio_sm_unclaim(this->pio_, this->sm_); this->mark_failed(LOG_STR("Failed to claim DMA channel")); return; } + this->dma_chan_ = dma_chan; // Mark the DMA channel as active RP2040PIOLEDStripLightOutput::dma_chan_active[this->dma_chan_] = true; From d9275977710008798121e7b91933cf8ad69b3dd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 10:44:16 -0600 Subject: [PATCH 10/55] [rp2040_pio_led_strip] Size the DMA channel arrays for RP2350 (#22153) --- esphome/components/rp2040_pio_led_strip/led_strip.cpp | 2 +- esphome/components/rp2040_pio_led_strip/led_strip.h | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index 117bbc281a7..96933662920 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -19,7 +19,7 @@ ESPHOME_LOG_TAG(TAG, "rp2040_pio_led_strip"); // DMA interrupt service routine void RP2040PIOLEDStripLightOutput::dma_write_complete_handler() { uint32_t channel = dma_hw->ints0; - for (uint dma_chan = 0; dma_chan < 12; ++dma_chan) { + for (uint dma_chan = 0; dma_chan < NUM_DMA_CHANNELS; ++dma_chan) { if (RP2040PIOLEDStripLightOutput::dma_chan_active[dma_chan] && (channel & (1u << dma_chan))) { dma_hw->ints0 = (1u << dma_chan); // Clear the interrupt sem_release(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[dma_chan]); // Handle the interrupt diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index d8c02af96be..8f76a908ce6 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -72,8 +72,8 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { uint8_t strips; }; inline static LoadedPrograms loaded_programs[2]; - inline static bool dma_chan_active[12]; - inline static struct semaphore dma_write_complete_sem[12]; + inline static bool dma_chan_active[NUM_DMA_CHANNELS]; + inline static struct semaphore dma_write_complete_sem[NUM_DMA_CHANNELS]; }; } // namespace esphome::rp2040_pio_led_strip From 1854bd12bbe8684eee855e23b1015c11b0637727 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 10:45:04 -0600 Subject: [PATCH 11/55] [rp2040_pio_led_strip] Compute the PIO clock divider with its fraction (#22265) --- .../components/rp2040_pio_led_strip/light.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 5d0508caf87..e75d5ff616b 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -37,6 +37,10 @@ def get_nops(timing: float) -> list[float | str]: return nops +# The state machine clock every timing is counted in +PIO_CLOCK_HZ = 57_500_000 + + def generate_assembly_code(name: str, t0h: int, t0l: int, t1h: int, t1l: int) -> str: """ Generate assembly code with the given timing values. @@ -69,12 +73,10 @@ static inline void rp2040_pio_led_strip_driver_{name}_init(PIO pio, uint sm, uin sm_config_set_out_shift(&c, false, true, 8); sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX); - // target frequency is 57.5MHz - long clk = clock_get_hz(clk_sys); - long target_freq = 57500000; - int n = 2; - int f = round(((clk / target_freq) - n ) * 256); - sm_config_set_clkdiv_int_frac(&c, n, f); + // The divider is 8.8 fixed point, rounded to the nearest step + uint32_t clk = clock_get_hz(clk_sys); + uint32_t div = (uint32_t) (((uint64_t) clk * 256 + {PIO_CLOCK_HZ // 2}) / {PIO_CLOCK_HZ}); + sm_config_set_clkdiv_int_frac(&c, div >> 8, div & 0xFF); pio_sm_init(pio, sm, offset, &c); @@ -124,8 +126,7 @@ writezero: def time_to_cycles(time_us: float) -> int: - cycles_per_us = 57.5 - return round(float(time_us) * cycles_per_us) + return round(float(time_us) * (PIO_CLOCK_HZ / 1_000_000)) CONF_PIO = "pio" From 58419f22078d7314d363c4e45c980a9db361aa92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 11:58:57 -0600 Subject: [PATCH 12/55] [canbus] Register on_frame automations as callbacks instead of trigger components (#21795) --- esphome/components/canbus/__init__.py | 61 +++++++++++++++------------ esphome/components/canbus/canbus.cpp | 19 --------- esphome/components/canbus/canbus.h | 27 +----------- 3 files changed, 34 insertions(+), 73 deletions(-) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 27220051a77..62796a0752c 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -5,7 +5,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM, CONF_TRIGGER_ID +from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType @@ -26,6 +26,12 @@ CONF_ON_FRAME = "on_frame" CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop" DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h +# All 29 id bits; received ids never use more, so on_frame skips the & for this mask +CAN_ID_MASK_ALL = 0x1FFFFFFF + + +def _flag(name: str, value: bool) -> str: + return name if value else f"!{name}" def validate_id(config: ConfigType) -> ConfigType: @@ -49,11 +55,7 @@ def validate_raw_data(value: Any) -> bytes | list: canbus_ns = cg.esphome_ns.namespace("canbus") CanbusComponent = canbus_ns.class_("CanbusComponent", cg.Component) -CanbusTrigger = canbus_ns.class_( - "CanbusTrigger", - automation.Trigger.template(cg.std_vector.template(cg.uint8), cg.uint32), - cg.Component, -) +_FRAME_DATA = cg.std_vector.template(cg.uint8) CanSpeed = canbus_ns.enum("CAN_SPEED") CAN_SPEEDS = CanSpeed.options( @@ -93,7 +95,7 @@ def get_rate(value: str) -> int: CANBUS_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(CanbusComponent), - cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), + cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=CAN_ID_MASK_ALL), cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum( CAN_SPEEDS, upper=True ), @@ -103,10 +105,9 @@ CANBUS_SCHEMA = cv.Schema( ): cv.positive_not_null_int, cv.Optional(CONF_ON_FRAME): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger), - cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), - cv.Optional(CONF_CAN_ID_MASK, default=0x1FFFFFFF): cv.int_range( - min=0, max=0x1FFFFFFF + cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=CAN_ID_MASK_ALL), + cv.Optional(CONF_CAN_ID_MASK, default=CAN_ID_MASK_ALL): cv.int_range( + min=0, max=CAN_ID_MASK_ALL ), cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, cv.Optional(CONF_REMOTE_TRANSMISSION_REQUEST): cv.boolean, @@ -130,27 +131,31 @@ async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: cg.add(var.set_max_frames_per_loop(max_frames)) for conf in config.get(CONF_ON_FRAME, []): - can_id = conf[CONF_CAN_ID] - can_id_mask = conf[CONF_CAN_ID_MASK] - ext_id = conf[CONF_USE_EXTENDED_ID] - trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], var, can_id, can_id_mask, ext_id - ) - if CONF_REMOTE_TRANSMISSION_REQUEST in conf: - cg.add( - trigger.set_remote_transmission_request( - conf[CONF_REMOTE_TRANSMISSION_REQUEST] - ) - ) - await cg.register_component(trigger, conf) - await automation.build_automation( - trigger, + can_id = f"0x{conf[CONF_CAN_ID]:X}" + if (mask := conf[CONF_CAN_ID_MASK]) != CAN_ID_MASK_ALL: + checks = [f"(can_id & 0x{mask:X}) == {can_id}"] + else: + checks = [f"can_id == {can_id}"] + checks.append(_flag("extended_id", conf[CONF_USE_EXTENDED_ID])) + if (rtr := conf.get(CONF_REMOTE_TRANSMISSION_REQUEST)) is not None: + checks.append(_flag("rtr", rtr)) + await automation.build_callback_automation( + var, + "add_callback", [ - (cg.std_vector.template(cg.uint8), "x"), + (_FRAME_DATA, "x"), (cg.uint32, "can_id"), (cg.bool_, "remote_transmission_request"), ], conf, + params=[ + (cg.uint32, "can_id"), + (cg.bool_, "extended_id"), + (cg.bool_, "rtr"), + (_FRAME_DATA.operator("const").operator("ref"), "data"), + ], + forward=["data", "can_id", "rtr"], + when=" && ".join(checks), ) @@ -174,7 +179,7 @@ async def register_canbus( CANBUS_SEND_ACTION_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(CONF_CANBUS_ID): cv.use_id(CanbusComponent), - cv.Optional(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), + cv.Optional(CONF_CAN_ID): cv.int_range(min=0, max=CAN_ID_MASK_ALL), cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, cv.Optional(CONF_REMOTE_TRANSMISSION_REQUEST, default=False): cv.boolean, # One classic CAN frame diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index dfe3ab9fd31..840b163976c 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -56,15 +56,6 @@ canbus::Error Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remo return error; } -void Canbus::add_trigger(CanbusTrigger *trigger) { - if (trigger->use_extended_id_) { - ESP_LOGVV(TAG, "add trigger for extended canid=0x%08" PRIx32, trigger->can_id_); - } else { - ESP_LOGVV(TAG, "add trigger for std canid=0x%03" PRIx32, trigger->can_id_); - } - this->triggers_.push_back(trigger); -}; - void Canbus::loop() { uint32_t now = App.get_loop_component_start_time(); if ((now - this->last_event_check_time_) >= EVENT_CHECK_INTERVAL_MS) { @@ -97,16 +88,6 @@ void Canbus::loop() { this->callback_manager_(can_message.can_id, can_message.use_extended_id, can_message.remote_transmission_request, data); - - // fire all triggers - for (auto *trigger : this->triggers_) { - if ((trigger->can_id_ == (can_message.can_id & trigger->can_id_mask_)) && - (trigger->use_extended_id_ == can_message.use_extended_id) && - (!trigger->remote_transmission_request_.has_value() || - trigger->remote_transmission_request_.value() == can_message.remote_transmission_request)) { - trigger->trigger(data, can_message.can_id, can_message.remote_transmission_request); - } - } } if (message_counter == this->max_frames_per_loop_) { diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 6c12768273d..b0b7c2249be 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -42,7 +42,6 @@ enum CanSpeed : uint8_t { CAN_1000KBPS }; -class CanbusTrigger; template class CanbusSendAction; /* CAN payload length definitions according to ISO 11898-1 */ @@ -58,7 +57,7 @@ So currently the flag is passed to and from the hardware but currently ignored t struct CanFrame { bool use_extended_id = false; bool remote_transmission_request = false; - uint32_t can_id; /* 29 or 11 bit CAN_ID */ + uint32_t can_id; /* 29 or 11 bit CAN_ID; drivers clear bits 29-31 (on_frame relies on it) */ uint8_t can_data_length_code; /* frame payload length in byte (0 .. CAN_MAX_DATA_LENGTH) */ uint8_t data[CAN_MAX_DATA_LENGTH] __attribute__((aligned(8))); }; @@ -107,7 +106,6 @@ class Canbus : public Component { void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } virtual CanStatus get_status() { return {}; } - void add_trigger(CanbusTrigger *trigger); /** * Add a callback to be called when a CAN message is received. All received messages * are passed to the callback without filtering. @@ -122,7 +120,6 @@ class Canbus : public Component { protected: template friend class CanbusSendAction; - std::vector triggers_{}; uint32_t can_id_; uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py @@ -184,26 +181,4 @@ template class CanbusSendAction final : public Action, pu bool remote_transmission_request_{false}; }; -class CanbusTrigger final : public Trigger, uint32_t, bool>, public Component { - friend class Canbus; - - public: - explicit CanbusTrigger(Canbus *parent, const std::uint32_t can_id, const std::uint32_t can_id_mask, - const bool use_extended_id) - : parent_(parent), can_id_(can_id), can_id_mask_(can_id_mask), use_extended_id_(use_extended_id) {}; - - void set_remote_transmission_request(bool remote_transmission_request) { - this->remote_transmission_request_ = remote_transmission_request; - } - - void setup() override { this->parent_->add_trigger(this); } - - protected: - Canbus *parent_; - uint32_t can_id_; - uint32_t can_id_mask_; - bool use_extended_id_; - optional remote_transmission_request_{}; -}; - } // namespace esphome::canbus From 55cb8ea03d9f4c69041fdd442bff73f5ebd642d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 12:06:39 -0600 Subject: [PATCH 13/55] [core] Let a byte payload lambda return a fixed-size buffer (#22043) --- esphome/automation.py | 8 ++- esphome/core/automation.h | 54 ++++++++++++------- .../core/test_templatable_bytes.cpp | 35 ++++++++++++ tests/unit_tests/test_automation.py | 17 ++++++ 4 files changed, 93 insertions(+), 21 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index e1cea9281d8..0d8954cc09d 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -45,6 +45,9 @@ CONF_CHOOSE = "choose" CONF_CHOICE_ID = "choice_id" +_BYTE_VECTOR = cg.std_vector.template(cg.uint8) + + def progmem_bytes(name: str, data: bytes | list[int]) -> MockObj: """Shared PROGMEM table for constant bytes; equal payloads share one, empty is nullptr.""" if not data: @@ -60,10 +63,11 @@ async def templatable_bytes( set_template: MockObj, set_static: MockObj, table_name: str, + return_type: MockObj = _BYTE_VECTOR, ) -> None: """Set a TemplatableBytes: a lambda via set_template, constant bytes via set_static.""" if cg.is_template(value): - fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8)) + fn = await cg.templatable(value, args, return_type) cg.add(set_template(fn)) else: cg.add(set_static(*_static_bytes(table_name, value))) @@ -884,7 +888,7 @@ async def _render_bytes( return f"{receiver}{target.format(data, parent=parent)};" if isinstance(value, Lambda): - call = await _call_user_lambda(value, args, cg.std_vector.template(cg.uint8)) + call = await _call_user_lambda(value, args, _BYTE_VECTOR) return [ "{", f" const std::vector esphome__bytes = {call};", diff --git a/esphome/core/automation.h b/esphome/core/automation.h index d00acd0a6d2..6ee72d00cc4 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -6,6 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" +#include #include #include #include @@ -82,9 +83,9 @@ template class TemplatableFn { /// Byte payload that is either a stateless lambda or a static table, which may be in PROGMEM. /// 8 bytes on 32-bit; codegen stores constant payloads as shared flash tables. -template class TemplatableBytes { +template class TemplatableBuffer { public: - void set_template(std::vector (*func)(Ts...)) { + void set_template(B (*func)(Ts...)) { this->code_.func = func; this->len_ = -1; } @@ -97,53 +98,68 @@ template class TemplatableBytes { const uint8_t *data() const { return this->code_.data; } /// Only valid when is_static(). size_t size() const { return static_cast(this->len_); } - std::vector value(const Ts &...x) const { + B value(const Ts &...x) const { if (this->len_ < 0) return this->code_.func(x...); - return to_vector(this->code_.data, this->size()); + return to_buffer(this->code_.data, this->size()); } - /// Calls fn(const uint8_t *data, size_t len) with the payload readable from RAM: a lambda's vector, a static + /// Calls fn(const uint8_t *data, size_t len) with the payload readable from RAM: the lambda's buffer, a static /// table directly, or on ESP8266 a copy of the PROGMEM table (on the stack up to N bytes). template void visit(F &&fn, const Ts &...x) const { if (this->len_ < 0) { - const std::vector bytes = this->code_.func(x...); + const B bytes = this->code_.func(x...); fn(bytes.data(), bytes.size()); return; } + const size_t len = this->static_len_(); #ifdef USE_ESP8266 - SmallBufferWithHeapFallback buf(this->size()); - if (this->len_ != 0) - progmem_memcpy(buf.get(), this->code_.data, this->size()); - fn(buf.get(), this->size()); + SmallBufferWithHeapFallback buf(len); + if (len != 0) + progmem_memcpy(buf.get(), this->code_.data, len); + fn(buf.get(), len); #else - fn(this->code_.data, this->size()); + fn(this->code_.data, len); #endif } protected: - static std::vector to_vector(const uint8_t *data, size_t len) { - std::vector out(len); + // A fixed-capacity B keeps only what fits, so visit() passes what value() returns + size_t static_len_() const { + if constexpr (requires { B::capacity(); }) { + return std::min(this->size(), B::capacity()); + } else { + return this->size(); + } + } + + static B to_buffer(const uint8_t *data, size_t len) { + B out; + out.resize(len); // An empty payload is (nullptr, 0), and memcpy from nullptr is undefined even for zero bytes. - if (len != 0) - progmem_memcpy(out.data(), data, len); // byte loads from flash fault on ESP8266 + if (out.size() != 0) + progmem_memcpy(out.data(), data, out.size()); // byte loads from flash fault on ESP8266 return out; } union { - std::vector (*func)(Ts...); + B (*func)(Ts...); const uint8_t *data; } code_{}; int32_t len_{-1}; // -1: lambda, otherwise the length of the static table }; -#define TEMPLATABLE_BYTES(name) \ +template using TemplatableBytes = TemplatableBuffer, Ts...>; + +#define TEMPLATABLE_BUFFER(name, ...) \ protected: \ - TemplatableBytes name##_{}; \ + TemplatableBuffer<__VA_ARGS__, Ts...> name##_{}; \ \ public: \ - void set_##name##_template(std::vector (*func)(Ts...)) { this->name##_.set_template(func); } \ + void set_##name##_template(__VA_ARGS__ (*func)(Ts...)) { this->name##_.set_template(func); } \ void set_##name##_static(const uint8_t *data, uint16_t len) { this->name##_.set_static(data, len); } +#define TEMPLATABLE_BYTES(name) TEMPLATABLE_BUFFER(name, std::vector) + // Forward declaration for TemplatableValue (string specialization needs it) template class TemplatableValue; diff --git a/tests/components/core/test_templatable_bytes.cpp b/tests/components/core/test_templatable_bytes.cpp index b75b8c1b556..eae17d25b4d 100644 --- a/tests/components/core/test_templatable_bytes.cpp +++ b/tests/components/core/test_templatable_bytes.cpp @@ -1,6 +1,7 @@ #include #include #include "esphome/core/automation.h" +#include "esphome/core/helpers.h" namespace esphome::testing { @@ -40,4 +41,38 @@ TEST(TemplatableBytesTest, VisitLambdaWithArgument) { EXPECT_EQ(seen, (std::vector(4, 7))); } +using Fixed = StaticVector; + +static Fixed pair(int first) { return {static_cast(first), 9}; } + +TEST(TemplatableBytesTest, FixedBufferLambda) { + TemplatableBuffer bytes; + bytes.set_template(pair); + std::vector seen; + bytes.visit([&](const uint8_t *data, size_t len) { seen.assign(data, data + len); }, 4); + EXPECT_EQ(seen, (std::vector{4, 9})); +} + +TEST(TemplatableBytesTest, FixedBufferKeepsWhatFits) { + TemplatableBuffer bytes; + bytes.set_static(PAYLOAD, sizeof(PAYLOAD)); + const Fixed value = bytes.value(); + EXPECT_EQ(std::vector(value.begin(), value.end()), (std::vector{1, 2})); + std::vector seen; + bytes.visit([&](const uint8_t *data, size_t len) { seen.assign(data, data + len); }); + EXPECT_EQ(seen, (std::vector{1, 2})); +} + +template class FixedHolder { + TEMPLATABLE_BUFFER(data, StaticVector) + + size_t size(const Ts &...x) const { return this->data_.value(x...).size(); } +}; + +TEST(TemplatableBytesTest, BufferMacroTakesTypeWithComma) { + FixedHolder holder; + holder.set_data_template(pair); + EXPECT_EQ(holder.size(1), 2u); +} + } // namespace esphome::testing diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index b9caa00c3bc..691c8c32cfa 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1783,6 +1783,23 @@ async def test_templatable_bytes_lambda_payload() -> None: assert "set_code_static" not in text +@pytest.mark.asyncio +async def test_templatable_bytes_lambda_return_type() -> None: + CORE.config = {} + var = MockObj("act", "->") + await templatable_bytes( + Lambda("return {0x01};"), + [], + var.set_code_template, + var.set_code_static, + "payload", + return_type=MockObj("ns::Buffer"), + ) + text = CORE.cpp_main_section + assert "-> ns::Buffer" in text + assert "std::vector" not in text + + @pytest.mark.asyncio async def test_templatable_bytes_rejects_oversized_payload() -> None: CORE.config = {} From 76a0c75d2711d2e27db32c13d6cdf1f6dc9bbf2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 12:11:22 -0600 Subject: [PATCH 14/55] [mdns] Bump espressif/mdns to 1.15.0 (#22267) --- esphome/components/esp32/__init__.py | 6 +++--- esphome/components/mdns/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 739bfab91cb..0a17832e85c 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -236,15 +236,15 @@ COMPILER_OPTIMIZATIONS = { # # Note: excluding a component only removes it from the initial build set. # ESP-IDF's requirement expansion adds an excluded component back when any -# component still in the build REQUIRES it (e.g. espressif/mdns pulls -# "console" back in, esp_http_client pulls "tcp_transport" back in), so +# component still in the build REQUIRES it (e.g. esp_http_client pulls +# "tcp_transport" back in), so # exclusions here are safe for such components and simply become no-ops in # builds that need them. DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "app_trace", # CPU trace/SystemView support - unused by ESPHome "bt", # Bluetooth stack - re-included by request_bluetooth(); its REQUIRES pulls the WiFi stack back "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing - "console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured + "console", # Console REPL - unused by ESPHome; espressif/mdns needs it only for its CLI, which mdns disables "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers "esp-tls", # TLS wrapper - re-included by request_tls() "esp_adc", # ADC driver - only needed by adc component diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 8c28603e78c..fcf61dca098 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -244,7 +244,7 @@ async def to_code(config: ConfigType) -> None: ethernet.request_ethernet_ip_state_listener() if CORE.is_esp32: - add_idf_component(name="espressif/mdns", ref="1.14.0") + add_idf_component(name="espressif/mdns", ref="1.15.0") # ESPHome only advertises; the browse APIs are unused add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_BROWSE", False) # The mdns console CLI is never used by ESPHome diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 809ea4aea26..e94b63cf005 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -28,7 +28,7 @@ dependencies: espressif/esp_modem: version: 2.1.0 espressif/mdns: - version: 1.14.0 + version: 1.15.0 espressif/esp_wifi_remote: version: 1.6.5 rules: From 3755a7c970eba0864283dd2487005b0a5d74e0f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 12:12:46 -0600 Subject: [PATCH 15/55] [core] Add cg.HubFunction to generate one function per hub from child statements (#22102) --- esphome/codegen.py | 1 + esphome/core/helpers.h | 12 ++ esphome/cpp_generator.py | 8 +- esphome/cpp_helpers.py | 118 +++++++++++++- tests/components/core/hub_publisher_test.cpp | 50 ++++++ tests/unit_tests/test_cpp_generator.py | 15 ++ tests/unit_tests/test_cpp_helpers.py | 158 ++++++++++++++++++- 7 files changed, 358 insertions(+), 4 deletions(-) create mode 100644 tests/components/core/hub_publisher_test.cpp diff --git a/esphome/codegen.py b/esphome/codegen.py index 5e32fdd38cd..27790f7a2ee 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -54,6 +54,7 @@ from esphome.cpp_generator import ( # noqa: F401 with_local_variable, ) from esphome.cpp_helpers import ( # noqa: F401 + HubFunction, add_optional, add_pin, add_variable, diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 2608154d404..dc5f187213c 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -58,6 +58,18 @@ #define ESPHOME_ALWAYS_INLINE __attribute__((always_inline)) #define PACKED __attribute__((packed)) +/// Declares the `_publisher_` function pointer and `set__publisher()` that a cg.HubFunction named +/// `` fills; the pointer stays nullptr when no child added an item, so check it before calling. +/// Use it in a public section: it ends on `public:`. +#define HUB_PUBLISHER(name, ...) HUB_PUBLISHER_RETURNING(void, name, __VA_ARGS__) +/// HUB_PUBLISHER for a cg.HubFunction that returns `ret` (bool: whether any item was true). +#define HUB_PUBLISHER_RETURNING(ret, name, ...) \ + protected: \ + ret (*name##_publisher_)(__VA_ARGS__){nullptr}; \ +\ + public: \ + void set_##name##_publisher(ret (*publisher)(__VA_ARGS__)) { this->name##_publisher_ = publisher; } + namespace esphome { // Forward declaration to avoid circular dependency with string_ref.h diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 527b95efed2..0151d9084a6 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -793,7 +793,7 @@ def static_function( name: str, return_type: SafeExpType, parameters: TemplateArgsType, - body: list[str], + body: list[str | Statement], ) -> RawExpression: """Emit ``static (parameters) { body }`` at global scope and return an expression naming it, for use as a template argument or a function pointer. @@ -807,7 +807,7 @@ def static_function( ) add_global(RawStatement(f"{signature};")) add_global_after_includes( - RawStatement(f"{signature} {{\n{indent(chr(10).join(body))}\n}}") + RawStatement(f"{signature} {{\n{indent(chr(10).join(map(str, body)))}\n}}") ) return RawExpression(name) @@ -1343,6 +1343,10 @@ class MockObjClass(MockObj): return True return any(str(parent) == str(other) for parent in self._parents) + def const_ref(self) -> MockObj: + """``const T &``, for a parameter that takes this type by reference.""" + return self.operator("const").operator("ref") + def template(self, *args: SafeExpType) -> "MockObjClass": if len(args) != 1 or not isinstance(args[0], TemplateArguments): args = TemplateArguments(*args) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 70090ddf27f..842a9827fa7 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -14,17 +14,22 @@ 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 ( + AssignmentExpression, + BinOpExpression, MockObj, RawStatement, + Statement, add, add_define, add_global, get_variable, new_Pvariable, + statement, + static_function, ) from esphome.cpp_types import App from esphome.helpers import cpp_string_escape -from esphome.types import ConfigFragmentType, ConfigType, Expression +from esphome.types import ConfigFragmentType, ConfigType, Expression, TemplateArgsType from esphome.util import Registry, RegistryEntry _LOGGER = logging.getLogger(__name__) @@ -216,6 +221,117 @@ def slot_counter(define: str) -> Callable[..., None]: return request_slot +_HUB_FUNCTION_DOMAIN = "hub_function" + + +@dataclass +class _HubFunctionState: + """One run's items per hub (keyed by ``str(hub)``) for one HubFunction.""" + + hubs: dict[str, tuple[MockObj, list[Expression | Statement]]] = field( + default_factory=dict + ) + emitted: bool = False + + +@dataclass +class _HubFunctionData: + """One run's state for every HubFunction, keyed by the instance, plus the names emitted.""" + + states: dict["HubFunction", _HubFunctionState] = field(default_factory=dict) + names: set[str] = field(default_factory=set) + + +def _hub_function_data() -> _HubFunctionData: + return CORE.data.setdefault(_HUB_FUNCTION_DOMAIN, _HubFunctionData()) + + +def _arg(name: str) -> str: + # Prefixed, so a parameter cannot hide an entity id the items name + return f"esphome__arg_{name}" + + +class HubFunction: + """One generated function per hub, ``esphome_____fn``, built from the items its children + ``add`` in ``to_code`` and passed to the ``set__publisher()`` that ``HUB_PUBLISHER`` declares. + A hub without items keeps ``nullptr``. ``void`` items are statements or expressions; ``bool`` items + are expressions that all run (no short-circuit), and the function returns whether any was true. + """ + + def __init__( + self, + name: str, + return_type: MockObj, + parameters: TemplateArgsType, + *, + define: str | None = None, + ) -> None: + if str(return_type) not in ("void", "bool"): + raise ValueError(f"HubFunction '{name}' returns void or bool") + self._name = name + self._return_type = return_type + self._returns_bool = str(return_type) == "bool" + self._parameters = [(type_, _arg(arg)) for type_, arg in parameters] + self._define = define + + @coroutine_with_priority(CoroPriority.FINAL) + async def emit_job() -> None: + self._emit() + + self._emit_job = emit_job + + def param(self, name: str) -> MockObj: + """The parameter ``name``, for use in the items.""" + arg = _arg(name) + if arg not in (param for _, param in self._parameters): + raise ValueError(f"HubFunction '{self._name}' has no parameter '{name}'") + return MockObj(arg) + + def add(self, hub: MockObj, item: Expression | Statement) -> None: + """Append ``item`` to the function of ``hub``, a plain variable such as ``cg.get_variable``'s.""" + if self._returns_bool and isinstance(item, Statement): + raise ValueError(f"HubFunction '{self._name}' takes bool expressions") + state = self._state() + if state.emitted: + raise ValueError( + f"HubFunction '{self._name}': item for {hub} added after the " + f"functions were emitted; add items from to_code" + ) + if not state.hubs: + CORE.add_job(self._emit_job) + state.hubs.setdefault(str(hub), (hub, []))[1].append(item) + + def _state(self) -> _HubFunctionState: + # Keyed by the instance, which lives for the process; CORE.data clears between runs + return _hub_function_data().states.setdefault(self, _HubFunctionState()) + + def _body(self, items: list[Expression | Statement]) -> list[Statement]: + if not self._returns_bool: + return [statement(item) for item in items] + any_ = MockObj("esphome__found") + return [ + statement(AssignmentExpression(self._return_type, "", any_, False)), + *(statement(BinOpExpression(any_, "|=", item)) for item in items), + RawStatement(f"return {any_};"), + ] + + def _emit(self) -> None: + state = self._state() + state.emitted = True + if self._define is not None: + add_define(self._define) + names = _hub_function_data().names + for hub, items in state.hubs.values(): + name = f"esphome__{hub}__{self._name}_fn" + if name in names: + raise ValueError(f"HubFunction: two functions named {name}") + names.add(name) + fn = static_function( + name, self._return_type, self._parameters, self._body(items) + ) + add(getattr(hub, f"set_{self._name}_publisher")(fn)) + + async def gpio_pin_expression(conf): """Generate an expression for the given pin option. diff --git a/tests/components/core/hub_publisher_test.cpp b/tests/components/core/hub_publisher_test.cpp new file mode 100644 index 00000000000..7c97ffd00b9 --- /dev/null +++ b/tests/components/core/hub_publisher_test.cpp @@ -0,0 +1,50 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::testing { + +class PublishingHub { + public: + bool publish(int value) { + if (this->value_publisher_ == nullptr) + return false; + this->value_publisher_(value); + return true; + } + + HUB_PUBLISHER(value, int) + + bool claim(uint32_t id) { return this->claim_publisher_ != nullptr && this->claim_publisher_(id); } + + HUB_PUBLISHER_RETURNING(bool, claim, uint32_t) +}; + +static int published = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static void record(int value) { published = value; } + +TEST(HubPublisher, StartsUnsetAndCallsTheFunctionOnceSet) { + PublishingHub hub; + published = 0; + EXPECT_FALSE(hub.publish(5)); + hub.set_value_publisher(record); + EXPECT_TRUE(hub.publish(7)); + EXPECT_EQ(published, 7); +} + +// The shape cg.HubFunction emits for a bool function (it names these esphome__arg_id and esphome__found) +static bool claim_seven(uint32_t id) { + bool found = false; + (found |= id == 7); + return found; +} + +TEST(HubPublisher, BoolPublisherReturnsWhetherAnyItemClaimed) { + PublishingHub hub; + EXPECT_FALSE(hub.claim(7)); + hub.set_claim_publisher(claim_seven); + EXPECT_TRUE(hub.claim(7)); + EXPECT_FALSE(hub.claim(8)); +} + +} // namespace esphome::testing diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 956ecea9139..efca10097e7 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -986,6 +986,21 @@ def test_static_function_declares_early_and_defines_after_includes() -> None: ] +def test_static_function_accepts_statements() -> None: + call = cg.MockObj("foo")(cg.MockObj("x") >> 2) + cg.static_function( + "esphome__st__fn", ct.void, [(ct.int32, "x")], [cg.statement(call), "return;"] + ) + assert [str(st) for st in CORE.global_statements_after_includes] == [ + "static void esphome__st__fn(int32_t x) {\n foo((x >> 2));\n return;\n}" + ] + + +def test_const_ref_renders_const_reference() -> None: + regs = cg.MockObj("hub", "::").class_("Regs") + assert str(regs.const_ref()) == "const hub::Regs &" + + def test_add_global_after_includes_rejects_non_statements() -> None: with pytest.raises(ValueError, match="must be expression or statement"): CORE.add_global_after_includes("not a statement") diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 5be0d0c7068..8c7d4528e5b 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, Mock import pytest -from esphome import const, cpp_helpers as ch +from esphome import codegen as cg, const, cpp_helpers as ch, cpp_types as ct from esphome.core import CoroPriority, coroutine_with_priority from esphome.cpp_helpers import ComponentSourcePool, register_component_source @@ -403,3 +403,159 @@ async def test_register_component_without_module_has_no_source( assert await ch.register_component(var, {}) is var app_mock.register_component_.assert_called_once_with(var) + + +_REGS = cg.esphome_ns.namespace("hub").class_("Regs") + + +def _status_function(define: str | None = "USE_HUB_STATUS") -> ch.HubFunction: + return ch.HubFunction( + "status", ct.void, [(_REGS.const_ref(), "status")], define=define + ) + + +def _after_includes() -> list[str]: + return [str(st) for st in ch.CORE.global_statements_after_includes] + + +def _main() -> list[str]: + return [str(st) for st in ch.CORE.main_statements] + + +def test_hub_function_one_function_per_hub_in_add_order() -> None: + """Each hub that got items gets its own function, items in the order added.""" + fn = _status_function() + status = fn.param("status") + hub_a, hub_b = cg.MockObj("hub_a", "->"), cg.MockObj("hub_b", "->") + fn.add(hub_a, cg.MockObj("x", "->").publish_state(status[0])) + fn.add(hub_b, cg.MockObj("y", "->").publish_state(status[1])) + fn.add(hub_a, cg.statement(cg.MockObj("z", "->").publish_state(status[2]))) + ch.CORE.flush_tasks() + assert _after_includes() == [ + ( + "static void esphome__hub_a__status_fn(const hub::Regs & esphome__arg_status) {\n" + " x->publish_state(esphome__arg_status[0]);\n" + " z->publish_state(esphome__arg_status[2]);\n}" + ), + ( + "static void esphome__hub_b__status_fn(const hub::Regs & esphome__arg_status) {\n" + " y->publish_state(esphome__arg_status[1]);\n}" + ), + ] + assert _main() == [ + "hub_a->set_status_publisher(esphome__hub_a__status_fn);", + "hub_b->set_status_publisher(esphome__hub_b__status_fn);", + ] + assert [d.name for d in ch.CORE.defines].count("USE_HUB_STATUS") == 1 + + +def test_hub_function_without_items_emits_nothing() -> None: + _status_function() + ch.CORE.flush_tasks() + assert _after_includes() == [] + assert _main() == [] + assert _define_value("USE_HUB_STATUS") is None + + +def test_hub_functions_on_one_hub_stay_apart() -> None: + """Two functions on one hub, such as press and release, keep their own items.""" + press = ch.HubFunction("press", ct.void, [(ct.uint8, "key")]) + release = ch.HubFunction("release", ct.void, [(ct.uint8, "key")]) + hub = cg.MockObj("keys", "->") + press.add(hub, cg.MockObj("on")(press.param("key"))) + release.add(hub, cg.MockObj("off")(release.param("key"))) + ch.CORE.flush_tasks() + assert _after_includes() == [ + "static void esphome__keys__press_fn(uint8_t esphome__arg_key) {\n on(esphome__arg_key);\n}", + "static void esphome__keys__release_fn(uint8_t esphome__arg_key) {\n off(esphome__arg_key);\n}", + ] + assert _main() == [ + "keys->set_press_publisher(esphome__keys__press_fn);", + "keys->set_release_publisher(esphome__keys__release_fn);", + ] + + +def test_hub_function_bool_returns_whether_any_item_was_true() -> None: + fn = ch.HubFunction("claim", ct.bool_, [(ct.uint32, "id")]) + hub = cg.MockObj("reader", "->") + fn.add(hub, cg.MockObj("card_a", "->").process(fn.param("id"))) + fn.add(hub, cg.MockObj("card_b", "->").process(fn.param("id"))) + ch.CORE.flush_tasks() + assert _after_includes() == [ + ( + "static bool esphome__reader__claim_fn(uint32_t esphome__arg_id) {\n" + " bool esphome__found = false;\n" + " (esphome__found |= card_a->process(esphome__arg_id));\n" + " (esphome__found |= card_b->process(esphome__arg_id));\n" + " return esphome__found;\n}" + ) + ] + + +def test_hub_function_rejects_other_return_types() -> None: + with pytest.raises(ValueError, match="void or bool"): + ch.HubFunction("value", ct.int32, []) + + +def test_hub_function_items_cannot_be_hidden_by_their_names() -> None: + """Parameters and the bool result are prefixed, so entity ids like status or any stay visible.""" + fn = ch.HubFunction("claim", ct.bool_, [(ct.bool_, "any")]) + fn.add(cg.MockObj("reader", "->"), cg.MockObj("any", "->").process(fn.param("any"))) + ch.CORE.flush_tasks() + assert _after_includes() == [ + ( + "static bool esphome__reader__claim_fn(bool esphome__arg_any) {\n" + " bool esphome__found = false;\n" + " (esphome__found |= any->process(esphome__arg_any));\n" + " return esphome__found;\n}" + ) + ] + + +def test_hub_function_param_must_exist() -> None: + with pytest.raises(ValueError, match="no parameter 'regs'"): + _status_function().param("regs") + + +def test_hub_function_add_after_emit_raises() -> None: + fn = _status_function() + hub = cg.MockObj("hub_a", "->") + fn.add(hub, cg.MockObj("x", "->").publish_state(1)) + ch.CORE.flush_tasks() + with pytest.raises(ValueError, match="after the functions were emitted"): + fn.add(hub, cg.MockObj("x", "->").publish_state(2)) + + +def test_hub_function_without_define() -> None: + fn = _status_function(define=None) + fn.add(cg.MockObj("hub_a", "->"), cg.MockObj("x", "->").publish_state(1)) + ch.CORE.flush_tasks() + assert not ch.CORE.defines + assert _main() == ["hub_a->set_status_publisher(esphome__hub_a__status_fn);"] + + +def test_hub_function_emits_again_in_the_next_run() -> None: + """A module-level instance starts empty in every run.""" + fn = _status_function() + fn.add(cg.MockObj("hub_a", "->"), cg.MockObj("x", "->").publish_state(1)) + ch.CORE.flush_tasks() + ch.CORE.reset() + fn.add(cg.MockObj("hub_b", "->"), cg.MockObj("y", "->").publish_state(2)) + ch.CORE.flush_tasks() + assert _main() == ["hub_b->set_status_publisher(esphome__hub_b__status_fn);"] + + +def test_hub_function_bool_rejects_statements() -> None: + fn = ch.HubFunction("claim", ct.bool_, [(ct.uint32, "id")]) + with pytest.raises(ValueError, match="takes bool expressions"): + fn.add(cg.MockObj("reader", "->"), cg.statement(cg.MockObj("f")())) + + +def test_hub_functions_with_one_name_on_one_hub_raise() -> None: + hub = cg.MockObj("hub_a", "->") + for fn in (_status_function(), _status_function(define=None)): + fn.add(hub, cg.MockObj("x", "->").publish_state(1)) + with pytest.raises( + ValueError, match="two functions named esphome__hub_a__status_fn" + ): + ch.CORE.flush_tasks() From 26303448d7deb21b4b6e6688f163111a568327c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 12:12:47 -0600 Subject: [PATCH 16/55] [bq25186] Add tests for the status binary and text sensors (#22090) --- .../bq25186/status_datasheet.py | 74 ++++++ .../bq25186/test_status_fields.py | 26 ++ tests/components/bq25186/__init__.py | 16 +- tests/components/bq25186/common.h | 43 ++++ .../bq25186/test_bq25186_pg_gpo.cpp | 34 +-- .../bq25186/test_bq25186_status.cpp | 237 ++++++++++++++++++ 6 files changed, 395 insertions(+), 35 deletions(-) create mode 100644 tests/component_tests/bq25186/status_datasheet.py create mode 100644 tests/component_tests/bq25186/test_status_fields.py create mode 100644 tests/components/bq25186/common.h create mode 100644 tests/components/bq25186/test_bq25186_status.cpp diff --git a/tests/component_tests/bq25186/status_datasheet.py b/tests/component_tests/bq25186/status_datasheet.py new file mode 100644 index 00000000000..a6d7b69959a --- /dev/null +++ b/tests/component_tests/bq25186/status_datasheet.py @@ -0,0 +1,74 @@ +"""The BQ25186 status fields from the datasheet, for checking the entity decode.""" + +from pathlib import Path +import re + +# Offsets into the status registers the hub reads from STAT0 +STAT0 = 0 +STAT1 = 1 +FLAG0 = 2 + +# Binary sensor key: (register, field bits, field values that read as on), in publish order +BINARY_SENSORS: dict[str, tuple[int, int, frozenset[int]]] = { + "ts": (STAT0, 0x80, frozenset({1})), + "charging": (STAT0, 0x60, frozenset({1, 2})), + "charge_done": (STAT0, 0x60, frozenset({3})), + "ilim": (STAT0, 0x10, frozenset({1})), + "vdppm": (STAT0, 0x08, frozenset({1})), + "vindpm": (STAT0, 0x04, frozenset({1})), + "thermreg": (STAT0, 0x02, frozenset({1})), + "power_good": (STAT0, 0x01, frozenset({1})), + "vin_ovp": (STAT1, 0x80, frozenset({1})), + "battery_uvlo": (STAT1, 0x40, frozenset({1})), + "safety_timer_flag": (STAT1, 0x04, frozenset({1})), + "wake1_flag": (STAT1, 0x02, frozenset({1})), + "wake2_flag": (STAT1, 0x01, frozenset({1})), + "ts_flag": (FLAG0, 0x80, frozenset({1})), + "ilim_flag": (FLAG0, 0x40, frozenset({1})), + "vdppm_flag": (FLAG0, 0x20, frozenset({1})), + "vindpm_flag": (FLAG0, 0x10, frozenset({1})), + "thermreg_flag": (FLAG0, 0x08, frozenset({1})), + "vin_ovp_flag": (FLAG0, 0x04, frozenset({1})), + "battery_uvlo_flag": (FLAG0, 0x02, frozenset({1})), + "battery_ocp_flag": (FLAG0, 0x01, frozenset({1})), +} + +# Text sensor key: (register, field bits, the state for each field value), in publish order +TEXT_SENSORS: dict[str, tuple[int, int, tuple[str, ...]]] = { + "charge_status": ( + STAT0, + 0x60, + ( + "Not Charging", + "Constant Current", + "Constant Voltage", + "Charge Done/Disabled", + ), + ), + "ts_status": ( + STAT1, + 0x18, + ( + "Normal", + "Hot/Cold (Charging Suspended)", + "Cool (Current Reduced)", + "Warm (Voltage Reduced)", + ), + ), +} + + +def field_value(value: int, bits: int) -> int: + """The field `bits` of the register `value`, shifted down.""" + return (value & bits) >> ((bits & -bits).bit_length() - 1) + + +def status_strings() -> list[str]: + """The strings of the component's PROGMEM_STRING_TABLE, in table order.""" + component = Path(__file__).parents[3] / "esphome" / "components" / "bq25186" + for path in sorted(component.glob("*.[ch]*")): + if match := re.search( + r"PROGMEM_STRING_TABLE\(\w+,(.*?)\);", path.read_text(), re.DOTALL + ): + return re.findall(r'"([^"]*)"', match[1]) + raise AssertionError("no PROGMEM_STRING_TABLE in the bq25186 component") diff --git a/tests/component_tests/bq25186/test_status_fields.py b/tests/component_tests/bq25186/test_status_fields.py new file mode 100644 index 00000000000..5fbc8cde68c --- /dev/null +++ b/tests/component_tests/bq25186/test_status_fields.py @@ -0,0 +1,26 @@ +"""Check the status entity field tables against the datasheet.""" + +from esphome.components.bq25186 import binary_sensor, text_sensor + +from .status_datasheet import BINARY_SENSORS, TEXT_SENSORS, field_value, status_strings + + +def test_binary_sensor_fields_match_the_datasheet() -> None: + assert [key for key, *_ in binary_sensor._FIELDS] == list(BINARY_SENSORS) + for key, reg, shift, mask, match, _ in binary_sensor._FIELDS: + sheet_reg, bits, on_values = BINARY_SENSORS[key] + assert reg == sheet_reg, key + for value in range(256): + decoded = bool((match >> ((value >> shift) & mask)) & 1) + assert decoded == (field_value(value, bits) in on_values), (key, value) + + +def test_text_sensor_fields_match_the_datasheet() -> None: + strings = status_strings() + assert [key for key, *_ in text_sensor._FIELDS] == list(TEXT_SENSORS) + for key, reg, shift, first in text_sensor._FIELDS: + sheet_reg, bits, states = TEXT_SENSORS[key] + assert reg == sheet_reg, key + for value in range(256): + decoded = strings[first + ((value >> shift) & 0x03)] + assert decoded == states[field_value(value, bits)], (key, value) diff --git a/tests/components/bq25186/__init__.py b/tests/components/bq25186/__init__.py index 03731db0eb0..ae4a1a1d38e 100644 --- a/tests/components/bq25186/__init__.py +++ b/tests/components/bq25186/__init__.py @@ -4,11 +4,23 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: - # One empty config entry so to_code runs and compiles the PG/GPO switch path. + # One empty config entry so to_code runs and compiles the PG/GPO switch and status entity paths. manifest.multi_conf = False - manifest.dependencies = manifest.dependencies + ["i2c", "switch"] + manifest.dependencies = manifest.dependencies + [ + "binary_sensor", + "i2c", + "switch", + "text_sensor", + ] async def to_code(config: ConfigType) -> None: cg.add_define("USE_BQ25186_PG_GPO") + cg.add_define("USE_BINARY_SENSOR") + cg.add_define("USE_BINARY_SENSOR_RAW_CALLBACK") + cg.add_define("ESPHOME_ENTITY_BINARY_SENSOR_COUNT", 0) + cg.add_define("USE_TEXT_SENSOR") + cg.add_define("ESPHOME_ENTITY_TEXT_SENSOR_COUNT", 0) + cg.add_define("BQ25186_BINARY_SENSOR_COUNT", 21) + cg.add_define("BQ25186_TEXT_SENSOR_COUNT", 2) manifest.to_code = to_code diff --git a/tests/components/bq25186/common.h b/tests/components/bq25186/common.h new file mode 100644 index 00000000000..83a78860265 --- /dev/null +++ b/tests/components/bq25186/common.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include + +#include "esphome/components/bq25186/bq25186.h" + +namespace esphome::bq25186::testing { + +static constexpr uint8_t ADDRESS = 0x6A; + +// The chip's registers behind an address pointer that moves on after every byte; writes can be made to fail. +class RegisterBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (size > 1 && this->fail_writes) + return i2c::ERROR_NOT_ACKNOWLEDGED; + if (out_size != 0 && this->fail_reads) + return i2c::ERROR_NOT_ACKNOWLEDGED; + if (size != 0) { + this->pointer_ = data[0]; + for (size_t i = 1; i < size; i++) + this->registers[this->next_()] = data[i]; + } + for (size_t i = 0; i < out_size; i++) + out[i] = this->registers[this->next_()]; + return i2c::ERROR_OK; + } + + std::array registers{}; + bool fail_writes{false}; + bool fail_reads{false}; + + protected: + size_t next_() { return this->pointer_++ % this->registers.size(); } + + uint8_t pointer_{0}; +}; + +} // namespace esphome::bq25186::testing diff --git a/tests/components/bq25186/test_bq25186_pg_gpo.cpp b/tests/components/bq25186/test_bq25186_pg_gpo.cpp index c1296c9e6e5..9a596c91780 100644 --- a/tests/components/bq25186/test_bq25186_pg_gpo.cpp +++ b/tests/components/bq25186/test_bq25186_pg_gpo.cpp @@ -1,42 +1,10 @@ -#include - -#include - -#include "esphome/components/bq25186/bq25186.h" +#include "common.h" #include "esphome/components/switch/switch.h" namespace esphome::bq25186::testing { -static constexpr uint8_t ADDRESS = 0x6A; static constexpr uint8_t SYS_REG_OTHER_BITS = 0x0F; -// The chip's registers behind an address pointer that moves on after every byte; writes can be made to fail. -class RegisterBus final : public i2c::I2CBus { - public: - i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, - size_t out_size) override { - EXPECT_EQ(address, ADDRESS); - if (size > 1 && this->fail_writes) - return i2c::ERROR_NOT_ACKNOWLEDGED; - if (size != 0) { - this->pointer_ = data[0]; - for (size_t i = 1; i < size; i++) - this->registers[this->next_()] = data[i]; - } - for (size_t i = 0; i < out_size; i++) - out[i] = this->registers[this->next_()]; - return i2c::ERROR_OK; - } - - std::array registers{}; - bool fail_writes{false}; - - protected: - size_t next_() { return this->pointer_++ % this->registers.size(); } - - uint8_t pointer_{0}; -}; - // The body code generation emits for apply= with publish="on_success" static BQ25186Component *hub_under_test = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static void apply_pg_gpo(switch_::Switch *entity, bool state) { diff --git a/tests/components/bq25186/test_bq25186_status.cpp b/tests/components/bq25186/test_bq25186_status.cpp new file mode 100644 index 00000000000..cf89b62d9ec --- /dev/null +++ b/tests/components/bq25186/test_bq25186_status.cpp @@ -0,0 +1,237 @@ +#include "common.h" +#include "esphome/components/bq25186/bq25186_binary_sensor.h" +#include "esphome/components/bq25186/bq25186_text_sensor.h" + +#include +#include +#include + +namespace esphome::bq25186::testing { + +// One entry per binary_sensor.py _FIELDS row, in its order: what code generation passes for each sensor. +// tests/component_tests/bq25186/test_status_fields.py checks _FIELDS against the datasheet. +struct BinaryField { + uint8_t reg; + uint8_t shift; + uint8_t mask; + uint8_t match; +}; +static constexpr std::array BINARY_FIELDS{{ + {0, 7, 0x01, 0b10}, {0, 5, 0x03, 0b0110}, {0, 5, 0x03, 0b1000}, {0, 4, 0x01, 0b10}, {0, 3, 0x01, 0b10}, + {0, 2, 0x01, 0b10}, {0, 1, 0x01, 0b10}, {0, 0, 0x01, 0b10}, {1, 7, 0x01, 0b10}, {1, 6, 0x01, 0b10}, + {1, 2, 0x01, 0b10}, {1, 1, 0x01, 0b10}, {1, 0, 0x01, 0b10}, {2, 7, 0x01, 0b10}, {2, 6, 0x01, 0b10}, + {2, 5, 0x01, 0b10}, {2, 4, 0x01, 0b10}, {2, 3, 0x01, 0b10}, {2, 2, 0x01, 0b10}, {2, 1, 0x01, 0b10}, + {2, 0, 0x01, 0b10}, +}}; + +enum BinaryIndex : uint8_t { + TS, + CHARGING, + CHARGE_DONE, + ILIM, + VDPPM, + VINDPM, + THERMREG, + POWER_GOOD, + VIN_OVP, + BATTERY_UVLO, + SAFETY_TIMER_FLAG, + WAKE1_FLAG, + WAKE2_FLAG, + TS_FLAG, + ILIM_FLAG, + VDPPM_FLAG, + VINDPM_FLAG, + THERMREG_FLAG, + VIN_OVP_FLAG, + BATTERY_UVLO_FLAG, + BATTERY_OCP_FLAG, + BINARY_COUNT, +}; +enum TextIndex : uint8_t { CHARGE_STATUS, TS_STATUS, TEXT_COUNT }; + +// The register and bit each single-bit sensor reads, from the datasheet +struct SingleBit { + BinaryIndex sensor; + uint8_t reg; + uint8_t bit; +}; +static constexpr std::array SINGLE_BITS{{ + {TS, BQ25186_REG_STAT0, 0x80}, + {ILIM, BQ25186_REG_STAT0, 0x10}, + {VDPPM, BQ25186_REG_STAT0, 0x08}, + {VINDPM, BQ25186_REG_STAT0, 0x04}, + {THERMREG, BQ25186_REG_STAT0, 0x02}, + {POWER_GOOD, BQ25186_REG_STAT0, 0x01}, + {VIN_OVP, BQ25186_REG_STAT1, 0x80}, + {BATTERY_UVLO, BQ25186_REG_STAT1, 0x40}, + {SAFETY_TIMER_FLAG, BQ25186_REG_STAT1, 0x04}, + {WAKE1_FLAG, BQ25186_REG_STAT1, 0x02}, + {WAKE2_FLAG, BQ25186_REG_STAT1, 0x01}, + {TS_FLAG, BQ25186_REG_FLAG0, 0x80}, + {ILIM_FLAG, BQ25186_REG_FLAG0, 0x40}, + {VDPPM_FLAG, BQ25186_REG_FLAG0, 0x20}, + {VINDPM_FLAG, BQ25186_REG_FLAG0, 0x10}, + {THERMREG_FLAG, BQ25186_REG_FLAG0, 0x08}, + {VIN_OVP_FLAG, BQ25186_REG_FLAG0, 0x04}, + {BATTERY_UVLO_FLAG, BQ25186_REG_FLAG0, 0x02}, + {BATTERY_OCP_FLAG, BQ25186_REG_FLAG0, 0x01}, +}}; + +class BQ25186StatusTest : public ::testing::Test { + protected: + void SetUp() override { + this->hub_.set_i2c_bus(&this->bus_); + this->hub_.set_i2c_address(ADDRESS); + for (size_t i = 0; i < BINARY_COUNT; i++) { + const BinaryField &f = BINARY_FIELDS[i]; + this->binary_sensors_[i] = std::make_unique(f.reg, f.shift, f.mask, f.match); + this->hub_.register_binary_sensor(this->binary_sensors_[i].get()); + } + this->text_sensors_[CHARGE_STATUS] = std::make_unique(0, 5, 0); + this->text_sensors_[TS_STATUS] = std::make_unique(1, 3, 4); + for (auto &sensor : this->text_sensors_) + this->hub_.register_text_sensor(sensor.get()); + } + + binary_sensor::BinarySensor &binary_(size_t index) { return *this->binary_sensors_[index]; } + text_sensor::TextSensor &text_(size_t index) { return *this->text_sensors_[index]; } + + void poll_(uint8_t stat0, uint8_t stat1 = 0, uint8_t flag0 = 0) { + this->bus_.registers[BQ25186_REG_STAT0] = stat0; + this->bus_.registers[BQ25186_REG_STAT1] = stat1; + this->bus_.registers[BQ25186_REG_FLAG0] = flag0; + this->hub_.update(); + } + + RegisterBus bus_; + BQ25186Component hub_; + std::array, BINARY_COUNT> binary_sensors_; + std::array, TEXT_COUNT> text_sensors_; +}; + +TEST_F(BQ25186StatusTest, EachSingleBitSensorReadsOnlyItsBit) { + for (const SingleBit &field : SINGLE_BITS) { + std::array regs{}; + regs[field.reg] = field.bit; + this->poll_(regs[0], regs[1], regs[2]); + for (size_t i = 0; i < BINARY_COUNT; i++) { + SCOPED_TRACE(i); + EXPECT_TRUE(this->binary_(i).has_state()); + EXPECT_EQ(this->binary_(i).state, i == field.sensor); + } + } +} + +TEST_F(BQ25186StatusTest, SingleBitSensorsIgnoreTheOtherBits) { + for (const SingleBit &field : SINGLE_BITS) { + std::array regs{0xFF, 0xFF, 0xFF}; + regs[field.reg] = static_cast(~field.bit); + this->poll_(regs[0], regs[1], regs[2]); + SCOPED_TRACE(field.sensor); + EXPECT_FALSE(this->binary_(field.sensor).state); + } +} + +TEST_F(BQ25186StatusTest, ChargingAndChargeDoneDecodeTheChargeStatus) { + // STAT0[6:5]: not charging, constant current, constant voltage, done + static constexpr std::array CHARGING_ON{false, true, true, false}; + static constexpr std::array DONE_ON{false, false, false, true}; + for (uint8_t value = 0; value < 4; value++) { + SCOPED_TRACE(value); + this->poll_(static_cast(value << 5)); + EXPECT_EQ(this->binary_(CHARGING).state, CHARGING_ON[value]); + EXPECT_EQ(this->binary_(CHARGE_DONE).state, DONE_ON[value]); + this->poll_(static_cast((value << 5) | 0x9F)); + EXPECT_EQ(this->binary_(CHARGING).state, CHARGING_ON[value]); + EXPECT_EQ(this->binary_(CHARGE_DONE).state, DONE_ON[value]); + } +} + +TEST_F(BQ25186StatusTest, ChargeStatusText) { + static constexpr std::array NAMES{"Not Charging", "Constant Current", "Constant Voltage", + "Charge Done/Disabled"}; + for (uint8_t value = 0; value < 4; value++) { + this->poll_(static_cast((value << 5) | 0x9F), 0xFF, 0xFF); + EXPECT_EQ(this->text_(CHARGE_STATUS).state, NAMES[value]); + } +} + +TEST_F(BQ25186StatusTest, TsStatusText) { + static constexpr std::array NAMES{"Normal", "Hot/Cold (Charging Suspended)", + "Cool (Current Reduced)", "Warm (Voltage Reduced)"}; + for (uint8_t value = 0; value < 4; value++) { + this->poll_(0xFF, static_cast((value << 3) | 0xE7), 0xFF); + EXPECT_EQ(this->text_(TS_STATUS).state, NAMES[value]); + } +} + +TEST_F(BQ25186StatusTest, FirstPollPublishesTextForValueZero) { + int published = 0; + this->text_(CHARGE_STATUS).add_on_state_callback([&published](const std::string &) { published++; }); + this->poll_(0); + EXPECT_EQ(published, 1); + EXPECT_EQ(this->text_(CHARGE_STATUS).state, "Not Charging"); +} + +TEST_F(BQ25186StatusTest, TextPublishesOnlyWhenTheFieldChanges) { + int charge = 0; + int ts = 0; + this->text_(CHARGE_STATUS).add_on_state_callback([&charge](const std::string &) { charge++; }); + this->text_(TS_STATUS).add_on_state_callback([&ts](const std::string &) { ts++; }); + this->poll_(0x20, 0x08); + EXPECT_EQ(charge, 1); + EXPECT_EQ(ts, 1); + // Other bits change, the fields do not + this->poll_(0x3F, 0xE7 | 0x08, 0xFF); + EXPECT_EQ(charge, 1); + EXPECT_EQ(ts, 1); + this->poll_(0x40, 0x08); + EXPECT_EQ(charge, 2); + EXPECT_EQ(ts, 1); + this->poll_(0x40, 0x10); + EXPECT_EQ(charge, 2); + EXPECT_EQ(ts, 2); +} + +TEST_F(BQ25186StatusTest, BinarySensorsPublishEveryPoll) { + std::array published{}; + for (size_t i = 0; i < BINARY_COUNT; i++) + this->binary_(i).add_on_raw_state_callback([&published, i](bool) { published[i]++; }); + this->poll_(0); + this->poll_(0); + this->poll_(0xFF, 0xFF, 0xFF); + for (size_t i = 0; i < BINARY_COUNT; i++) { + SCOPED_TRACE(i); + EXPECT_EQ(published[i], 3); + } +} + +TEST_F(BQ25186StatusTest, BinarySensorsPublishBeforeTextSensorsInFieldOrder) { + // Binary sensor i records i, text sensor i records TEXT_BASE + i + static constexpr size_t TEXT_BASE = 100; + std::vector order; + for (size_t i = 0; i < BINARY_COUNT; i++) + this->binary_(i).add_on_raw_state_callback([&order, i](bool) { order.push_back(i); }); + for (size_t i = 0; i < TEXT_COUNT; i++) + this->text_(i).add_on_state_callback([&order, i](const std::string &) { order.push_back(TEXT_BASE + i); }); + this->poll_(0); + std::vector expected; + expected.reserve(BINARY_COUNT + TEXT_COUNT); + for (size_t i = 0; i < BINARY_COUNT; i++) + expected.push_back(i); + for (size_t i = 0; i < TEXT_COUNT; i++) + expected.push_back(TEXT_BASE + i); + EXPECT_EQ(order, expected); +} + +TEST_F(BQ25186StatusTest, FailedReadPublishesNothing) { + this->bus_.fail_reads = true; + this->poll_(0xFF, 0xFF, 0xFF); + for (size_t i = 0; i < BINARY_COUNT; i++) + EXPECT_FALSE(this->binary_(i).has_state()); + for (size_t i = 0; i < TEXT_COUNT; i++) + EXPECT_FALSE(this->text_(i).has_state()); +} + +} // namespace esphome::bq25186::testing From 2473ddcf1925f179e10b5e2354745b76a753df46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 12:17:40 -0600 Subject: [PATCH 17/55] [logger] Report the RP2 crash record when a host opens the USB port (#22266) --- esphome/components/logger/__init__.py | 2 ++ esphome/components/logger/logger.h | 3 +++ esphome/components/logger/logger_rp2.cpp | 17 +++++++++++++++- esphome/core/defines.h | 1 + tests/component_tests/logger/test_logger.py | 20 +++++++++++++++++++ .../logger/test_logger_rp2_uart0.yaml | 9 +++++++++ .../logger/test_logger_rp2_usb_cdc.yaml | 8 ++++++++ 7 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/logger/test_logger_rp2_uart0.yaml create mode 100644 tests/component_tests/logger/test_logger_rp2_usb_cdc.yaml diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index c97d90475c4..ee04666d889 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -618,6 +618,8 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_define("USE_ESP32_LOG_V2") # --wrap: a strong definition collides; see logger_esp32.cpp cg.add_build_flag("-Wl,--wrap=esp_log_format") + if CORE.is_rp2 and config[CONF_HARDWARE_UART] == USB_CDC: + cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") # Define platform support flags for components that need auto-detection try: uart_selection(USB_SERIAL_JTAG) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 05f1849a943..c02320b1e55 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -151,6 +151,9 @@ class Logger final : public Component { explicit Logger(uint32_t baud_rate); #if defined(USE_ESPHOME_TASK_LOG_BUFFER) || (defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC)) void loop() override; +#endif +#if defined(USE_RP2_CRASH_HANDLER) && defined(USE_LOGGER_UART_SELECTION_USB_CDC) + void setup() override; #endif /// Manually set the baud rate for serial, set to 0 to disable. void set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } diff --git a/esphome/components/logger/logger_rp2.cpp b/esphome/components/logger/logger_rp2.cpp index 6d190df5cdb..20880b74d70 100644 --- a/esphome/components/logger/logger_rp2.cpp +++ b/esphome/components/logger/logger_rp2.cpp @@ -10,6 +10,20 @@ namespace esphome::logger { ESPHOME_LOG_TAG(TAG, "logger"); +#if defined(USE_RP2_CRASH_HANDLER) && defined(USE_LOGGER_UART_SELECTION_USB_CDC) +void Logger::setup() { + if (this->hw_serial_ == nullptr || !crash_log_pending()) + return; + // Report the crash each time a host opens the USB port (sets DTR) + this->set_interval(500, [opened_before = false]() mutable { + const bool opened = Serial.dtr(); + if (opened && !opened_before) + log_crash_to_client(); + opened_before = opened; + }); +} +#endif + void Logger::pre_setup() { if (this->baud_rate_ > 0) { switch (this->uart_) { @@ -29,7 +43,8 @@ void Logger::pre_setup() { } global_logger = this; ESP_LOGI(TAG, "Log initialized"); -#ifdef USE_RP2_CRASH_HANDLER + // Nobody has a USB port open this early; setup() reports the crash once a host opens it +#if defined(USE_RP2_CRASH_HANDLER) && !defined(USE_LOGGER_UART_SELECTION_USB_CDC) rp2::crash_handler_log(); #endif } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d4328860edd..af451a3f649 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -784,6 +784,7 @@ #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC +#define USE_LOGGER_UART_SELECTION_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP #define USE_RP2040_BLE #define USE_RP2_BLE_TRACKER diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 4cbfcff5414..0400c8c68c0 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -161,3 +161,23 @@ def test_log_v2_only_on_native_toolchain( assert ("USE_ESP32_LOG_V2" in {d.name for d in CORE.defines}) is log_v2 sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert ("CONFIG_LOG_VERSION_2" in sdkconfig) is log_v2 + + +@pytest.mark.parametrize( + ("config", "usb_cdc"), + [ + pytest.param("test_logger_rp2_usb_cdc.yaml", True, id="usb_cdc"), + pytest.param("test_logger_rp2_uart0.yaml", False, id="uart0"), + ], +) +def test_rp2_usb_cdc_selection_define( + generate_main: Callable[[str | Path], str], + config: str, + usb_cdc: bool, +) -> None: + """The USB CDC crash replay only compiles in when the logger uses USB CDC.""" + generate_main(f"tests/component_tests/logger/{config}") + + assert ( + "USE_LOGGER_UART_SELECTION_USB_CDC" in {d.name for d in CORE.defines} + ) is usb_cdc diff --git a/tests/component_tests/logger/test_logger_rp2_uart0.yaml b/tests/component_tests/logger/test_logger_rp2_uart0.yaml new file mode 100644 index 00000000000..671f4a26610 --- /dev/null +++ b/tests/component_tests/logger/test_logger_rp2_uart0.yaml @@ -0,0 +1,9 @@ +--- +esphome: + name: test + +rp2040: + board: rpipico + +logger: + hardware_uart: UART0 diff --git a/tests/component_tests/logger/test_logger_rp2_usb_cdc.yaml b/tests/component_tests/logger/test_logger_rp2_usb_cdc.yaml new file mode 100644 index 00000000000..dc108720c0d --- /dev/null +++ b/tests/component_tests/logger/test_logger_rp2_usb_cdc.yaml @@ -0,0 +1,8 @@ +--- +esphome: + name: test + +rp2040: + board: rpipico + +logger: From 295de903037f575cdb6655acb96c6efda5934fa8 Mon Sep 17 00:00:00 2001 From: Ryan Ronnander <61520+ryan-ronnander@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:17:55 -0500 Subject: [PATCH 18/55] [sm2135] Fix light turning off when switching color modes (#19438) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sm2135/sm2135.cpp | 9 ++++----- esphome/components/sm2135/sm2135.h | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/esphome/components/sm2135/sm2135.cpp b/esphome/components/sm2135/sm2135.cpp index 5addda669df..10a0b771df5 100644 --- a/esphome/components/sm2135/sm2135.cpp +++ b/esphome/components/sm2135/sm2135.cpp @@ -98,9 +98,10 @@ void SM2135::loop() { this->write_byte_(current_mask_); if (this->separate_modes_) { - if (this->update_channel_ == 3 || this->update_channel_ == 4) { - // No color so must be Cold/Warm - + const bool color_on = this->pwm_amounts_[0] || this->pwm_amounts_[1] || this->pwm_amounts_[2]; + const bool white_on = this->pwm_amounts_[3] || this->pwm_amounts_[4]; + // Both groups only hold a value without color_interlock; the last updated channel then decides + if (!color_on || (white_on && this->update_channel_ >= 3)) { this->write_byte_(SM2135_CW); this->sm2135_stop_(); delay(1); @@ -109,8 +110,6 @@ void SM2135::loop() { this->write_byte_(this->pwm_amounts_[3]); this->write_byte_(this->pwm_amounts_[4]); } else { - // Color - this->write_byte_(SM2135_RGB); this->write_byte_(this->pwm_amounts_[0]); this->write_byte_(this->pwm_amounts_[1]); diff --git a/esphome/components/sm2135/sm2135.h b/esphome/components/sm2135/sm2135.h index ce047115d57..56be75ef69e 100644 --- a/esphome/components/sm2135/sm2135.h +++ b/esphome/components/sm2135/sm2135.h @@ -63,7 +63,7 @@ class SM2135 final : public Component { SM2135Current rgb_current_; SM2135Current cw_current_; bool separate_modes_; - uint8_t update_channel_; + uint8_t update_channel_{0}; std::vector pwm_amounts_; bool update_{true}; }; From 6d3239bf4351604a8d3167343f1336cb2f65b90e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 12:34:44 -0600 Subject: [PATCH 19/55] [store_yaml] Embed user YAML in firmware for recovery (#16445) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/api/api.proto | 29 + esphome/components/api/api_connection.cpp | 46 +- esphome/components/api/api_connection.h | 15 + esphome/components/api/api_pb2.cpp | 37 + esphome/components/api/api_pb2.h | 47 +- esphome/components/api/api_pb2_dump.cpp | 21 + esphome/components/api/api_pb2_service.cpp | 9 + esphome/components/api/api_pb2_service.h | 4 + esphome/components/store_yaml/__init__.py | 723 +++++++++ esphome/core/defines.h | 2 + script/analyze_component_buses.py | 1 + tests/components/store_yaml/common.yaml | 4 + .../store_yaml/test.bk72xx-ard.yaml | 6 + .../components/store_yaml/test.esp32-idf.yaml | 6 + .../store_yaml/test.esp8266-ard.yaml | 6 + tests/components/store_yaml/test.host.yaml | 4 + .../store_yaml/test.ln882x-ard.yaml | 6 + .../store_yaml/test.rp2040-ard.yaml | 6 + .../store_yaml/test.rtl87xx-ard.yaml | 6 + .../store_yaml/validate.esp32-idf.yaml | 9 + .../fixtures/store_yaml_recovery.yaml | 18 + .../store_yaml_recovery_backpressure.yaml | 24 + tests/integration/raw_api_client.py | 31 +- tests/integration/test_store_yaml_recovery.py | 109 ++ .../unit_tests/components/test_store_yaml.py | 1312 +++++++++++++++++ 26 files changed, 2474 insertions(+), 8 deletions(-) create mode 100644 esphome/components/store_yaml/__init__.py create mode 100644 tests/components/store_yaml/common.yaml create mode 100644 tests/components/store_yaml/test.bk72xx-ard.yaml create mode 100644 tests/components/store_yaml/test.esp32-idf.yaml create mode 100644 tests/components/store_yaml/test.esp8266-ard.yaml create mode 100644 tests/components/store_yaml/test.host.yaml create mode 100644 tests/components/store_yaml/test.ln882x-ard.yaml create mode 100644 tests/components/store_yaml/test.rp2040-ard.yaml create mode 100644 tests/components/store_yaml/test.rtl87xx-ard.yaml create mode 100644 tests/components/store_yaml/validate.esp32-idf.yaml create mode 100644 tests/integration/fixtures/store_yaml_recovery.yaml create mode 100644 tests/integration/fixtures/store_yaml_recovery_backpressure.yaml create mode 100644 tests/integration/test_store_yaml_recovery.py create mode 100644 tests/unit_tests/components/test_store_yaml.py diff --git a/CODEOWNERS b/CODEOWNERS index 9bb0f23c290..ea48bb99e28 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -633,6 +633,7 @@ esphome/components/st7789v/* @kbx81 esphome/components/st7920/* @marsjan155 esphome/components/statsd/* @Links2004 esphome/components/stcc4/* @j9brown +esphome/components/store_yaml/* @bdraco esphome/components/stts22h/* @B48D81EFCC esphome/components/substitutions/* @esphome/core esphome/components/sun/* @OttoWinter diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index af11d925faa..4a5b54c086c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -83,6 +83,7 @@ service APIConnection { rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {} + rpc get_yaml(GetYamlRequest) returns (void) {} } @@ -401,6 +402,11 @@ message ZWaveProxyCapabilities { uint32 home_id = 2; } +message StoreYamlCapabilities { + // Firmware embeds its YAML, so clients can request it with get_yaml + bool supported = 1; +} + message WizardCapabilities { // True when the device has a wizard configured, so DeviceWizardRequest will be answered bool configured = 1; @@ -425,6 +431,7 @@ message DeviceCapabilitiesResponse { [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"]; SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"]; + StoreYamlCapabilities store_yaml = 7 [(field_ifdef) = "USE_STORE_YAML"]; } // ==================== DEVICE WIZARD ==================== @@ -3143,3 +3150,25 @@ message SendspinPairingTokenResponse { // The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK. string token = 2 [(max_data_length) = 127]; } + +// ==================== STORE YAML ==================== +// Streams the compressed user YAML embedded in firmware; the client decompresses. +// A GetYamlRequest during an active transfer is ignored; reconnect to restart. +message GetYamlRequest { + option (id) = 161; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_STORE_YAML"; + option (no_delay) = true; +} + +message GetYamlResponse { + option (id) = 162; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_STORE_YAML"; + + // A zstd frame of the EHY1 file envelope, sent from flash + bytes data = 1 [(force) = true, (pointer_to_buffer) = true, (progmem) = true]; + bool done = 2 [(force) = true]; + // Set on the first chunk only + uint32 total_size = 3; +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c687ac3e4ef..92333e4917b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -366,6 +366,13 @@ void APIConnection::loop() { // (missing a frame is fine, missing a state update is not) this->try_send_camera_image_(); #endif + +#ifdef USE_STORE_YAML + // Guard inline so the idle hot path pays a compare, not a function call. + if (this->store_yaml_pos_ != STORE_YAML_IDLE) { + this->try_send_store_yaml_(); + } +#endif } void APIConnection::check_keepalive_(uint32_t now) { @@ -1222,6 +1229,36 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) { } #endif +#ifdef USE_STORE_YAML +void APIConnection::on_get_yaml_request() { + // A re-request while a transfer is in flight is ignored (see api.proto) + if (this->store_yaml_pos_ != STORE_YAML_IDLE) + return; + this->store_yaml_pos_ = 0; + this->try_send_store_yaml_(); +} + +void APIConnection::try_send_store_yaml_() { + // Advance only after a successful send, so a full TX buffer retries the same chunk from loop() + while (this->helper_->can_write_without_blocking()) { + const size_t to_send = std::min(STORE_YAML_DATA_SIZE - this->store_yaml_pos_, MAX_BATCH_PACKET_SIZE); + GetYamlResponse resp; + resp.data = STORE_YAML_DATA + this->store_yaml_pos_; + resp.data_len = to_send; + if (this->store_yaml_pos_ == 0) + resp.total_size = STORE_YAML_DATA_SIZE; + resp.done = this->store_yaml_pos_ + to_send == STORE_YAML_DATA_SIZE; + if (!this->send_message(resp)) + return; + if (resp.done) { + this->store_yaml_pos_ = STORE_YAML_IDLE; + return; + } + this->store_yaml_pos_ += to_send; + } +} +#endif + #ifdef USE_HOMEASSISTANT_TIME void APIConnection::on_get_time_response(const GetTimeResponse &value) { if (homeassistant::global_homeassistant_time != nullptr) { @@ -2123,6 +2160,9 @@ bool APIConnection::send_device_capabilities_response_() { #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 +#ifdef USE_STORE_YAML + resp.store_yaml.supported = true; #endif return this->send_message(resp); } @@ -2365,10 +2405,14 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg) { #ifdef HAS_PROTO_MESSAGE_DUMP - // Skip dump for log messages (recursive logging risk) and camera frames (high-frequency noise) + // Skip dump for log messages (recursive logging risk), camera frames (high-frequency noise) + // and YAML chunks (may contain secrets) if (message_type != SubscribeLogsResponse::MESSAGE_TYPE #ifdef USE_CAMERA && message_type != CameraImageResponse::MESSAGE_TYPE +#endif +#ifdef USE_STORE_YAML + && message_type != GetYamlResponse::MESSAGE_TYPE #endif ) { auto *proto_msg = static_cast(msg); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 2c986159121..5a8af7e98c1 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -32,6 +32,11 @@ namespace esphome::api { // Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. class APIServer; +#ifdef USE_STORE_YAML +/// The zstd YAML envelope generated by store_yaml, STORE_YAML_DATA_SIZE bytes in flash. +extern const uint8_t STORE_YAML_DATA[] PROGMEM; +#endif + // One shared flash string for every refused-frame warning: send_message() // fails as soon as the TCP buffer is full, and each caller only pays for its // short name. The guard drops the helper and its arguments below WARN. @@ -123,6 +128,9 @@ class APIConnection final : public APIServerConnectionBase { void set_camera_state(std::shared_ptr image); void on_camera_image_request(const CameraImageRequest &msg); #endif +#ifdef USE_STORE_YAML + void on_get_yaml_request(); +#endif #ifdef USE_CLIMATE bool send_climate_state(climate::Climate *climate); void on_climate_command_request(const ClimateCommandRequest &msg); @@ -421,6 +429,13 @@ class APIConnection final : public APIServerConnectionBase { void try_send_camera_image_(); #endif +#ifdef USE_STORE_YAML + void try_send_store_yaml_(); + static constexpr size_t STORE_YAML_IDLE = std::numeric_limits::max(); + // Streaming offset into STORE_YAML_DATA, or STORE_YAML_IDLE + size_t store_yaml_pos_{STORE_YAML_IDLE}; +#endif + #ifdef USE_API_HOMEASSISTANT_STATES void process_state_subscriptions_(); #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index acc25610446..0f31c82596f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -299,6 +299,19 @@ uint32_t ZWaveProxyCapabilities::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_STORE_YAML +uint8_t *StoreYamlCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.supported); + return pos; +} +uint32_t StoreYamlCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_bool(1, msg.supported); + return size; +} +#endif #ifdef USE_API_WIZARD uint8_t *WizardCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); @@ -348,6 +361,9 @@ uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__res #endif #ifdef USE_SENDSPIN pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.sendspin); +#endif +#ifdef USE_STORE_YAML + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.store_yaml); #endif return pos; } @@ -373,6 +389,9 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { #endif #ifdef USE_SENDSPIN size += ProtoSize::calc_message(1, msg.sendspin.calculate_size()); +#endif +#ifdef USE_STORE_YAML + size += ProtoSize::calc_message(1, msg.store_yaml.calculate_size()); #endif return size; } @@ -4407,6 +4426,24 @@ uint32_t SendspinPairingTokenResponse::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_STORE_YAML +uint8_t *GetYamlResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, msg.done ? 0x01 : 0x00); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.total_size); + return pos; +} +uint32_t GetYamlResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_length_force(1, msg.data_len); + size += ProtoSize::calc_bool_force(1); + size += ProtoSize::calc_uint32(1, msg.total_size); + return size; +} +#endif #ifndef HAS_PROTO_MESSAGE_DUMP static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index bf300e4c752..306f53e3c9f 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -734,6 +734,23 @@ class ZWaveProxyCapabilities final : public ProtoMessage { protected: }; #endif +#ifdef USE_STORE_YAML +class StoreYamlCapabilities final : public ProtoMessage { + public: + bool supported{false}; + 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); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif #ifdef USE_API_WIZARD class WizardCapabilities final : public ProtoMessage { public: @@ -772,7 +789,7 @@ class SendspinCapabilities final : public ProtoMessage { class DeviceCapabilitiesResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 150; - static constexpr uint8_t ESTIMATED_SIZE = 136; + static constexpr uint8_t ESTIMATED_SIZE = 153; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } #endif @@ -793,6 +810,9 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #endif #ifdef USE_SENDSPIN SendspinCapabilities sendspin{}; +#endif +#ifdef USE_STORE_YAML + StoreYamlCapabilities store_yaml{}; #endif 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 { @@ -4209,5 +4229,30 @@ class SendspinPairingTokenResponse final : public ProtoMessage { protected: }; #endif +#ifdef USE_STORE_YAML +class GetYamlResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 162; + static constexpr uint8_t ESTIMATED_SIZE = 25; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("get_yaml_response"); } +#endif + const uint8_t *data{nullptr}; + uint16_t data_len{0}; + bool done{false}; + uint32_t total_size{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); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 60b64b41838..51460174d01 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1127,6 +1127,13 @@ const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_STORE_YAML +const char *StoreYamlCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("StoreYamlCapabilities")); + dump_field(out, ESPHOME_PSTR("supported"), this->supported); + return out.c_str(); +} +#endif #ifdef USE_API_WIZARD const char *WizardCapabilities::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("WizardCapabilities")); @@ -1175,6 +1182,11 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": "); this->sendspin.dump_to(out); out.append("\n"); +#endif +#ifdef USE_STORE_YAML + out.append(2, ' ').append_p(ESPHOME_PSTR("store_yaml")).append(": "); + this->store_yaml.dump_to(out); + out.append("\n"); #endif return out.c_str(); } @@ -3016,6 +3028,15 @@ const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_STORE_YAML +const char *GetYamlResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("GetYamlResponse")); + dump_progmem_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); + dump_field(out, ESPHOME_PSTR("done"), this->done); + dump_field(out, ESPHOME_PSTR("total_size"), this->total_size); + return out.c_str(); +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 683bb84c302..dd9736bae10 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -760,6 +760,15 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_sendspin_pairing_token_request(); break; } +#endif +#ifdef USE_STORE_YAML + case 161 /* GetYamlRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_get_yaml_request")); +#endif + this->on_get_yaml_request(); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index b4c39e022b3..f246d8d21fc 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -256,6 +256,10 @@ class APIServerConnectionBase { #ifdef USE_SENDSPIN void on_sendspin_pairing_token_request() {}; #endif + +#ifdef USE_STORE_YAML + void on_get_yaml_request() {}; +#endif }; } // namespace esphome::api diff --git a/esphome/components/store_yaml/__init__.py b/esphome/components/store_yaml/__init__.py new file mode 100644 index 00000000000..4e305eac710 --- /dev/null +++ b/esphome/components/store_yaml/__init__.py @@ -0,0 +1,723 @@ +from __future__ import annotations + +from collections.abc import Generator +from dataclasses import dataclass +import logging +import os +from pathlib import Path +import re +import struct + +import yaml + +from esphome import yaml_util +import esphome.codegen as cg +from esphome.components import noise, packages +from esphome.config_helpers import Extend, Remove +import esphome.config_validation as cv +from esphome.const import ( + CONF_API, + CONF_DEFAULTS, + CONF_ID, + CONF_PACKAGES, + CONF_SSID, + CONF_SUBSTITUTIONS, +) +from esphome.core import CORE, EsphomeError, Lambda +from esphome.expression import SUBSTITUTION_VARIABLE_PROG +import esphome.final_validate as fv +from esphome.git import URL_USERINFO_RE +from esphome.helpers import ensure_unique_string, list_starts_with, zstd_module +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@bdraco"] +DEPENDENCIES = ["api"] +DOMAIN = "store_yaml" + +CONF_INCLUDE_SECRETS = "include_secrets" +CONF_ALLOW_UNENCRYPTED = "allow_unencrypted" + +# 22 is the max and gives ~70-90% reduction on YAML. +ZSTD_LEVEL = 22 +# "EHY1" = ESPHome YAML, version 1. +ENVELOPE_MAGIC = b"EHY1" +SECRETS_SKELETON_HEADER = ( + "# Redacted by store_yaml. Fill in these values and the recovered\n" + "# config is ready to flash.\n" +) +UNCAPTURED_NOTE_PATH = "store_yaml_uncaptured.yaml" +_FIX = ( + "Reference them with `!secret`, or set `include_secrets: true` to embed " + "secrets deliberately." +) +# Substitution names to every value they may take in one file. +_Scope = dict[str, list[object]] +# An `!include`, its vars before any folding, and the config path of its site. +_Include = tuple[yaml_util.IncludeFile, dict[str, object], tuple[str, ...]] +# Stands in for an integer credential at its keys and definitions. +_INT_MARK = "\x00" +# A removed branch is never validated, so its credentials are known only by name. +_CREDENTIAL_WORDS = ( + "password", + "key", + "secret", + "token", + "psk", + "tlv", + "pairing_code", + "pin_code", +) +# Every key validated with cv.sensitive (a test keeps this complete) and the +# packet_transport `encryption: ` shorthand: credentials wherever they appear, +# also when a merge or override drops them before validation. SSIDs are broadcast, +# and a bare `key` also names keypad keys; both are checked where validation kept them. +_SENSITIVE_KEYS = frozenset( + {"password", "new_password", "private_key", "peer_preshared_key"} + | {"client_certificate_key", "network_key", "old_key", "pin_code", "pskc", "tlv"} + | {"static_pairing_code", "encryption", "bindkey", "decryption_key", "auth_key"} +) +# Keys under these name declared arguments, not values (`variables: {password: string}`). +_DECLARATIONS = frozenset({"variables", "parameters"}) +# cv.bind_key's normalised output: 32 uppercase hex digits. +_BIND_KEY_RE = re.compile(r"[0-9A-F]{32}") +# A quoted string or a number: a lambda without one cannot spell a credential. +_LITERAL_RE = re.compile(r"""["']|(? ConfigType: + """Require an API encryption key unless ``allow_unencrypted`` is set.""" + full = fv.full_config.get() + if noise.static_encryption_key(full.get(CONF_API, {})) is not None: + return config + if config.get(CONF_ALLOW_UNENCRYPTED): + _LOGGER.warning( + "store_yaml is enabled without API encryption; any client that can " + "reach the device on the network can pull the embedded YAML%s.", + " including the verbatim contents of secrets.yaml" + if config.get(CONF_INCLUDE_SECRETS) + else "", + ) + return config + raise cv.Invalid( + "store_yaml requires API encryption (`api.encryption.key`), or any client " + "that reaches the device can read the embedded YAML. Set " + "`store_yaml.allow_unencrypted: true` to accept that." + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def _gather_files( + discovered: yaml_util.DiscoveredYamlFiles, +) -> tuple[list[tuple[str, Path]], set[str]]: + """(envelope path, named path) per name a file was reached by, and the envelope + paths of secrets files.""" + if discovered.load_errors: + raise EsphomeError( + "store_yaml: could not load all configuration files: " + + "; ".join(discovered.load_errors) + ) + if discovered.unresolved: + _LOGGER.warning( + "store_yaml: %d !include path(s) use substitutions and cannot be " + "captured (%s); the embedded recovery data will not contain them", + len(discovered.unresolved), + ", ".join(discovered.unresolved), + ) + + root = yaml_util.named_path(CORE.config_path).parent + entries: list[tuple[str, Path]] = [] + secret_rels: set[str] = set() + for path in discovered.files: + for alias in discovered.aliases.get(path, [path]): + # Keep ".." for files outside the root so same-named files don't collide. + try: + rel_str = alias.relative_to(root, walk_up=True).as_posix() + except ValueError as err: + raise EsphomeError( + f"store_yaml: cannot place {alias} in the recovery envelope; " + f"it does not share a root with {root}: {err}" + ) from err + if path in discovered.secrets: + secret_rels.add(rel_str) + entries.append((rel_str, alias)) + return entries, secret_rels + + +def _read_files_verbatim(entries: list[tuple[str, Path]]) -> list[tuple[str, bytes]]: + """Read each file's exact on-disk bytes (the `include_secrets: true` path).""" + files: list[tuple[str, bytes]] = [] + for rel, path in entries: + try: + files.append((rel, path.read_bytes())) + except OSError as err: + raise EsphomeError( + f"store_yaml: cannot read tracked YAML file {path}: {err}" + ) from err + return files + + +class _IncludeName(str): + """An `!include` file name: scanned for secrets, never taken for the value it loads.""" + + __slots__ = () + + +def _iter_nodes( + node: object, path: tuple[str, ...] = () +) -> Generator[tuple[tuple[str, ...], object, bool]]: + """Yield (config_path, value, is_key) for every key and scalar, unwrapping the + tagged wrappers the dumper writes as text.""" + if isinstance(node, dict): + for key, value in node.items(): + yield path, str(key), True + yield from _iter_nodes(value, (*path, str(key))) + elif isinstance(node, (list, tuple)): + for item in node: + yield from _iter_nodes(item, path) + elif isinstance(node, Lambda): + yield path, node, False + elif isinstance(node, (Extend, Remove)): + yield path, node.value, False + elif isinstance(node, yaml_util.IncludeFile): + yield path, _IncludeName(node.file), False + if isinstance(node.condition, str): + yield path, node.condition, False + if node.vars: + yield from _iter_nodes(node.vars, path) + elif isinstance(node, (str, int, float)) and not isinstance(node, bool): + yield path, node, False + + +def _str_keys(value: object) -> dict[str, object]: + """`value` as a mapping with string keys, or empty.""" + return {str(k): v for k, v in value.items()} if isinstance(value, dict) else {} + + +def _ends_with(full: tuple[str, ...], part: tuple[str, ...]) -> bool: + """`part` ends `full`, skipping segments validation inserted (`wifi.password` + to `wifi.networks.password`).""" + if full[-1] != part[-1]: + return False + rest = iter(reversed(full[:-1])) + return all(seg in rest for seg in reversed(part[:-1])) + + +def _merged(path: tuple[str, ...]) -> set[tuple[str, ...]]: + """`path` as validation sees it: every `packages[.]` prefix merges away.""" + if path and path[0] == CONF_PACKAGES: + return _merged(path[1:]) | _merged(path[2:]) + return {path} + + +def _defines_substitution(path: tuple[str, ...]) -> bool: + """`path` is a `substitutions:` entry, also inside an inline package.""" + return any(len(p) > 1 and p[0] == CONF_SUBSTITUTIONS for p in _merged(path)) + + +@dataclass +class _SensitiveValue: + secret_name: str + config_path: str # dotted path, for messages (never the value itself) + from_secret: bool # loaded through a real `!secret` + # Keys the value sits at, to tell its own occurrences from collisions. + sensitive_keys: set[str] + # Integers skip the substring scan; short PINs would match unrelated text. + is_int: bool = True + + +def _include_scopes( + includes: dict[str, list[_Include]], + trees: dict[str, object], + sources: dict[str, Path], +) -> tuple[dict[str, _Scope], dict[str, list[tuple[str, ...]]]]: + """Per file: every value a substitution name may take (per include: its vars, + else the file's `defaults:`, else the caller's chain, else substitutions) and + the config paths its content lands under.""" + substitutions: _Scope = {} + for tree in trees.values(): + for path, node, is_key in _iter_nodes(tree): + if not is_key and _defines_substitution(path): + substitutions.setdefault(path[-1], []).append(node) + defaults = { + rel: _str_keys(_str_keys(tree).get(CONF_DEFAULTS)) + for rel, tree in trees.items() + } + rels_by_path: dict[Path, list[str]] = {} + for rel, path in sources.items(): + rels_by_path.setdefault(path.resolve(), []).append(rel) + edges: dict[str, list[tuple[str, dict[str, object], tuple[str, ...]]]] = {} + for src, items in includes.items(): + for include, vars, site in items: + if include.has_unresolved_file() or not yaml_util.include_enabled(include): + continue + target = (include.parent_file.parent / include.file).resolve() + edges.setdefault(src, []).extend( + (rel, vars, site) for rel in rels_by_path.get(target, ()) + ) + # Each way a file is reached: its vars chain, built as the loader does, and + # the include-site prefix; a caller's chain flows into nested includes. + targeted = {rel for targets in edges.values() for rel, _, _ in targets} + invocations: dict[str, list[tuple[dict[str, object], tuple[str, ...]]]] = {} + pending = [(rel, defaults[rel], (), ()) for rel in trees if rel not in targeted] + while pending: + rel, chain, prefix, lineage = pending.pop() + if (chain, prefix) in invocations.setdefault(rel, []): + continue + invocations[rel].append((chain, prefix)) + pending.extend( + (dst, {**chain, **defaults[dst], **vars}, prefix + site, (*lineage, rel)) + for dst, vars, site in edges.get(rel, ()) + if dst not in lineage # an include cycle never ends otherwise + ) + scopes: dict[str, _Scope] = {} + prefixes: dict[str, list[tuple[str, ...]]] = {} + for rel in trees: + chains = invocations.get(rel) or [(defaults[rel], ())] + scope: _Scope = {} + for chain, _ in chains: + for name in {*substitutions, *chain}: + scope.setdefault(name, []).extend( + [chain[name]] if name in chain else substitutions[name] + ) + scopes[rel] = scope + prefixes[rel] = [prefix for _, prefix in chains] + return scopes, prefixes + + +def _check_sensitive_usage( + sensitive: dict[str, _SensitiveValue], + sensitive_paths: set[tuple[str, ...]], + trees: dict[str, object], + scopes: dict[str, _Scope], + prefixes: dict[str, list[tuple[str, ...]]], + removed: set[tuple[str, ...]], +) -> None: + """A sensitive value is a whole scalar (swapped) or fails, except a secret SSID + inside a name (warned); unverifiable credential locations and URL userinfo fail.""" + credential_keys = {sp[-1] for sp in sensitive_paths if sp} + + def verified(text: str) -> bool: + return not text or text in sensitive or yaml_util.is_secret(text) is not None + + def references_verified( + text: str, scope: _Scope, seen: frozenset = frozenset() + ) -> bool: + """Every reference names definitions that are verified or alias verified ones.""" + names = [m.strip("{}") for m in SUBSTITUTION_VARIABLE_PROG.findall(text)] + return bool(names) and all( + name in scope + and name not in seen + and all( + verified(str(v)) + or SUBSTITUTION_VARIABLE_PROG.fullmatch(str(v)) is not None + and references_verified(str(v), scope, seen | {name}) + for v in scope[name] + ) + for name in names + ) + + def credential_path(path: tuple[str, ...]) -> bool: + """Whether `path` lands where the validated config holds a credential.""" + if not path: + return False + merged = _merged(path) + if path[-1] in _SENSITIVE_KEYS and not _DECLARATIONS.intersection(path[-2:-1]): + return True + if any(p and any(list_starts_with(p, r) for r in removed) for p in merged): + return path[-1] in credential_keys or any( + word in path[-1] for word in _CREDENTIAL_WORDS + ) + return path[-1] in credential_keys and any( + # One segment says nothing about where an `!include_dir_*` file lands + len(p) > 1 and any(_ends_with(sp, p) for sp in sensitive_paths) + for p in merged + ) + + def shown(path: tuple[str, ...]) -> str: + """`path` for messages, credential segments masked.""" + return ".".join( + f"" if seg in sensitive else seg + for seg in path + ) + + failures: dict[str, list[str]] = { + "appear embedded inside larger values or as mapping keys, which redaction " + "does not replace": [], + "cannot be confirmed redacted: they sit at a credential key or location but " + "are not a known secret (for example a literal password, or one a merge, " + "override or `!remove` dropped before validation)": [], + } + embedded, unverified = failures.values() + for rel, tree in trees.items(): + scope = scopes[rel] + for path, node, is_key in _iter_nodes(tree): + text = str(node) + where = ( + f"a mapping key under {shown(path) or 'the document root'}" + if is_key + else shown(path) + ) + if verified(text): + # A whole scalar is swapped for its `!secret`; a key never is. + if is_key and (info := sensitive.get(text)) is not None: + embedded.append(f"{info.config_path} (as {where} in {rel})") + continue + if not is_key and any( + SUBSTITUTION_VARIABLE_PROG.sub("", userinfo) not in ("", ":") + or not references_verified(userinfo, scope) + for userinfo in URL_USERINFO_RE.findall(text) + ): + unverified.append(f"{where} in {rel} (a URL with credentials)") + elif ( + not is_key + and not isinstance(node, _IncludeName) + and any(credential_path(prefix + path) for prefix in prefixes[rel]) + and not ( + _LITERAL_RE.search(text) is None + if isinstance(node, Lambda) + else SUBSTITUTION_VARIABLE_PROG.fullmatch(text) is not None + and references_verified(text, scope) + ) + ): + unverified.append(f"{where} in {rel}") + continue + for value, other in sensitive.items(): + # A key the value is sensitive at (`password` for `word`) is structure. + if ( + other.is_int + or value not in text + or (is_key and text in other.sensitive_keys) + ): + continue + if other.from_secret and other.sensitive_keys <= {CONF_SSID}: + # A broadcast SSID inside a name is common; warn without the value. + _LOGGER.warning( + "store_yaml: !secret %s is not redacted inside %s in %s", + other.secret_name, + where, + rel, + ) + else: + embedded.append(f"{other.config_path} (inside {where} in {rel})") + for why, hits in failures.items(): + if hits: + raise EsphomeError( + f"store_yaml: sensitive value(s) {why}: {', '.join(hits)}. {_FIX}" + ) + + +def _collect_sensitive_values() -> tuple[ + dict[str, _SensitiveValue], set[tuple[str, ...]] +]: + """Map each cv.sensitive value to its `!secret` name (inline values get one from + their path) and collect every sensitive path, empty values included.""" + used = yaml_util.registered_secret_names() + result: dict[str, _SensitiveValue] = {} + paths: set[tuple[str, ...]] = set() + for path, node, is_key in _iter_nodes(CORE.config): + if is_key or not isinstance(node, yaml_util.Sensitive): + continue + paths.add(path) + if isinstance(node, Lambda): + if _LITERAL_RE.search(node.value) is not None: + raise EsphomeError( + f"store_yaml: the credential at {'.'.join(path)} is set by a " + f"lambda holding a literal, which cannot be redacted. {_FIX}" + ) + continue + if isinstance(node, str) and not node: + continue + # Decimal for ints, to match the raw tree's parsed scalars, not HexInt's hex. + value = str(int(node)) if isinstance(node, int) else str(node) + if (entry := result.get(value)) is None: + name = yaml_util.is_secret(value) + if name is None and _BIND_KEY_RE.fullmatch(value): + # cv.bind_key uppercases the key; its secret keeps the user's spelling + secrets = yaml_util.registered_secret_values().items() + name = next((n for v, n in secrets if v.upper() == value), None) + from_secret = name is not None + if not from_secret: + name = ensure_unique_string("_".join(path) or "secret", used) + used.add(name) + entry = result[value] = _SensitiveValue( + name, ".".join(path), from_secret, set() + ) + entry.is_int = entry.is_int and isinstance(node, int) + entry.sensitive_keys.update(path[-1:]) + return result, paths + + +def _symlink_descriptions(discovered: yaml_util.DiscoveredYamlFiles) -> list[str]: + """Each directory symlink the config went through, as `link -> target`.""" + root = Path(CORE.config_path).absolute().parent + return [ + f"{link.relative_to(root, walk_up=True).as_posix()} -> " + f"{Path(os.path.relpath(target, link.parent.resolve())).as_posix()}" + for link, target in discovered.links.items() + ] + + +def _uncaptured_note( + unresolved: list[str], remote_packages: list[str], symlinks: list[str] +) -> tuple[str, bytes] | None: + """A comment-only entry listing uncaptured content, or None if there is none.""" + sections = ( + ( + "These !include paths use substitutions; restore the files manually:", + unresolved, + ), + ( + "These packages come from remote sources; re-fetch them to complete this config:", + remote_packages, + ), + ("These directories were symlinks; recreate them as link -> target:", symlinks), + ) + body = "".join( + f"# {title}\n" + "".join(f"# {item}\n" for item in items) + for title, items in sections + if items + ) + if not body: + return None + header = "# store_yaml: the following content could not be captured.\n" + return UNCAPTURED_NOTE_PATH, (header + body).encode("utf-8") + + +def _remote_package_descriptions() -> list[str]: + """Each remote package source as its secret name or masked URL, plus ref.""" + + def describe(text: str) -> str: + if (name := yaml_util.is_secret(text)) is not None: + return f"!secret {name}" + # A secret substituted into the URL or ref is shown by name, longest first so + # a secret containing another is replaced whole. + secrets = yaml_util.registered_secret_values().items() + for value, name in sorted(secrets, key=lambda item: -len(item[0])): + if value and value in text: + text = text.replace(value, f"") + return URL_USERINFO_RE.sub("://***@", text) + + return [ + describe(source.url) + (f"@{describe(source.ref)}" if source.ref else "") + for source in packages.get_remote_package_sources() + ] + + +def _build_secrets_skeleton(keys: set[str]) -> bytes: + # Plain PyYAML quotes a key such as "true" and does no secret substitution. + body = yaml.safe_dump(dict.fromkeys(sorted(keys), ""), sort_keys=False) + return (SECRETS_SKELETON_HEADER + (body if keys else "")).encode("utf-8") + + +def _generate_redacted_files( + entries: list[tuple[str, Path]], secret_rels: set[str], remote_packages: list[str] +) -> list[tuple[str, bytes]]: + """Re-dump captured files with sensitive values as `!secret`, secrets files as a + skeleton; comments and formatting are lost.""" + sensitive, sensitive_paths = _collect_sensitive_values() + includes: dict[str, list[_Include]] = {} + removed: dict[str, set[tuple[str, ...]]] = {} + + def prepare( + node: object, + source: Path, + rel: str, + path: tuple[str, ...], + in_vars: bool = False, + ) -> object: + """Fold container secrets to their registered text (a plain str would skip + the secret check), swap integer credentials at their own keys, point each + `!include` at its target's captured name, collect includes and removals.""" + if isinstance(node, (dict, list)) and yaml_util.is_secret(node) is not None: + return yaml_util.make_data_base(str(node)) + if isinstance(node, dict): + for key, value in node.items(): + node[key] = prepare(value, source, rel, (*path, str(key)), in_vars) + elif isinstance(node, list): + for i, item in enumerate(node): + node[i] = prepare(item, source, rel, path, in_vars) + elif isinstance(node, Remove): + # `id: !remove x` drops the whole list item, not just its id. + removed.setdefault(rel, set()).add( + path[:-1] if path[-1:] == (CONF_ID,) else path + ) + elif isinstance(node, (int, str)) and not isinstance(node, bool): + # An integer credential, quoted or not, at its own keys or as a variable + # definition a credential may reference. + info = sensitive.get(str(node)) + at_key = info is not None and info.sensitive_keys.intersection(path[-1:]) + definition = ( + in_vars or path[:1] == (CONF_DEFAULTS,) or _defines_substitution(path) + ) + if info and info.is_int and (at_key or definition): + return yaml_util.make_data_base(_INT_MARK + info.secret_name) + # cv.bind_key uppercases the key, so match an inline one the same way + upper = node.upper() if isinstance(node, str) else "" + bind = sensitive.get(upper) if _BIND_KEY_RE.fullmatch(upper) else None + if bind and bind.sensitive_keys.intersection(path[-1:]): + return yaml_util.make_data_base(upper) + elif isinstance(node, yaml_util.IncludeFile): + vars = _str_keys(node.vars) + if yaml_util.is_secret(node.vars) is not None: + vars = dict.fromkeys(vars, node.vars) + includes.setdefault(rel, []).append((node, vars, path)) + if node.vars: + node.vars = prepare(node.vars, source, rel, path, in_vars=True) + if not node.has_unresolved_file(): + # Where the loader read it, through directory symlinks and `!include_dir_*` moves. + target = yaml_util.named_path(Path(node.parent_file).parent / node.file) + node = node.with_file( + Path(os.path.relpath(target, source.parent)).as_posix() + ) + return node + + for rel, path in entries: + # A secrets file becomes a key skeleton; what it includes would ship verbatim. + if rel in secret_rels and any( + isinstance(token, yaml.TagToken) and token.value[1].startswith("include") + for token in yaml.scan(path.read_text(encoding="utf-8"), yaml.SafeLoader) + ): + raise EsphomeError( + f"store_yaml: {rel} uses !include, which redaction cannot follow. " + f"Inline the files into {rel}, or set `include_secrets: true`." + ) + captured = {rel: path for rel, path in entries if rel not in secret_rels} + trees: dict[str, object] = {} + for rel, path in captured.items(): + # A document that is one `!include` keeps its vars and condition. + with path.open(encoding="utf-8") as handle: + tree = yaml_util.parse_yaml(path, handle, resolve_document_include=False) + trees[rel] = prepare(tree, yaml_util.named_path(path), rel, ()) + scopes, prefixes = _include_scopes(includes, trees, captured) + removed_at = { + merged + for rel, paths in removed.items() + for path in paths + for prefix in prefixes[rel] + for merged in _merged(prefix + path) + if merged + } + + # An integer credential is swapped by prepare(), not by the dumper, so an + # unrelated short number (`accuracy_decimals: 0`) is left alone. + registered = { + (_INT_MARK + info.secret_name if info.is_int else value): info.secret_name + for value, info in sensitive.items() + } + with yaml_util.secret_values_registered(registered) as skeleton_keys: + _check_sensitive_usage( + sensitive, sensitive_paths, trees, scopes, prefixes, removed_at + ) + texts = {rel: yaml_util.dump(tree) for rel, tree in trees.items()} + + # An inline value whose name was never emitted was not found as a whole scalar. + leaked = [ + info.config_path + for info in sensitive.values() + if not info.from_secret and info.secret_name not in skeleton_keys + ] + if leaked: + raise EsphomeError( + f"store_yaml: could not redact the sensitive value(s) of {', '.join(leaked)}; " + "not found in any captured file (composed via substitutions, set with -s, " + f"or defined in a remote package: {', '.join(remote_packages) or 'none'}). " + + _FIX + ) + + skeleton = _build_secrets_skeleton(skeleton_keys) + result = [ + (rel, skeleton if rel in secret_rels else texts[rel].encode("utf-8")) + for rel, _ in entries + ] + if skeleton_keys and yaml_util.SECRET_YAML not in secret_rels: + # `!secret` looks for secrets.yaml beside the config; ship a skeleton there. + result.append((yaml_util.SECRET_YAML, skeleton)) + return result + + +def _pack_envelope(files: list[tuple[str, bytes]]) -> bytes: + """EHY1 | u32 count | {u16 path_len, path, u32 content_len, content}..., little-endian.""" + paths = [path for path, _ in files] + if len(set(paths)) != len(paths): + raise EsphomeError(f"store_yaml: duplicate envelope path in {paths}") + return ( + ENVELOPE_MAGIC + + struct.pack(" dict[str, bytes]: + """Reference decoder; rejects absolute paths, allows `..`, so disk writers confine paths.""" + if blob[:4] != ENVELOPE_MAGIC: + raise EsphomeError("envelope must start with EHY1 magic") + files: dict[str, bytes] = {} + try: + (count,) = struct.unpack_from(" None: + cg.add_define("USE_STORE_YAML") + + # A fresh parse captures only user files and refills the secret registry. + discovered = yaml_util.discover_user_yaml_files(CORE.config_path) + entries, secret_rels = _gather_files(discovered) + remote_packages = _remote_package_descriptions() + if config[CONF_INCLUDE_SECRETS]: + files = _read_files_verbatim(entries) + else: + files = _generate_redacted_files(entries, secret_rels, remote_packages) + if remote_packages: + _LOGGER.warning( + "store_yaml: remote packages are not embedded, only recorded: %s", + ", ".join(remote_packages), + ) + note = _uncaptured_note( + discovered.unresolved, remote_packages, _symlink_descriptions(discovered) + ) + if note is not None: + files.append(note) + envelope = _pack_envelope(files) + compressed = zstd_module().compress(envelope, level=ZSTD_LEVEL) + + _LOGGER.info( + "store_yaml: embedding %d file(s) as %d bytes (%d uncompressed, %.1f%% ratio)", + len(files), + len(compressed), + len(envelope), + 100.0 * len(compressed) / len(envelope), + ) + + cg.extern_progmem_array("esphome::api::STORE_YAML_DATA", cg.uint8, list(compressed)) + cg.add_define("STORE_YAML_DATA_SIZE", len(compressed)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index af451a3f649..0ec5596e9f0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -396,8 +396,10 @@ #define USE_API_USER_DEFINED_ACTION_OPTIONAL_ARGS #define USE_API_USER_DEFINED_ACTION_RESPONSES #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#define USE_STORE_YAML #define API_MAX_SEND_QUEUE 8 #define API_WIZARD_DATA_SIZE 1 +#define STORE_YAML_DATA_SIZE 1 #define API_WIZARD_INPUT_COUNT 1 #define MAX_API_CONNECTIONS 6 // The Improv library is not in the Zephyr tidy environment diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 115a6ef2fd9..8fc1b87bd4c 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -102,6 +102,7 @@ ISOLATED_COMPONENTS = { "modem": "Cellular-only node with api - grouping with wifi-bearing components hides that case", "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", + "store_yaml": "Embeds the test's own YAML; grouping would change the blob and its redaction", "tinyusb": "Conflicts with usb_host component - cannot be used together", "tinyusb_keyboard": "Depends on tinyusb which conflicts with usb_host", "uart_mux": "Depends on tinyusb which conflicts with usb_host", diff --git a/tests/components/store_yaml/common.yaml b/tests/components/store_yaml/common.yaml new file mode 100644 index 00000000000..fc6ad1fe752 --- /dev/null +++ b/tests/components/store_yaml/common.yaml @@ -0,0 +1,4 @@ +api: + +store_yaml: + allow_unencrypted: true diff --git a/tests/components/store_yaml/test.bk72xx-ard.yaml b/tests/components/store_yaml/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.bk72xx-ard.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.esp32-idf.yaml b/tests/components/store_yaml/test.esp32-idf.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.esp8266-ard.yaml b/tests/components/store_yaml/test.esp8266-ard.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.host.yaml b/tests/components/store_yaml/test.host.yaml new file mode 100644 index 00000000000..6c822b28eef --- /dev/null +++ b/tests/components/store_yaml/test.host.yaml @@ -0,0 +1,4 @@ +network: + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.ln882x-ard.yaml b/tests/components/store_yaml/test.ln882x-ard.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.ln882x-ard.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.rp2040-ard.yaml b/tests/components/store_yaml/test.rp2040-ard.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/test.rtl87xx-ard.yaml b/tests/components/store_yaml/test.rtl87xx-ard.yaml new file mode 100644 index 00000000000..1de35f3cc3a --- /dev/null +++ b/tests/components/store_yaml/test.rtl87xx-ard.yaml @@ -0,0 +1,6 @@ +wifi: + ssid: MySSID + password: password1 + +packages: + store_yaml: !include common.yaml diff --git a/tests/components/store_yaml/validate.esp32-idf.yaml b/tests/components/store_yaml/validate.esp32-idf.yaml new file mode 100644 index 00000000000..cc09a556d4f --- /dev/null +++ b/tests/components/store_yaml/validate.esp32-idf.yaml @@ -0,0 +1,9 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= + +store_yaml: diff --git a/tests/integration/fixtures/store_yaml_recovery.yaml b/tests/integration/fixtures/store_yaml_recovery.yaml new file mode 100644 index 00000000000..c416e41f50f --- /dev/null +++ b/tests/integration/fixtures/store_yaml_recovery.yaml @@ -0,0 +1,18 @@ +esphome: + name: store-yaml-test + areas: + - id: living_room + name: "Living Room" + +host: + +logger: + +api: + +ota: + - platform: esphome + password: recoverme123 + +store_yaml: + allow_unencrypted: true diff --git a/tests/integration/fixtures/store_yaml_recovery_backpressure.yaml b/tests/integration/fixtures/store_yaml_recovery_backpressure.yaml new file mode 100644 index 00000000000..e2b0861a485 --- /dev/null +++ b/tests/integration/fixtures/store_yaml_recovery_backpressure.yaml @@ -0,0 +1,24 @@ +esphome: + name: store-yaml-backpressure + +host: + +logger: + +api: + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + components: [sndbuf_pin_component] + +# Pins the device's socket send buffers for deterministic TCP backpressure +sndbuf_pin_component: + buffer_size: SERVER_SNDBUF + +store_yaml: + allow_unencrypted: true + +substitutions: +# GENERATED_SUBSTITUTIONS diff --git a/tests/integration/raw_api_client.py b/tests/integration/raw_api_client.py index f8f5184dc19..1327c783135 100644 --- a/tests/integration/raw_api_client.py +++ b/tests/integration/raw_api_client.py @@ -1,7 +1,8 @@ """Minimal plaintext native-api client over a raw socket. Reads only when told to, so tests control when the TCP pipe backs up toward -the device; message types are counted and each frame is kept for inspection. +the device; message types are counted and each frame is kept for inspection, +and read_frame() hands them out in arrival order. """ from __future__ import annotations @@ -137,6 +138,7 @@ class RawApiClient: def __init__(self, port: int, recv_buffer_size: int | None = None) -> None: self._port = port self._parser = FrameParser() + self._read_pos = 0 # frames before this index were consumed by read_frame() self.bytes_received = 0 self.frame_counts: Counter[int] = Counter() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -179,21 +181,38 @@ class RawApiClient: loop = asyncio.get_running_loop() await loop.sock_sendall(self._sock, encode_frame(msg_type, payload)) + async def _recv_once(self) -> None: + data = await asyncio.get_running_loop().sock_recv(self._sock, _READ_CHUNK) + assert data, "server closed the connection unexpectedly" + self.bytes_received += len(data) + self.frame_counts.update(self._parser.feed(data)) + async def read_until_frame( self, msg_type: int, timeout: float = 10.0, count: int = 1 ) -> None: """Read until at least count frames of msg_type have been received.""" - loop = asyncio.get_running_loop() async def _read_loop() -> None: while self.frame_counts[msg_type] < count: - data = await loop.sock_recv(self._sock, _READ_CHUNK) - assert data, "server closed the connection unexpectedly" - self.bytes_received += len(data) - self.frame_counts.update(self._parser.feed(data)) + await self._recv_once() await asyncio.wait_for(_read_loop(), timeout) + async def read_frame(self, msg_type: int, timeout: float = 10.0) -> bytes: + """Return the oldest unconsumed payload of msg_type, skipping earlier frames of other types.""" + frames = self._parser.frames + + async def _read_loop() -> bytes: + while True: + while self._read_pos < len(frames): + frame_type, payload = frames[self._read_pos] + self._read_pos += 1 + if frame_type == msg_type: + return payload + await self._recv_once() + + return await asyncio.wait_for(_read_loop(), timeout) + def frames(self, msg_type: int) -> list[bytes]: """Payloads of every frame of msg_type received so far.""" return [payload for kind, payload in self._parser.frames if kind == msg_type] diff --git a/tests/integration/test_store_yaml_recovery.py b/tests/integration/test_store_yaml_recovery.py new file mode 100644 index 00000000000..a8081f83d29 --- /dev/null +++ b/tests/integration/test_store_yaml_recovery.py @@ -0,0 +1,109 @@ +"""End-to-end test of `store_yaml` recovery over the native API. + +Uses the raw client so a test controls when it reads; aioesphomeapi has no get_yaml helper. +""" + +from __future__ import annotations + +import asyncio +import random + +from aioesphomeapi import api_pb2 +import pytest + +from esphome.components.store_yaml import unpack_envelope +from esphome.helpers import zstd_module +from esphome.yaml_util import find_secret_references + +from .log_utils import LineWaiter +from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient +from .types import RunCompiledFunction + +GET_YAML_RESPONSE = MESSAGE_TYPE_OF[api_pb2.GetYamlResponse] + + +async def _read_yaml_stream(client: RawApiClient) -> list[api_pb2.GetYamlResponse]: + """Read GetYamlResponse chunks up to and including the one marked done.""" + responses: list[api_pb2.GetYamlResponse] = [] + while not responses or not responses[-1].done: + payload = await client.read_frame(GET_YAML_RESPONSE) + responses.append(api_pb2.GetYamlResponse.FromString(payload)) + return responses + + +@pytest.mark.asyncio +async def test_store_yaml_recovery( + yaml_config: str, + run_compiled: RunCompiledFunction, + unused_tcp_port: int, +) -> None: + """Stream the embedded YAML back from a host build and check the recovered files.""" + async with run_compiled(yaml_config), RawApiClient(unused_tcp_port) as client: + await client.connect("store_yaml integration test") + + # Clients learn the device can answer get_yaml from its capabilities. + await client.send_message(api_pb2.DeviceCapabilitiesRequest()) + capabilities = api_pb2.DeviceCapabilitiesResponse.FromString( + await client.read_frame(MESSAGE_TYPE_OF[api_pb2.DeviceCapabilitiesResponse]) + ) + assert capabilities.store_yaml.supported + + await client.send_message(api_pb2.GetYamlRequest()) + responses = await _read_yaml_stream(client) + + compressed = b"".join(r.data for r in responses) + assert responses[0].total_size == len(compressed) + + envelope = zstd_module().decompress(compressed) + files = unpack_envelope(envelope) + combined = b"\n".join(files.values()) + assert b"store-yaml-test" in combined + assert b"store_yaml:" in combined + # The inline OTA password comes back as a !secret listed in the skeleton. + assert b"recoverme123" not in envelope + assert "ota_password" in find_secret_references(combined.decode()) + assert b"ota_password: ''" in files["secrets.yaml"] + + +# Random hex barely compresses, so the blob spans many chunks and outgrows both buffers +SERVER_SNDBUF = 8192 +RECV_BUFFER_SIZE = 4096 +CHUNK_LIMIT = 1390 + + +@pytest.mark.asyncio +async def test_store_yaml_recovery_backpressure( + yaml_config: str, + run_compiled: RunCompiledFunction, + unused_tcp_port: int, +) -> None: + """A client that stops reading mid-stream still recovers every byte once it drains.""" + rng = random.Random(16445) + values = {f"blob_{i}": rng.randbytes(60).hex() for i in range(1000)} + config = yaml_config.replace("SERVER_SNDBUF", str(SERVER_SNDBUF)).replace( + "# GENERATED_SUBSTITUTIONS", + "\n".join(f" {name}: {value}" for name, value in values.items()), + ) + log = LineWaiter() + async with ( + run_compiled(config, line_callback=log.callback), + RawApiClient(unused_tcp_port, recv_buffer_size=RECV_BUFFER_SIZE) as client, + ): + # Without the pin the kernel buffers could swallow the stall + await log.wait_for("SO_SNDBUF pinned to", timeout=10.0) + await client.connect("store_yaml backpressure test") + await client.send_message(api_pb2.GetYamlRequest()) + # Stop reading so the device fills both buffers and has to resume later + await asyncio.sleep(0.5) + responses = await _read_yaml_stream(client) + + chunks = [r.data for r in responses] + compressed = b"".join(chunks) + assert len(compressed) > 4 * (SERVER_SNDBUF + RECV_BUFFER_SIZE) + assert len(responses) > 1 and all(len(c) <= CHUNK_LIMIT for c in chunks) + assert not any(r.done for r in responses[:-1]) + assert responses[0].total_size == len(compressed) + combined = b"\n".join( + unpack_envelope(zstd_module().decompress(compressed)).values() + ) + assert all(value.encode() in combined for value in values.values()) diff --git a/tests/unit_tests/components/test_store_yaml.py b/tests/unit_tests/components/test_store_yaml.py new file mode 100644 index 00000000000..99937ebb738 --- /dev/null +++ b/tests/unit_tests/components/test_store_yaml.py @@ -0,0 +1,1312 @@ +"""Tests for store_yaml file gathering, redaction and envelope packing.""" + +from __future__ import annotations + +from pathlib import Path +import re +import struct +import sys + +import pytest +import yaml + +from esphome import yaml_util +from esphome.components import packages, store_yaml +from esphome.components.store_yaml import ( + CONF_ALLOW_UNENCRYPTED, + ENVELOPE_MAGIC, + SECRETS_SKELETON_HEADER, + UNCAPTURED_NOTE_PATH, + _build_secrets_skeleton, + _final_validate, + _gather_files, + _generate_redacted_files, + _iter_nodes, + _pack_envelope, + _read_files_verbatim, + _remote_package_descriptions, + _symlink_descriptions, + _uncaptured_note, + unpack_envelope, +) +import esphome.config_validation as cv +from esphome.core import CORE, EsphomeError, Lambda +import esphome.final_validate as fv +from esphome.helpers import add_class_to_obj +from esphome.yaml_util import ( + DiscoveredYamlFiles, + SensitiveHexInt, + SensitiveInt, + SensitiveStr, + discover_user_yaml_files, +) + +WIFI_PW = {"wifi": {"networks": [{"password": SensitiveStr("hunter2")}]}} +API_KEY = {"api": {"encryption": {"key": SensitiveStr("SUPER_SECRET")}}} +_PKG_PW = "packages:\n common: !include pkg/common.yaml\n" +_SECRET_VAR = "wifi: !include {file: wifi.yaml, vars: {pw: !secret wifi_password}}\n" +_OUTER_VAR = "packages:\n outer: !include {file: outer.yaml, vars: {pw: !secret wifi_password}}\n" +_WIFI_SECRET = "wifi_password: hunter2\n" + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + """An entry yaml, an include and a secrets file as the validated config's root.""" + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "entry.yaml").write_text( + "esphome:\n name: test\napi:\n encryption:\n key: !secret api_key\n" + ) + (project_dir / "wifi.yaml").write_text("ssid: my_ssid\npassword: my_password\n") + (project_dir / "secrets.yaml").write_text("api_key: SUPER_SECRET\n") + CORE.config_path = project_dir / "entry.yaml" + CORE.config = {} + return project_dir + + +@pytest.fixture +def shared(tmp_path: Path) -> Path: + shared = tmp_path / "shared" + shared.mkdir() + return shared + + +def _sources( + project_dir: Path, + *names: str, + secrets: tuple[str, ...] = (), + unresolved: list[str] | None = None, + load_errors: list[str] | None = None, +) -> DiscoveredYamlFiles: + files = [(project_dir / name).resolve() for name in names] + return DiscoveredYamlFiles( + files, + {(project_dir / name).resolve() for name in secrets}, + unresolved or [], + load_errors or [], + {path: [path] for path in files}, + ) + + +def _gather_redacted(discovered: DiscoveredYamlFiles) -> dict[str, bytes]: + entries, secret_rels = _gather_files(discovered) + return dict( + _generate_redacted_files(entries, secret_rels, _remote_package_descriptions()) + ) + + +def _redact( + project: Path, files: dict[str, str], config: dict | None = None +) -> dict[str, bytes]: + """Write `files`, register the secrets file as discovery would, and redact.""" + for name, text in files.items(): + (project / name).parent.mkdir(parents=True, exist_ok=True) + (project / name).write_text(text) + if config is not None: + CORE.config = config + secrets = yaml.safe_load((project / "secrets.yaml").read_text()) or {} + for name, value in secrets.items(): + yaml_util._SECRET_VALUES[str(value)] = name + return _gather_redacted( + _sources(project, *files, "secrets.yaml", secrets=("secrets.yaml",)) + ) + + +def _run_final_validate(full_config: dict, config: dict) -> dict: + token = fv.full_config.set(full_config) + try: + return _final_validate(config) + finally: + fv.full_config.reset(token) + + +def test_gather_maps_rel_paths_and_reads_verbatim(project: Path) -> None: + """Entries map to root-relative paths; `include_secrets: true` keeps the bytes.""" + discovered = _sources( + project, "entry.yaml", "secrets.yaml", secrets=("secrets.yaml",) + ) + entries, secret_rels = _gather_files(discovered) + assert dict(entries) == { + "entry.yaml": project / "entry.yaml", + "secrets.yaml": project / "secrets.yaml", + } + assert secret_rels == {"secrets.yaml"} + contents = dict(_read_files_verbatim(entries)) + assert contents["secrets.yaml"] == (project / "secrets.yaml").read_bytes() + + +def test_gather_uses_relative_path_for_external_files( + project: Path, tmp_path: Path +) -> None: + """Files outside the project root keep a ``..`` path.""" + sibling = tmp_path / "outside.yaml" + sibling.write_text("foo: bar\n") + discovered = _sources(project, "entry.yaml") + discovered.files.append(sibling) + files, _ = _gather_files(discovered) + assert "../outside.yaml" in dict(files) + + +def test_gather_raises_on_load_errors(project: Path) -> None: + discovered = _sources(project, "entry.yaml", load_errors=["oops.yaml: boom"]) + with pytest.raises(EsphomeError, match="oops.yaml"): + _gather_files(discovered) + + +def test_read_files_verbatim_raises_on_unreadable_file( + project: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + entries, _ = _gather_files(_sources(project, "entry.yaml", "wifi.yaml")) + orig_read_bytes = Path.read_bytes + + def fake_read_bytes(self: Path) -> bytes: + if self.name == "wifi.yaml": + raise OSError("permission denied") + return orig_read_bytes(self) + + monkeypatch.setattr(Path, "read_bytes", fake_read_bytes) + with pytest.raises(EsphomeError, match="wifi.yaml"): + _read_files_verbatim(entries) + + +def test_gather_warns_on_unresolved_includes( + project: Path, caplog: pytest.LogCaptureFixture +) -> None: + discovered = _sources(project, "entry.yaml", unresolved=["${board}.yaml"]) + with caplog.at_level("WARNING", logger="esphome.components.store_yaml"): + files, _ = _gather_files(discovered) + assert len(files) == 1 + assert any( + "${board}.yaml" in r.message and "not contain" in r.message + for r in caplog.records + ) + + +def test_gather_raises_esphome_error_on_cross_anchor_path( + project: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A path with no common anchor raises EsphomeError, not ValueError.""" + + def fake_relative_to(self: Path, other: Path, walk_up: bool = False) -> Path: + raise ValueError("paths have different anchors") + + monkeypatch.setattr(Path, "relative_to", fake_relative_to) + with pytest.raises(EsphomeError, match="does not share a root"): + _gather_files(_sources(project, "entry.yaml")) + + +def test_redacted_secrets_file_becomes_skeleton(project: Path) -> None: + files = _redact(project, {"entry.yaml": (project / "entry.yaml").read_text()}) + skeleton = files["secrets.yaml"].decode() + assert skeleton.startswith(SECRETS_SKELETON_HEADER) + assert "api_key: ''" in skeleton + assert b"SUPER_SECRET" not in b"".join(files.values()) + assert "key: !secret 'api_key'" in files["entry.yaml"].decode() + + +def test_redacted_symlinked_secrets_outside_root(project: Path, tmp_path: Path) -> None: + """A secrets.yaml linked to another name outside the root gets a root skeleton.""" + target = tmp_path / "actual_creds.yaml" + target.write_text("api_key: SUPER_SECRET\n") + link = project / "secrets.yaml" + link.unlink() + link.symlink_to(target) + resolved = link.resolve() + files = _gather_redacted( + DiscoveredYamlFiles([project / "entry.yaml", resolved], {resolved}) + ) + assert "api_key: ''" in files["secrets.yaml"].decode() + assert b"SUPER_SECRET" not in b"".join(files.values()) + + +@pytest.mark.parametrize( + ("source", "config", "expected", "name"), + [ + pytest.param( + "password: 'my_password'\n", + {"wifi": [{"password": SensitiveStr("my_password")}]}, + b"password: !secret 'wifi_password'\n", + "wifi_password", + id="string", + ), + pytest.param( + "password: word\n", + {"wifi": [{"password": SensitiveStr("word")}]}, + b"password: !secret 'wifi_password'\n", + "wifi_password", + id="value_inside_its_own_key_name", + ), + pytest.param( + "network_key: 0x00112233445566778899aabbccddeeff\nname: node 12345\n", + { + "openthread": { + "network_key": SensitiveHexInt(0x00112233445566778899AABBCCDDEEFF) + } + }, + b"network_key: !secret 'openthread_network_key'\nname: node 12345\n", + "openthread_network_key", + id="hex_int", + ), + pytest.param( + "network_key: 12345\nname: node 12345\n", + {"openthread": {"network_key": SensitiveInt(12345)}}, + b"network_key: !secret 'openthread_network_key'\nname: node 12345\n", + "openthread_network_key", + id="int", + ), + ], +) +def test_redacted_inline_value_becomes_secret_ref( + project: Path, source: str, config: dict, expected: bytes, name: str +) -> None: + """An inline sensitive value becomes a path-named !secret listed in the skeleton.""" + files = _redact(project, {"wifi.yaml": source}, config) + assert files["wifi.yaml"] == expected + assert f"{name}: ''" in files["secrets.yaml"].decode() + + +@pytest.mark.parametrize( + ("files", "config", "match"), + [ + pytest.param( + {"thread.yaml": 'network_key: "0x1234"\n'}, + {"openthread": {"network_key": SensitiveHexInt(0x1234)}}, + "network_key in thread.yaml", + id="quoted_int", + ), + pytest.param( + {"wifi.yaml": "ssid: my_ssid\n"}, + {"wifi": [{"password": SensitiveStr("not_in_any_file")}]}, + "wifi.password", + id="not_locatable", + ), + pytest.param( + { + "wifi.yaml": "password: my_password\nx: !lambda 'return \"my_password\";'\n" + }, + {"wifi": [{"password": SensitiveStr("my_password")}]}, + "embedded", + id="lambda", + ), + pytest.param( + { + "entry.yaml": "password: my_password\n" + "pkg: !include {file: wifi.yaml, vars: {url: 'http://user:my_password@host'}}\n" + }, + {"wifi": [{"password": SensitiveStr("my_password")}]}, + "embedded|URL with credentials", + id="include_vars", + ), + pytest.param( + { + "entry.yaml": "password: my_password\n" + "pkg: !include {file: wifi.yaml, condition: \"${ x == 'my_password' }\"}\n" + }, + {"wifi": [{"password": SensitiveStr("my_password")}]}, + "embedded", + id="include_condition", + ), + pytest.param( + { + "entry.yaml": "packages:\n - url: https://host/repo\n password: hunter2\n" + }, + {}, + "packages.password in entry.yaml", + id="private_package_password", + ), + pytest.param( + { + "pkg/common.yaml": "wifi:\n password: old_pw\n", + "entry.yaml": _PKG_PW + "wifi:\n password: new_pw\n", + }, + {"wifi": {"networks": [{"password": SensitiveStr("new_pw")}]}}, + "wifi.password in pkg/common.yaml", + id="overridden_package_password_regrouped_by_validation", + ), + pytest.param( + { + "pkg/common.yaml": "mqtt:\n password: old_pw\n", + "entry.yaml": _PKG_PW + + "wifi:\n password: hunter2\nmqtt:\n password: hunter2\n", + }, + {**WIFI_PW, "mqtt": {"password": SensitiveStr("hunter2")}}, + "mqtt.password in pkg/common.yaml", + id="shared_password_keeps_every_path", + ), + pytest.param( + { + "pkg/common.yaml": "mqtt:\n password: old_pw\n", + "entry.yaml": _PKG_PW + 'mqtt:\n password: ""\n', + }, + {"mqtt": {"password": SensitiveStr("")}}, + "mqtt.password in pkg/common.yaml", + id="overridden_by_empty_password", + ), + pytest.param( + {"wifi.yaml": "substitutions:\n prefix_hunter2: x\npassword: hunter2\n"}, + WIFI_PW, + "inside a mapping key under substitutions", + id="key_embeds_password", + ), + pytest.param( + { + "legacy.yaml": "mqtt:\n broker: b\n password: hunter2\n", + "entry.yaml": "packages:\n legacy: !include legacy.yaml\n" + "mqtt: !remove\nwifi:\n password: pw2\n", + }, + {"wifi": {"networks": [{"password": SensitiveStr("pw2")}]}}, + "mqtt.password in legacy.yaml", + id="removed_component_keeps_credential_keys", + ), + pytest.param( + { + "vpn.yaml": "wireguard:\n private_key: hunter2\n", + "entry.yaml": "packages:\n vpn: !include vpn.yaml\nwireguard: !remove\n", + }, + API_KEY, + "wireguard.private_key in vpn.yaml", + id="removed_component_credential_known_by_name", + ), + pytest.param( + { + "player.yaml": 'sendspin:\n static_pairing_code: "123456"\n', + "entry.yaml": "packages:\n p: !include player.yaml\nsendspin: !remove\n", + }, + API_KEY, + "sendspin.static_pairing_code in player.yaml", + id="removed_credential_outside_the_common_words", + ), + pytest.param( + { + "base.yaml": "wifi:\n ssid: Old\n password: old_password123\n", + "entry.yaml": "<<: !include base.yaml\nwifi:\n ssid: OpenNetwork\n", + }, + {"wifi": {"networks": [{"ssid": SensitiveStr("OpenNetwork")}]}}, + "wifi.password in base.yaml", + id="credential_shadowed_by_a_merge", + ), + pytest.param( + { + "base.yaml": "dsmr:\n decryption_key: 00112233445566778899AABBCCDDEEFF\n", + "entry.yaml": "<<: !include base.yaml\ndsmr:\n max_telegram_length: 1500\n", + }, + API_KEY, + "dsmr.decryption_key in base.yaml", + id="bind_key_shadowed_by_a_merge", + ), + pytest.param( + { + "pkg.yaml": "packet_transport:\n encryption: old_key_123\n", + "entry.yaml": "packages:\n p: !include pkg.yaml\n" + "packet_transport:\n encryption: !secret new_key\n", + "secrets.yaml": "new_key: new_key_456\n", + }, + {"packet_transport": {"encryption": {"key": SensitiveStr("new_key_456")}}}, + "packet_transport.encryption in pkg.yaml", + id="overridden_shorthand_key", + ), + pytest.param( + { + "scripts.yaml": "script:\n - id: old_script\n then:\n" + " - wifi.configure:\n ssid: x\n password: hunter2\n", + "entry.yaml": "packages:\n s: !include scripts.yaml\n" + "script:\n - id: !remove old_script\n", + }, + API_KEY, + "password in scripts.yaml", + id="list_item_removed_by_id", + ), + pytest.param( + { + "entry.yaml": "packages:\n vpn:\n wireguard:\n private_key: hunter2\n" + " off:\n wireguard: !remove\n" + }, + API_KEY, + "wireguard.private_key in entry.yaml", + id="removed_by_an_inline_package", + ), + pytest.param( + {"wifi.yaml": "wifi:\n hunter2:\n child: x\n"}, + {"ota": [{"password": SensitiveStr("hunter2")}]}, + "mapping key under wifi", + id="password_as_mapping_key", + ), + pytest.param( + { + "ld.yaml": "ld2410:\n bluetooth_password: !lambda 'return \"hunter2\";'\n" + }, + { + "ld2410": { + "bluetooth_password": add_class_to_obj( + Lambda('return "hunter2";'), yaml_util.Sensitive + ) + } + }, + "set by a lambda", + id="credential_from_a_lambda", + ), + pytest.param( + { + "ota.yaml": "password: !secret ota_pw\nurl: http://host/?pw=hunter2\n", + "secrets.yaml": "ota_pw: hunter2\n", + }, + {"ota": [{"password": SensitiveStr("hunter2")}]}, + "inside url in ota.yaml", + id="secret_inside_a_larger_value", + ), + pytest.param( + {"fp.yaml": "fingerprint_grow:\n password: !lambda 'return 12345678;'\n"}, + { + "fingerprint_grow": { + "password": add_class_to_obj( + Lambda("return 12345678;"), yaml_util.Sensitive + ) + } + }, + "set by a lambda", + id="credential_from_a_lambda_number", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n a: ${b}\n b: ${a}\nwifi:\n password: ${a}\n" + }, + WIFI_PW, + "cannot be confirmed", + id="alias_cycle", + ), + pytest.param( + { + "entry.yaml": "packages:\n base:\n api:\n encryption:\n key: old_key\n" + "api:\n encryption:\n key: !secret api_key\n" + }, + API_KEY, + "packages.base.api.encryption.key", + id="inline_package_override", + ), + pytest.param( + { + "pkg/common.yaml": "substitutions:\n pkg_pw: !secret pw\n", + "entry.yaml": "substitutions:\n pkg_pw: hunter2\n" + + _PKG_PW + + " private:\n url: https://host/repo\n password: ${pkg_pw}\n", + "secrets.yaml": "pw: other\n", + }, + {}, + "cannot be confirmed", + id="substitution_defined_twice", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n u: !secret u\n p: !secret p\n" + 'x: !lambda \'use("https://${u}:${p}@host", "https://bob:hunter2@other");\'\n', + "secrets.yaml": "u: bob\np: pw\n", + }, + {}, + "URL with credentials", + id="second_url_in_a_scalar", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n pkg_password: hunter2\n" + "packages:\n private:\n url: https://host/repo\n password: ${pkg_password}\n" + }, + {}, + "cannot be confirmed", + id="literal_substitution", + ), + pytest.param( + { + "entry.yaml": "packages:\n private:\n url: https://host/repo\n" + " password: ${ pkg_password }x\n" + }, + {}, + "cannot be confirmed", + id="expression", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n git_host: host\n" + "packages:\n - url: https://user:hunter2@${git_host}/repo\n" + }, + {}, + "URL with credentials", + id="mixed_url", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n user: bob\n pw: hunter2\n" + "packages:\n - url: https://${user}:${pw}@host/repo\n" + }, + {}, + "URL with credentials", + id="literal_url_parts", + ), + pytest.param( + { + "entry.yaml": "wifi: !include {file: wifi.yaml, vars: {pw: other_pw}}\n", + "wifi.yaml": "password: ${pw}\n", + }, + WIFI_PW, + "cannot be confirmed", + id="literal_include_var", + ), + pytest.param( + { + "entry.yaml": _OUTER_VAR, + "outer.yaml": "packages:\n inner: !include inner.yaml\n", + "inner.yaml": "defaults:\n pw: realpw\nmqtt:\n password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + {"mqtt": {"password": SensitiveStr("other_pw")}}, + "cannot be confirmed", + id="nested_include_callee_default_wins", + ), + pytest.param( + { + "entry.yaml": "cfg: !include {file: base.yaml, vars: !secret common_vars}\n", + "base.yaml": "password: ${pw}\n", + "secrets.yaml": "common_vars:\n pw: hunter2\n", + }, + WIFI_PW, + "could not redact", + id="credential_only_inside_a_secret_mapping", + ), + pytest.param( + { + "entry.yaml": _SECRET_VAR + "wifi2: !include wifi.yaml\n", + "wifi.yaml": "defaults:\n pw: placeholder\npassword: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + "cannot be confirmed", + id="second_include_uses_literal_default", + ), + ], +) +def test_redacted_fails_closed( + project: Path, files: dict[str, str], config: dict, match: str +) -> None: + """An unprovable credential fails the build, and the message never echoes one.""" + with pytest.raises(EsphomeError, match=match) as err: + _redact(project, files, config) + assert not any( + str(node) in str(err.value) + for _, node, is_key in _iter_nodes(config) + if not is_key and isinstance(node, yaml_util.Sensitive) and node + ) + + +@pytest.mark.parametrize( + ("files", "config", "expect"), + [ + pytest.param( + {"wifi.yaml": "password: SUPER_SECRET\n"}, + {"wifi": [{"password": SensitiveStr("SUPER_SECRET")}]}, + {"wifi.yaml": "password: !secret 'api_key'\n"}, + id="inline_value_reuses_the_secret_name", + ), + pytest.param( + { + "wifi.yaml": "password: first_pw\n", + "wifi2.yaml": "password: second_pw\n", + }, + { + "wifi": [ + {"password": SensitiveStr("first_pw")}, + {"password": SensitiveStr("second_pw")}, + ] + }, + { + "wifi.yaml": "password: !secret 'wifi_password'\n", + "wifi2.yaml": "password: !secret 'wifi_password_2'\n", + }, + id="colliding_names_stay_distinct", + ), + pytest.param( + {"wifi.yaml": "ssid: my_ssid\n"}, + {"mqtt": {"password": SensitiveStr("")}}, + {"wifi.yaml": "ssid: my_ssid\n"}, + id="empty_sensitive_value_is_not_swapped", + ), + pytest.param( + { + "entry.yaml": "api:\n encryption:\n key: !secret api_key\n" + " actions:\n - action: press\n variables:\n key: string\n" + }, + API_KEY, + {"entry.yaml": "key: string"}, + id="action_variable_named_key", + ), + pytest.param( + { + "entry.yaml": "api:\n encryption:\n key: !secret api_key\n" + "binary_sensor: !include buttons.yaml\n", + "buttons.yaml": "- platform: matrix_keypad\n key: A\n", + }, + API_KEY, + {"buttons.yaml": "key: A"}, + id="list_file_key_is_not_the_encryption_key", + ), + pytest.param( + { + "entry.yaml": "api:\n encryption:\n key: !secret api_key\n", + "a.yaml": "platform: matrix_keypad\nkey: A\n", + "secrets.yaml": "api_key: SUPER_SECRET\n", + }, + API_KEY, + {"a.yaml": "key: A"}, + id="include_dir_fragment_key_is_not_the_encryption_key", + ), + pytest.param( + { + "entry.yaml": "wifi:\n password: !include password.yaml\n", + "password.yaml": "hunter2\n", + }, + WIFI_PW, + {"entry.yaml": "password: !include 'password.yaml'"}, + id="credential_loaded_by_include", + ), + pytest.param( + { + "entry.yaml": "sensor:\n - platform: xiaomi_lywsd03mmc\n" + " bindkey: !secret device_key\n", + "secrets.yaml": "device_key: 00112233445566778899aabbccddeeff\n", + }, + {"sensor": [{"bindkey": SensitiveStr("00112233445566778899AABBCCDDEEFF")}]}, + {"entry.yaml": "bindkey: !secret 'device_key'"}, + id="lowercase_bind_key_secret", + ), + pytest.param( + { + "entry.yaml": "sensor:\n - platform: xiaomi_lywsd03mmc\n" + " bindkey: 00112233445566778899aabbccddeeff\n", + }, + {"sensor": [{"bindkey": SensitiveStr("00112233445566778899AABBCCDDEEFF")}]}, + {"entry.yaml": "bindkey: !secret 'sensor_bindkey'"}, + id="inline_lowercase_bind_key", + ), + pytest.param( + { + "entry.yaml": _SECRET_VAR, + "wifi.yaml": "password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="secret_include_var", + ), + pytest.param( + { + "entry.yaml": "wifi: !include wifi.yaml\n", + "wifi.yaml": "defaults:\n pw: !secret wifi_password\npassword: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="secret_default", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n pw: !secret wifi_password\nwifi: !include wifi.yaml\n", + "wifi.yaml": "password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="secret_substitution", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n pw: hunter2\n" + _SECRET_VAR, + "wifi.yaml": "password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="var_shadows_literal_substitution", + ), + pytest.param( + { + "entry.yaml": _SECRET_VAR, + "wifi.yaml": "defaults:\n pw: placeholder\npassword: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="var_shadows_literal_default", + ), + pytest.param( + { + "entry.yaml": _OUTER_VAR, + "outer.yaml": "wifi: !include wifi.yaml\n", + "wifi.yaml": "password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {}, + id="nested_include_inherits_caller_vars", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n pkg_password: !secret pw\n" + "packages:\n private:\n url: https://host/repo\n password: ${pkg_password}\n", + "secrets.yaml": "pw: hunter2\n", + }, + {}, + {}, + id="git_password_from_secret_substitution", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n user: !secret user\n pw: !secret pw\n" + "packages:\n - url: https://${user}:${pw}@host/repo\n", + "secrets.yaml": "user: bob\npw: hunter2\n", + }, + {}, + {}, + id="url_parts_from_secrets", + ), + pytest.param( + { + "entry.yaml": "http_request:\n request_headers: !secret headers\n" + " more: !secret list_headers\n", + "secrets.yaml": "headers:\n Authorization: Bearer hunter2\n" + "list_headers:\n - Bearer hunter2\n", + }, + {}, + { + "entry.yaml": "request_headers: !secret 'headers'\n more: !secret 'list_headers'", + "secrets.yaml": "headers: ''", + }, + id="container_secrets", + ), + pytest.param( + { + "entry.yaml": "http: !include {file: h.yaml, vars: {headers: !secret headers}}\n", + "h.yaml": "request_headers: ${headers}\n", + "secrets.yaml": "headers:\n Authorization: Bearer hunter2\n", + }, + {}, + {"entry.yaml": "headers: !secret 'headers'"}, + id="container_secret_in_include_vars", + ), + pytest.param( + { + "entry.yaml": "cfg: !include {file: base.yaml, vars: !secret common_vars}\n", + "base.yaml": "name: ${name}\n", + "secrets.yaml": "common_vars:\n name: hunter2\n", + }, + {}, + {"entry.yaml": "vars: !secret 'common_vars'"}, + id="secret_vars_mapping", + ), + pytest.param( + { + "entry.yaml": "packages:\n - url: !secret pkg_url\n", + "secrets.yaml": "pkg_url: https://user:hunter2@host/repo\n", + }, + {}, + {"entry.yaml": "url: !secret 'pkg_url'"}, + id="secret_url", + ), + pytest.param( + { + "packages/base.yaml": "wifi: !include ../wifi.yaml\n", + "entry.yaml": "packages: !include_dir_named packages\n", + }, + {}, + { + "entry.yaml": "wifi: !include 'wifi.yaml'", + "packages/base.yaml": "wifi: !include '../wifi.yaml'", + }, + id="dir_include_rebases_nested_include", + ), + pytest.param( + { + "entry.yaml": "!include\n file: base.yaml\n vars:\n name: kitchen\n", + "base.yaml": "esphome:\n name: ${name}\n", + }, + {}, + {"entry.yaml": "!include\nfile: base.yaml\nvars:\n name: kitchen\n"}, + id="document_include_keeps_vars", + ), + pytest.param( + {"wifi.yaml": "ssid: !literal '${ssid}'\n"}, + {}, + {"wifi.yaml": "ssid: !literal '${ssid}'\n"}, + id="literal", + ), + pytest.param( + {"fp.yaml": "password: 0\naccuracy_decimals: 0\n"}, + {"fingerprint_grow": {"password": SensitiveInt(0)}}, + { + "fp.yaml": "password: !secret 'fingerprint_grow_password'\naccuracy_decimals: 0\n" + }, + id="zero_credential_swapped_only_at_its_key", + ), + pytest.param( + { + "fp.yaml": "substitutions:\n fp_password: 12345678\n" + "fingerprint_grow:\n - password: 12345678\n - password: ${fp_password}\n" + "sensor:\n accuracy_decimals: 0\n" + }, + {"fingerprint_grow": [{"password": SensitiveInt(12345678)}] * 2}, + { + "fp.yaml": "fp_password: !secret 'fingerprint_grow_password'\n" + "fingerprint_grow:\n - password: !secret 'fingerprint_grow_password'\n" + " - password: ${fp_password}\nsensor:\n accuracy_decimals: 0\n" + }, + id="int_credential_reused_through_a_substitution", + ), + pytest.param( + { + "fp.yaml": "fingerprint_grow:\n - password: 12345678\n - password: '12345678'\n" + }, + {"fingerprint_grow": [{"password": SensitiveInt(12345678)}] * 2}, + { + "fp.yaml": "fingerprint_grow:\n - password: !secret 'fingerprint_grow_password'\n" + " - password: !secret 'fingerprint_grow_password'\n" + }, + id="int_credential_written_quoted", + ), + pytest.param( + { + "entry.yaml": "packages:\n base:\n api:\n actions:\n" + " - action: login\n variables:\n" + " password: string\n" + "wifi:\n password: !secret wifi_password\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {"entry.yaml": "password: string"}, + id="inline_package_field_named_password", + ), + pytest.param( + { + "wifi.yaml": "substitutions:\n wifi_password: hunter2\n" + "wifi:\n password: ${wifi_password}\n" + }, + {"wifi": [{"password": SensitiveStr("hunter2")}]}, + {"wifi.yaml": "password: ${wifi_password}"}, + id="substitution_definition", + ), + pytest.param( + { + "entry.yaml": "packages:\n board: !include ${board}.yaml\n" + " off: !include {file: missing.yaml, condition: false}\n" + "logger:\n enabled: true\nwifi:\n password: !secret wifi_password\n" + "sensor:\n - !include s.yaml\n - !include s.yaml\n", + "s.yaml": "platform: template\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {"entry.yaml": "enabled: true", "s.yaml": "platform: template"}, + id="templated_disabled_and_repeated_includes", + ), + pytest.param( + { + "entry.yaml": "substitutions:\n base_pw: !secret wifi_password\n pw: ${base_pw}\n" + "wifi:\n password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {"entry.yaml": "base_pw: !secret 'wifi_password'"}, + id="substitution_alias_of_a_secret", + ), + pytest.param( + { + "entry.yaml": "packages:\n base: !include base.yaml\n", + "base.yaml": "packages:\n loop: !include {file: base.yaml, condition: false}\n" + "wifi:\n password: !secret wifi_password\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {"base.yaml": "password: !secret 'wifi_password'"}, + id="disabled_self_include_is_skipped", + ), + pytest.param( + { + "entry.yaml": "packages:\n base:\n substitutions:\n" + " pw: !secret wifi_password\nwifi:\n password: ${pw}\n", + "secrets.yaml": _WIFI_SECRET, + }, + WIFI_PW, + {"entry.yaml": "pw: !secret 'wifi_password'"}, + id="substitution_defined_in_an_inline_package", + ), + pytest.param( + { + "fp.yaml": "packages:\n base:\n substitutions:\n" + " fp_password: 12345678\n" + "fingerprint_grow:\n password: ${fp_password}\n" + }, + {"fingerprint_grow": {"password": SensitiveInt(12345678)}}, + {"fp.yaml": "fp_password: !secret 'fingerprint_grow_password'"}, + id="int_substitution_defined_in_an_inline_package", + ), + pytest.param( + { + "wifi.yaml": "button:\n - on_press:\n wifi.configure:\n" + " ssid: !lambda 'return id(ssid_text).state;'\n" + " password: !lambda 'return id(pw_text).state;'\n" + }, + { + "button": [ + { + "on_press": [ + { + "then": [ + { + "wifi.configure": { + key: add_class_to_obj( + Lambda(f"return id({key}_text).state;"), + yaml_util.Sensitive, + ) + for key in ("ssid", "password") + } + } + ] + } + ] + } + ] + }, + { + "wifi.yaml": "password: !lambda |-\n return id(pw_text).state;\n" + }, + id="lambda_reading_an_entity_is_kept", + ), + ], +) +def test_redacted_hides_credentials( + project: Path, files: dict[str, str], config: dict, expect: dict[str, str] +) -> None: + """The redacted files contain the expected text and never `hunter2`.""" + out = _redact(project, files, config) + assert b"hunter2" not in b"".join(out.values()) + for rel, text in expect.items(): + assert text in out[rel].decode() + + +@pytest.mark.parametrize("int_first", [True, False]) +def test_redacted_shared_text_keeps_substring_scan( + project: Path, int_first: bool +) -> None: + """A text that is an integer credential in one place and a string in another + is still scanned as a substring, whichever is collected first.""" + thread = ("openthread", {"network_key": SensitiveInt(12345678)}) + mqtt = ("mqtt", {"password": SensitiveStr("12345678")}) + config = dict([thread, mqtt] if int_first else [mqtt, thread]) + files = { + "node.yaml": "network_key: 12345678\npassword: '12345678'\nname: node12345678\n" + } + with pytest.raises(EsphomeError, match="embedded"): + _redact(project, files, config) + + +def test_redacted_symlinked_package_loads_relative_to_its_name( + project: Path, shared: Path +) -> None: + """A directory include inside a symlinked file resolves beside the link.""" + (shared / "pkg.yaml").write_text("sensor: !include_dir_merge_list sensors\n") + (project / "pkg.yaml").symlink_to(shared / "pkg.yaml") + (project / "sensors").mkdir() + (project / "sensors" / "a.yaml").write_text("- platform: template\n name: a\n") + (project / "entry.yaml").write_text("packages:\n p: !include pkg.yaml\n") + files = _gather_redacted(discover_user_yaml_files(project / "entry.yaml")) + assert "platform: template" in files["pkg.yaml"].decode() + + +def test_redacted_symlinked_dir_include_rebases_where_the_loader_reads( + project: Path, shared: Path +) -> None: + """`..` after a symlinked directory goes where the loader reads: through the link + on POSIX, lexically on Windows.""" + (shared / "pkgs").mkdir() + (shared / "pkgs" / "base.yaml").write_text("wifi: !include ../wifi.yaml\n") + (shared / "wifi.yaml").write_bytes(b"ssid: shared\n") + (project / "wifi.yaml").write_bytes(b"ssid: project\n") + (project / "pkgs").symlink_to(shared / "pkgs") + (project / "entry.yaml").write_text("packages: !include_dir_named pkgs\n") + files = _gather_redacted(discover_user_yaml_files(project / "entry.yaml")) + # The file itself is captured at its real location, so its own include stays put. + assert "!include '../wifi.yaml'" in files["../shared/pkgs/base.yaml"].decode() + if sys.platform == "win32": + assert "wifi: !include 'wifi.yaml'" in files["entry.yaml"].decode() + assert files["wifi.yaml"] == b"ssid: project\n" + else: + assert "wifi: !include '../shared/wifi.yaml'" in files["entry.yaml"].decode() + assert files["../shared/wifi.yaml"] == b"ssid: shared\n" + + +def test_include_through_a_directory_symlink( + project: Path, shared: Path, tmp_path: Path +) -> None: + """The include is pointed at the captured file and the note names the link, + also when the config directory itself is reached through a symlink.""" + (shared / "wifi.yaml").write_text("ssid: shared\n") + (project / "linked").symlink_to(shared) + (project / "entry.yaml").write_text("wifi: !include linked/wifi.yaml\n") + via = tmp_path / "via" + via.symlink_to(project) + CORE.config_path = via / "entry.yaml" + discovered = discover_user_yaml_files(via / "entry.yaml") + assert _symlink_descriptions(discovered) == ["linked -> ../shared"] + files = _gather_redacted(discovered) + assert "wifi: !include '../shared/wifi.yaml'" in files["entry.yaml"].decode() + assert files["../shared/wifi.yaml"] == b"ssid: shared\n" + + +def test_gather_emits_every_alias_of_one_file(project: Path, shared: Path) -> None: + """Every name linking to one file appears in the envelope, in both modes.""" + (shared / "sensor.yaml").write_bytes(b"platform: template\n") + (project / "a.yaml").symlink_to(shared / "sensor.yaml") + (project / "b.yaml").symlink_to(shared / "sensor.yaml") + (project / "entry.yaml").write_text( + "sensor:\n - !include {file: a.yaml, vars: {n: 1}}\n" + " - !include {file: b.yaml, vars: {n: 2}}\n" + ) + entries, _ = _gather_files(discover_user_yaml_files(project / "entry.yaml")) + assert {rel for rel, _ in entries} == {"entry.yaml", "a.yaml", "b.yaml"} + assert dict(_read_files_verbatim(entries))["b.yaml"] == b"platform: template\n" + files = _gather_redacted(discover_user_yaml_files(project / "entry.yaml")) + assert files["a.yaml"] == files["b.yaml"] == b"platform: template\n" + + +@pytest.mark.parametrize( + ("files", "config", "in_log", "not_in_log"), + [ + pytest.param( + { + "wifi.yaml": "ssid: Kitchen\nname: Kitchen Temperature\n", + "secrets.yaml": "wifi_ssid: Kitchen\n", + }, + {"wifi": [{"ssid": SensitiveStr("Kitchen")}]}, + "not redacted inside name in wifi.yaml", + "", + id="secret_sourced_overlap", + ), + ], +) +def test_redacted_warns_without_failing( + project: Path, + caplog: pytest.LogCaptureFixture, + files: dict[str, str], + config: dict, + in_log: str, + not_in_log: str, +) -> None: + """Collisions with a known value are swapped or kept with a warning, not an error.""" + out = _redact(project, files, config) + assert b"!secret" in out["wifi.yaml"] + assert in_log in caplog.text + assert not not_in_log or not_in_log not in caplog.text + + +def test_uncaptured_note_lists_content() -> None: + rel, content = _uncaptured_note( + ["${board}.yaml"], ["https://github.com/org/repo@main"], ["linked -> ../shared"] + ) + assert rel == UNCAPTURED_NOTE_PATH + text = content.decode() + assert text.startswith("# store_yaml:") + for line in ( + "${board}.yaml", + "https://github.com/org/repo@main", + "linked -> ../shared", + ): + assert f"# {line}" in text + assert _uncaptured_note([], [], []) is None + + +def test_remote_package_descriptions() -> None: + """Sources format as url@ref or url; userinfo is masked and a `!secret` URL named.""" + assert _remote_package_descriptions() == [] + data = packages._get_data() + for url, ref in ( + ("https://github.com/org/repo", "main"), + ("https://github.com/org/other", None), + ("https://user:pw@host/repo", "main"), + ("https://user:pw@host/private", None), + ("https://github.com/org/x", "v1-secret"), + ("https://host/repo?access_token=tok123", None), + ("https://host/repo?k=hunter2abc", None), + ): + data.remote_sources[packages.RemotePackageSource(url, ref)] = None + yaml_util._SECRET_VALUES["https://user:pw@host/private"] = "pkg_url" + yaml_util._SECRET_VALUES["v1-secret"] = "private_ref" + yaml_util._SECRET_VALUES["tok123"] = "git_token" + yaml_util._SECRET_VALUES["hunter2"] = "pw" + yaml_util._SECRET_VALUES["hunter2abc"] = "long_token" + assert _remote_package_descriptions() == [ + "https://github.com/org/repo@main", + "https://github.com/org/other", + "https://***@host/repo@main", + "!secret pkg_url", + "https://github.com/org/x@!secret private_ref", + "https://host/repo?access_token=", + "https://host/repo?k=", + ] + + +def test_secrets_skeleton_quotes_awkward_keys() -> None: + """Keys that YAML would read as other types load back as the same strings.""" + keys = {"true", "123", "a: b", "api_key"} + skeleton = _build_secrets_skeleton(keys).decode() + assert skeleton.startswith(SECRETS_SKELETON_HEADER) + assert "'true': ''" in skeleton + assert yaml.safe_load(skeleton) == dict.fromkeys(keys, "") + assert _build_secrets_skeleton(set()) == SECRETS_SKELETON_HEADER.encode() + + +def test_pack_envelope_roundtrip() -> None: + files = [ + ("entry.yaml", b"esphome:\n name: test\n"), + ("dossiers/élan.yaml", b"ssid: a\n"), + ] + assert unpack_envelope(_pack_envelope(files)) == dict(files) + + +def test_pack_envelope_rejects_duplicate_paths() -> None: + with pytest.raises(EsphomeError, match="duplicate"): + _pack_envelope([("entry.yaml", b"a: 1\n"), ("entry.yaml", b"b: 2\n")]) + + +_ONE = _pack_envelope([("entry.yaml", b"esphome:\n")]) +_ENTRY = struct.pack(" None: + with pytest.raises(EsphomeError, match=match): + unpack_envelope(blob) + + +@pytest.mark.parametrize( + ("api", "ok"), + [ + ({"encryption": {"key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="}}, True), + ({}, False), + # Keyless encryption accepts the all-zeros key, so it does not count. + ({"encryption": {}}, False), + ], + ids=["encrypted", "no_encryption", "keyless_encryption"], +) +def test_final_validate_requires_a_key(api: dict, ok: bool) -> None: + config = {CONF_ALLOW_UNENCRYPTED: False} + if ok: + assert _run_final_validate({"api": api}, config) is config + return + with pytest.raises(cv.Invalid, match="requires API encryption"): + _run_final_validate({"api": api}, config) + + +@pytest.mark.parametrize( + ("include_secrets", "expected"), + [ + (False, "without API encryption"), + (True, "verbatim contents of secrets.yaml"), + ], +) +def test_final_validate_allows_unencrypted_with_escape_hatch( + caplog: pytest.LogCaptureFixture, include_secrets: bool, expected: str +) -> None: + config = {CONF_ALLOW_UNENCRYPTED: True, "include_secrets": include_secrets} + assert _run_final_validate({"api": {}}, config) is config + assert expected in caplog.text + + +@pytest.mark.parametrize( + "secrets", + [ + "<<: !include shared.yaml\n", + "wifi_password: hunter2\nextra: !include_dir_named creds\n", + ], + ids=["include", "include_dir"], +) +def test_redacted_rejects_a_secrets_file_that_includes_files( + project: Path, secrets: str +) -> None: + """What a secrets file includes would ship verbatim beside its skeleton.""" + (project / "entry.yaml").write_text("wifi:\n password: !secret wifi_password\n") + (project / "secrets.yaml").write_text(secrets) + (project / "shared.yaml").write_text("wifi_password: hunter2\n") + (project / "creds").mkdir() + with pytest.raises(EsphomeError, match="secrets.yaml uses !include"): + _gather_redacted(discover_user_yaml_files(project / "entry.yaml")) + + +@pytest.mark.parametrize( + "secrets", + [ + "# Credentials for !include wifi.yaml\nwifi_password: hunter2\n", + "wifi_password: hunter2\nnote: '!include is not used here'\n", + ], + ids=["comment", "quoted"], +) +def test_redacted_accepts_include_text_that_is_not_a_tag( + project: Path, secrets: str +) -> None: + """Only a real include tag rejects a secrets file, not the word in text.""" + (project / "entry.yaml").write_text("wifi:\n password: !secret wifi_password\n") + (project / "secrets.yaml").write_text(secrets) + CORE.config = WIFI_PW + files = _gather_redacted(discover_user_yaml_files(project / "entry.yaml")) + assert b"hunter2" not in b"".join(files.values()) + + +def test_credential_words_cover_every_sensitive_key() -> None: + """A removed branch is matched by name, so every `cv.sensitive` key must be named.""" + root = Path(store_yaml.__file__).parents[2] + sources = [f.read_text(encoding="utf-8") for f in root.rglob("*.py")] + consts = { + m.group(1): m.group(2) + for text in sources + for m in re.finditer(r'^(CONF_\w+) = "([^"]+)"', text, re.MULTILINE) + } + keys = { + consts.get(m.group(1), m.group(1)) + for text in sources + for m in re.finditer( + r"cv\.(?:Optional|Required|Exclusive|Inclusive)\((CONF_\w+)[^:]*:\s*cv\.(?:sensitive\(|bind_key)", + text, + ) + } + assert {"password", "tlv", "static_pairing_code", "bindkey", "auth_key"} <= keys + # A broadcast SSID is not secret, and a bare `key` also names keypad keys. + missing = keys - {"ssid", "key"} - store_yaml._SENSITIVE_KEYS + assert not missing, f"add {sorted(missing)} to _SENSITIVE_KEYS" + unnamed = { + k + for k in keys - {"ssid"} + if not any(w in k for w in store_yaml._CREDENTIAL_WORDS) + } + assert not unnamed, f"add a word for {sorted(unnamed)} to _CREDENTIAL_WORDS" From c9ca5ba910cf3956d26bd260f4f71862fe9bd46a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 12:41:24 -0600 Subject: [PATCH 20/55] [canbus] Pass received frames to callbacks without a heap vector per frame (#22008) --- esphome/components/canbus/__init__.py | 27 ++-- esphome/components/canbus/canbus.cpp | 12 +- esphome/components/canbus/canbus.h | 39 ++++- tests/components/canbus/common.yaml | 21 +++ .../components/canbus/frame_callback_test.cpp | 142 ++++++++++++++++++ 5 files changed, 214 insertions(+), 27 deletions(-) create mode 100644 tests/components/canbus/frame_callback_test.cpp diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 62796a0752c..1936bba54e8 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -55,7 +55,8 @@ def validate_raw_data(value: Any) -> bytes | list: canbus_ns = cg.esphome_ns.namespace("canbus") CanbusComponent = canbus_ns.class_("CanbusComponent", cg.Component) -_FRAME_DATA = cg.std_vector.template(cg.uint8) +CanFrame = canbus_ns.struct("CanFrame") +CanPayload = canbus_ns.class_("CanPayload") CanSpeed = canbus_ns.enum("CAN_SPEED") CAN_SPEEDS = CanSpeed.options( @@ -133,28 +134,27 @@ async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_FRAME, []): can_id = f"0x{conf[CONF_CAN_ID]:X}" if (mask := conf[CONF_CAN_ID_MASK]) != CAN_ID_MASK_ALL: - checks = [f"(can_id & 0x{mask:X}) == {can_id}"] + checks = [f"(frame.can_id & 0x{mask:X}) == {can_id}"] else: - checks = [f"can_id == {can_id}"] - checks.append(_flag("extended_id", conf[CONF_USE_EXTENDED_ID])) + checks = [f"frame.can_id == {can_id}"] + checks.append(_flag("frame.use_extended_id", conf[CONF_USE_EXTENDED_ID])) if (rtr := conf.get(CONF_REMOTE_TRANSMISSION_REQUEST)) is not None: - checks.append(_flag("rtr", rtr)) + checks.append(_flag("frame.remote_transmission_request", rtr)) await automation.build_callback_automation( var, - "add_callback", + "add_frame_callback", [ - (_FRAME_DATA, "x"), + (CanPayload, "x"), (cg.uint32, "can_id"), (cg.bool_, "remote_transmission_request"), ], conf, - params=[ - (cg.uint32, "can_id"), - (cg.bool_, "extended_id"), - (cg.bool_, "rtr"), - (_FRAME_DATA.operator("const").operator("ref"), "data"), + params=[(CanFrame.operator("const").operator("ref"), "frame")], + forward=[ + "frame.payload()", + "frame.can_id", + "frame.remote_transmission_request", ], - forward=["data", "can_id", "rtr"], when=" && ".join(checks), ) @@ -221,5 +221,6 @@ async def canbus_action_to_code( var.set_data_template, var.set_data_static, "canbus_data", + return_type=CanPayload, ) return var diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index 840b163976c..8d2831556d4 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -78,16 +78,8 @@ void Canbus::loop() { can_message.can_id, can_message.can_data_length_code); } - std::vector data; - - // show data received - for (int i = 0; i < std::min(can_message.can_data_length_code, CAN_MAX_DATA_LENGTH); i++) { - ESP_LOGV(TAG, " can_message.data[%d]=%02x", i, can_message.data[i]); - data.push_back(can_message.data[i]); - } - - this->callback_manager_(can_message.can_id, can_message.use_extended_id, can_message.remote_transmission_request, - data); + ESP_LOGV_HEX(TAG, can_message.data, can_message.payload_length(), CAN_MAX_DATA_LENGTH, " data=%s"); + this->frame_callbacks_.call(can_message); } if (message_counter == this->max_frames_per_loop_) { diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index b0b7c2249be..73094872277 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -2,9 +2,12 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/core/optional.h" +#include #include +#include #include namespace esphome::canbus { @@ -49,6 +52,17 @@ static const uint8_t CAN_MAX_DATA_LENGTH = 8; static const uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128; +/// Frame payload without heap; converts to and from std::vector for older lambdas. +class CanPayload : public StaticVector { + public: + using StaticVector::StaticVector; + // Not = default, so value-init does not zero the buffer + CanPayload() {} + CanPayload(const std::vector &data) // NOLINT(google-explicit-constructor) + : StaticVector(data.begin(), data.end()) {} + operator std::vector() const { return {this->begin(), this->end()}; } // NOLINT(google-explicit-constructor) +}; + /* Can Frame describes a normative CAN Frame The RTR = Remote Transmission Request is implemented in every CAN controller but rarely used @@ -60,6 +74,14 @@ struct CanFrame { uint32_t can_id; /* 29 or 11 bit CAN_ID; drivers clear bits 29-31 (on_frame relies on it) */ uint8_t can_data_length_code; /* frame payload length in byte (0 .. CAN_MAX_DATA_LENGTH) */ uint8_t data[CAN_MAX_DATA_LENGTH] __attribute__((aligned(8))); + + uint8_t payload_length() const { return std::min(this->can_data_length_code, CAN_MAX_DATA_LENGTH); } + CanPayload payload() const { + CanPayload out; + std::memcpy(out.data(), this->data, CAN_MAX_DATA_LENGTH); + out.resize(this->payload_length()); + return out; + } }; enum CanEventFlags : uint8_t { @@ -106,6 +128,9 @@ class Canbus : public Component { void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } virtual CanStatus get_status() { return {}; } + /// The frame is only valid during the call. + template void add_frame_callback(F &&callback) { this->frame_callbacks_.add(std::forward(callback)); } + /** * Add a callback to be called when a CAN message is received. All received messages * are passed to the callback without filtering. @@ -116,7 +141,14 @@ class Canbus : public Component { * - rtr If this is a remote transmission request * - data The message data */ - template void add_callback(F &&callback) { this->callback_manager_.add(std::forward(callback)); } + template + ESPDEPRECATED("Use add_frame_callback(), which does not copy the payload. Removed in 2027.5.0.", "2026.11.0") + void add_callback(F &&callback) { + this->frame_callbacks_.add([cb = std::forward(callback)](const CanFrame &frame) mutable { + cb(frame.can_id, frame.use_extended_id, frame.remote_transmission_request, + std::vector(frame.data, frame.data + frame.payload_length())); + }); + } protected: template friend class CanbusSendAction; @@ -126,8 +158,7 @@ class Canbus : public Component { bool use_extended_id_{false}; uint8_t events_to_log_{0}; bool bus_off_{false}; - CallbackManager &data)> - callback_manager_{}; + CallbackManager frame_callbacks_{}; // interval in which to check for new can bus events (bus-off, passive, active) static constexpr uint32_t EVENT_CHECK_INTERVAL_MS = 100; @@ -154,7 +185,7 @@ class Canbus : public Component { }; template class CanbusSendAction final : public Action, public Parented { - TEMPLATABLE_BYTES(data) + TEMPLATABLE_BUFFER(data, CanPayload) void set_can_id(uint32_t can_id) { this->can_id_ = can_id; } diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 07a341bcdca..014c70b8b9d 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -20,6 +20,27 @@ canbus: then: logger.log: Truth - canbus.send: !lambda return x; + # x forms from the docs + - can_id: 24 + then: + - number.set: + id: canbus_test_number + value: !lambda "return (x.size() > 0) ? (float) x[0] / 255 : 0;" + - lambda: |- + if (x.empty()) + return; + ESP_LOGI("can", "SOC: %d%%", x[0]); + ESP_LOGD("can", "%s", format_hex_pretty(x).c_str()); + uint8_t buf[8]; + memcpy(buf, x.data(), x.size()); + for (uint8_t b : x) + buf[0] ^= b; + std::vector copy = x; + ESP_LOGD("can", "%u %u", buf[0], (unsigned) copy.size()); + - canbus.send: !lambda |- + std::vector data(x.begin(), x.end()); + data.push_back(0x01); + return data; - can_id: 0b00000000000000000000001000000 can_id_mask: 0b11111000000000011111111000000 use_extended_id: true diff --git a/tests/components/canbus/frame_callback_test.cpp b/tests/components/canbus/frame_callback_test.cpp new file mode 100644 index 00000000000..3efd426a5d5 --- /dev/null +++ b/tests/components/canbus/frame_callback_test.cpp @@ -0,0 +1,142 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/canbus/canbus.h" + +// Needs AddressSanitizer's malloc hooks +#ifndef __has_feature +#define __has_feature(x) 0 +#endif +#if defined(__SANITIZE_ADDRESS__) || __has_feature(address_sanitizer) +#define CANBUS_TEST_ASAN +extern "C" int __sanitizer_install_malloc_and_free_hooks(void (*malloc_hook)(const volatile void *, size_t), + void (*free_hook)(const volatile void *)); +#endif + +namespace esphome::canbus::testing { + +// Receives queued frames +class QueuedCanbus : public Canbus { + public: + void queue(uint32_t can_id, bool extended, std::initializer_list data) { + CanFrame frame{}; + frame.can_id = can_id; + frame.use_extended_id = extended; + frame.can_data_length_code = static_cast(data.size()); + std::copy(data.begin(), data.end(), frame.data); + this->rx_.push_back(frame); + } + + protected: + bool setup_internal() override { return true; } + Error send_message(CanFrame *frame) override { return ERROR_OK; } + Error read_message(CanFrame *frame) override { + if (this->rx_.empty()) + return ERROR_NOMSG; + *frame = this->rx_.front(); + this->rx_.pop_front(); + return ERROR_OK; + } + + std::deque rx_; +}; + +TEST(CanbusFrameCallback, PassesEveryFrameAndItsPayload) { + using Received = std::vector>>; + QueuedCanbus bus; + Received received; + bus.add_frame_callback([&](const CanFrame &frame) { received.emplace_back(frame.can_id, frame.payload()); }); + bus.setup(); + bus.queue(0x100, false, {0x01, 0x02}); + bus.queue(0x1ABCDE42, true, {}); + bus.queue(0x7FF, false, {1, 2, 3, 4, 5, 6, 7, 8}); + + bus.loop(); + + EXPECT_EQ(received, (Received{{0x100, {0x01, 0x02}}, {0x1ABCDE42, {}}, {0x7FF, {1, 2, 3, 4, 5, 6, 7, 8}}})); +} + +TEST(CanbusFrameCallback, ReceivingFramesDoesNotAllocate) { +#ifdef CANBUS_TEST_ASAN + static std::atomic allocations{0}; + static const int hooks_installed = __sanitizer_install_malloc_and_free_hooks( + [](const volatile void *, size_t) { allocations.fetch_add(1, std::memory_order_relaxed); }, + [](const volatile void *) {}); + ASSERT_NE(hooks_installed, 0); + QueuedCanbus bus; + size_t bytes_seen = 0; + bus.add_frame_callback([&](const CanFrame &frame) { bytes_seen += frame.payload().size(); }); + bus.setup(); + for (uint8_t i = 0; i < 10; i++) + bus.queue(0x100 + i, false, {i, i, i, i, i, i, i, i}); + + const size_t before = allocations.load(std::memory_order_relaxed); + bus.loop(); + const size_t after = allocations.load(std::memory_order_relaxed); + + EXPECT_EQ(bytes_seen, 80u); + EXPECT_EQ(after - before, 0u); +#else + GTEST_SKIP() << "Allocation counting requires AddressSanitizer"; +#endif +} + +TEST(CanbusFrameCallback, DeprecatedVectorCallbackStillReceivesFrames) { + QueuedCanbus bus; + std::vector received; + uint32_t received_id = 0; + int calls = 0; +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + // mutable, as before + bus.add_callback([&, seen = 0](uint32_t can_id, bool, bool, const std::vector &data) mutable { + received_id = can_id; + received = data; + calls = ++seen; + }); +#pragma GCC diagnostic pop + bus.setup(); + bus.queue(0x122, false, {0x01}); + bus.queue(0x123, false, {0xAA, 0xBB}); + + bus.loop(); + + EXPECT_EQ(calls, 2); + EXPECT_EQ(received_id, 0x123u); + EXPECT_EQ(received, (std::vector{0xAA, 0xBB})); +} + +TEST(CanbusFrameCallback, PayloadKeepsVectorForms) { + CanFrame frame{}; + frame.can_data_length_code = 3; + frame.data[0] = 'a'; + frame.data[1] = 'b'; + frame.data[2] = 'c'; + const CanPayload x = frame.payload(); + + EXPECT_EQ(x.size(), 3u); + EXPECT_FALSE(x.empty()); + EXPECT_EQ(x[1], 'b'); + EXPECT_EQ(x.data()[2], 'c'); + EXPECT_EQ(std::string(x.begin(), x.end()), "abc"); + const std::vector copy = x; + EXPECT_EQ(copy, (std::vector{'a', 'b', 'c'})); + auto takes_vector = [](const std::vector &v) { return v.size(); }; + EXPECT_EQ(takes_vector(x), 3u); + + CanPayload braced = {0x00, 0x20, 0x42}; + EXPECT_EQ(braced.size(), 3u); + const CanPayload too_long = std::vector(CAN_MAX_DATA_LENGTH + 2, 7); + EXPECT_EQ(too_long.size(), CAN_MAX_DATA_LENGTH); + frame.can_data_length_code = 15; // legal on the wire + EXPECT_EQ(frame.payload().size(), CAN_MAX_DATA_LENGTH); +} + +} // namespace esphome::canbus::testing From 9969cb3de1e065dc25af14cbe2b765a909038cfd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 12:43:38 -0600 Subject: [PATCH 21/55] [bq25186] Generate the status entity decoding in code generation (#22091) --- esphome/components/bq25186/__init__.py | 21 ++-- esphome/components/bq25186/binary_sensor.py | 26 +++-- esphome/components/bq25186/bq25186.cpp | 16 +-- esphome/components/bq25186/bq25186.h | 34 +++--- .../bq25186/bq25186_binary_sensor.h | 31 ----- .../bq25186/bq25186_text_sensor.cpp | 22 ---- .../components/bq25186/bq25186_text_sensor.h | 26 ----- esphome/components/bq25186/text_sensor.py | 45 ++++++-- esphome/core/defines.h | 3 +- .../bq25186/config/hub_function.yaml | 108 ++++++++++++++++++ .../bq25186/test_hub_function.py | 93 +++++++++++++++ tests/components/bq25186/__init__.py | 3 +- .../bq25186/test_bq25186_status.cpp | 93 +++++++-------- 13 files changed, 327 insertions(+), 194 deletions(-) delete mode 100644 esphome/components/bq25186/bq25186_binary_sensor.h delete mode 100644 esphome/components/bq25186/bq25186_text_sensor.cpp delete mode 100644 esphome/components/bq25186/bq25186_text_sensor.h create mode 100644 tests/component_tests/bq25186/config/hub_function.yaml create mode 100644 tests/component_tests/bq25186/test_hub_function.py diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py index 78df18fb042..5bd7c661e95 100644 --- a/esphome/components/bq25186/__init__.py +++ b/esphome/components/bq25186/__init__.py @@ -65,19 +65,14 @@ BQ25186Component = bq25186_ns.class_( BQ25186_CHILD_SCHEMA = cv.Schema( {cv.GenerateID(CONF_BQ25186_ID): cv.use_id(BQ25186Component)} ) -_request_binary_sensor_slot = cg.slot_counter("BQ25186_BINARY_SENSOR_COUNT") -_request_text_sensor_slot = cg.slot_counter("BQ25186_TEXT_SENSOR_COUNT") - - -def register_binary_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: - _request_binary_sensor_slot(str(hub)) - return hub.register_binary_sensor(var) - - -def register_text_sensor(hub: cg.MockObj, var: cg.MockObj) -> cg.Expression: - _request_text_sensor_slot(str(hub)) - return hub.register_text_sensor(var) - +BQ25186StatusRegisters = bq25186_ns.class_("BQ25186StatusRegisters") +STATUS_PUBLISHER = cg.HubFunction( + "status", + cg.void, + [(BQ25186StatusRegisters.const_ref(), "status")], + define="USE_BQ25186_STATUS_PUBLISHER", +) +STATUS = STATUS_PUBLISHER.param("status") REG_VBAT_CTRL = 0x03 REG_ICHG_CTRL = 0x04 diff --git a/esphome/components/bq25186/binary_sensor.py b/esphome/components/bq25186/binary_sensor.py index fb741950e29..ec8f773d0c2 100644 --- a/esphome/components/bq25186/binary_sensor.py +++ b/esphome/components/bq25186/binary_sensor.py @@ -1,5 +1,4 @@ -from functools import partial - +from esphome.automation import parent_ref import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv @@ -9,16 +8,13 @@ from esphome.const import ( DEVICE_CLASS_PLUG, DEVICE_CLASS_RUNNING, ) +from esphome.cpp_generator import HexIntLiteral from esphome.types import ConfigType -from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_binary_sensor +from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, STATUS, STATUS_PUBLISHER DEPENDENCIES = ["bq25186"] -BQ25186BinarySensor = bq25186_ns.class_( - "BQ25186BinarySensor", binary_sensor.BinarySensor -) - REG_STAT0 = 0 REG_STAT1 = 1 REG_FLAG0 = 2 @@ -51,11 +47,19 @@ _FIELDS = ( ) +def _state_expression(reg: int, shift: int, mask: int, match: int) -> cg.MockObj: + """Whether the field (status[reg] >> shift) & mask reads as on, as a bit of match.""" + value = (STATUS[reg] >> shift) & HexIntLiteral(mask) + if mask == 0x01 and match == _BIT_SET: + return value + return (cg.MockObj(f"{match:#06b}") >> value) & 1 + + CONFIG_SCHEMA = cv.All( BQ25186_CHILD_SCHEMA.extend( { cv.Optional(key): binary_sensor.binary_sensor_schema( - BQ25186BinarySensor, device_class=device_class + device_class=device_class ) for key, *_, device_class in _FIELDS } @@ -68,6 +72,6 @@ async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BQ25186_ID]) sensors = binary_sensor.sub_binary_sensors(config) for key, reg, shift, mask, match, _ in _FIELDS: - await sensors( - key, partial(register_binary_sensor, hub), reg, shift, mask, match - ) + if (var := await sensors(key, None)) is not None: + state = _state_expression(reg, shift, mask, match) + STATUS_PUBLISHER.add(hub, parent_ref(var).publish_state(state)) diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp index 567a580bdea..f91123e7c36 100644 --- a/esphome/components/bq25186/bq25186.cpp +++ b/esphome/components/bq25186/bq25186.cpp @@ -1,10 +1,4 @@ #include "bq25186.h" -#ifdef USE_BINARY_SENSOR -#include "bq25186_binary_sensor.h" -#endif -#ifdef USE_TEXT_SENSOR -#include "bq25186_text_sensor.h" -#endif #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -144,13 +138,9 @@ void BQ25186Component::update() { #endif if (!pending) this->status_clear_warning(); -#ifdef BQ25186_BINARY_SENSOR_COUNT - for (auto *sensor : this->binary_sensors_) - sensor->on_status(status); -#endif -#ifdef BQ25186_TEXT_SENSOR_COUNT - for (auto *sensor : this->text_sensors_) - sensor->on_status(status); +#ifdef USE_BQ25186_STATUS_PUBLISHER + if (this->status_publisher_ != nullptr) + this->status_publisher_(status); #endif } diff --git a/esphome/components/bq25186/bq25186.h b/esphome/components/bq25186/bq25186.h index 7b3092d86ce..5f3420e47a8 100644 --- a/esphome/components/bq25186/bq25186.h +++ b/esphome/components/bq25186/bq25186.h @@ -7,6 +7,10 @@ #ifdef USE_BQ25186_PG_GPO #include "esphome/components/switch/switch.h" #endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/progmem.h" +#endif #include @@ -38,8 +42,21 @@ static constexpr uint8_t BQ25186_SYS_REG_PG_GPO_LEVEL = 0x10; using BQ25186ConfigRegisters = std::array; using BQ25186StatusRegisters = std::array; -class BQ25186BinarySensor; -class BQ25186TextSensor; +#ifdef USE_TEXT_SENSOR +// STAT0[6:5] charge status, then STAT1[4:3] TS status +PROGMEM_STRING_TABLE(BQ25186StatusStrings, "Not Charging", "Constant Current", "Constant Voltage", + "Charge Done/Disabled", "Normal", "Hot/Cold (Charging Suspended)", "Cool (Current Reduced)", + "Warm (Voltage Reduced)"); + +/// Publish the two-bit field in the low bits of `value` as string `first + field`, only when it differs from `last` +inline void publish_status_text(text_sensor::TextSensor *sensor, uint8_t &last, uint8_t value, uint8_t first) { + value &= 0x03; + if (value == last) + return; + last = value; + sensor->publish_state(BQ25186StatusStrings::get_progmem_str(first + value, BQ25186StatusStrings::LAST_INDEX)); +} +#endif class BQ25186Component : public PollingComponent, public i2c::I2CDevice { public: @@ -52,11 +69,8 @@ class BQ25186Component : public PollingComponent, public i2c::I2CDevice { this->configure_on_boot_ = true; } bool apply_config(const BQ25186ConfigRegisters ®s); -#ifdef BQ25186_BINARY_SENSOR_COUNT - void register_binary_sensor(BQ25186BinarySensor *sensor) { this->binary_sensors_.push_back(sensor); } -#endif -#ifdef BQ25186_TEXT_SENSOR_COUNT - void register_text_sensor(BQ25186TextSensor *sensor) { this->text_sensors_.push_back(sensor); } +#ifdef USE_BQ25186_STATUS_PUBLISHER + HUB_PUBLISHER(status, const BQ25186StatusRegisters &) #endif #ifdef USE_BQ25186_PG_GPO /// The /PG/GPO pin is put in general purpose output mode when a switch drives it @@ -78,12 +92,6 @@ class BQ25186Component : public PollingComponent, public i2c::I2CDevice { void check_pg_gpo_(const BQ25186StatusRegisters &status, bool &pending); #endif -#ifdef BQ25186_BINARY_SENSOR_COUNT - StaticVector binary_sensors_; -#endif -#ifdef BQ25186_TEXT_SENSOR_COUNT - StaticVector text_sensors_; -#endif BQ25186ConfigRegisters regs_{}; // the configuration last applied, written again after a chip reset bool configure_on_boot_{false}; bool configured_{false}; // regs_ went to the chip at least once diff --git a/esphome/components/bq25186/bq25186_binary_sensor.h b/esphome/components/bq25186/bq25186_binary_sensor.h deleted file mode 100644 index 265f34a4459..00000000000 --- a/esphome/components/bq25186/bq25186_binary_sensor.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" -#ifdef USE_BINARY_SENSOR - -#include "bq25186.h" -#include "esphome/components/binary_sensor/binary_sensor.h" - -namespace esphome::bq25186 { - -/// One field of a status register; `match` has a bit set for each field value that reads as on -class BQ25186BinarySensor final : public binary_sensor::BinarySensor { - public: - BQ25186BinarySensor(uint8_t reg, uint8_t shift, uint8_t mask, uint8_t match) - : reg_(reg), shift_(shift), mask_(mask), match_(match) {} - - void on_status(const BQ25186StatusRegisters &status) { - const uint8_t value = (status[this->reg_] >> this->shift_) & this->mask_; - this->publish_state((this->match_ >> value) & 1); - } - - protected: - const uint8_t reg_; - const uint8_t shift_; - const uint8_t mask_; - const uint8_t match_; -}; - -} // namespace esphome::bq25186 - -#endif // USE_BINARY_SENSOR diff --git a/esphome/components/bq25186/bq25186_text_sensor.cpp b/esphome/components/bq25186/bq25186_text_sensor.cpp deleted file mode 100644 index f34d6ebdc0a..00000000000 --- a/esphome/components/bq25186/bq25186_text_sensor.cpp +++ /dev/null @@ -1,22 +0,0 @@ -#include "bq25186_text_sensor.h" -#ifdef USE_TEXT_SENSOR - -#include "esphome/core/progmem.h" - -namespace esphome::bq25186 { - -// STAT0[6:5] charge status, then STAT1[4:3] TS status -PROGMEM_STRING_TABLE(StatusStrings, "Not Charging", "Constant Current", "Constant Voltage", "Charge Done/Disabled", - "Normal", "Hot/Cold (Charging Suspended)", "Cool (Current Reduced)", "Warm (Voltage Reduced)"); - -void BQ25186TextSensor::on_status(const BQ25186StatusRegisters &status) { - const uint8_t value = (status[this->reg_] >> this->shift_) & 0x03; - if (value == this->last_) - return; - this->last_ = value; - this->publish_state(StatusStrings::get_progmem_str(this->first_ + value, StatusStrings::LAST_INDEX)); -} - -} // namespace esphome::bq25186 - -#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/bq25186_text_sensor.h b/esphome/components/bq25186/bq25186_text_sensor.h deleted file mode 100644 index 33c8f806fa9..00000000000 --- a/esphome/components/bq25186/bq25186_text_sensor.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" -#ifdef USE_TEXT_SENSOR - -#include "bq25186.h" -#include "esphome/components/text_sensor/text_sensor.h" - -namespace esphome::bq25186 { - -/// A two-bit status field; `first` is the string table index of the field's value 0 -class BQ25186TextSensor final : public text_sensor::TextSensor { - public: - BQ25186TextSensor(uint8_t reg, uint8_t shift, uint8_t first) : reg_(reg), shift_(shift), first_(first) {} - void on_status(const BQ25186StatusRegisters &status); - - protected: - const uint8_t reg_; - const uint8_t shift_; - const uint8_t first_; - uint8_t last_{0xFF}; // the field value last published -}; - -} // namespace esphome::bq25186 - -#endif // USE_TEXT_SENSOR diff --git a/esphome/components/bq25186/text_sensor.py b/esphome/components/bq25186/text_sensor.py index b27d2fbcfae..e8804bca5a5 100644 --- a/esphome/components/bq25186/text_sensor.py +++ b/esphome/components/bq25186/text_sensor.py @@ -1,16 +1,24 @@ -from functools import partial - +from esphome.automation import parent_ref import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.cpp_generator import ( + AssignmentExpression, + HexIntLiteral, + VariableDeclarationExpression, +) from esphome.types import ConfigType -from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns, register_text_sensor +from . import ( + BQ25186_CHILD_SCHEMA, + CONF_BQ25186_ID, + STATUS, + STATUS_PUBLISHER, + bq25186_ns, +) DEPENDENCIES = ["bq25186"] -BQ25186TextSensor = bq25186_ns.class_("BQ25186TextSensor", text_sensor.TextSensor) - REG_STAT0 = 0 REG_STAT1 = 1 @@ -20,12 +28,28 @@ _FIELDS = ( ("ts_status", REG_STAT1, 3, 4), ) + +def _add_text_status( + hub: cg.MockObj, var: cg.MockObj, reg: int, shift: int, first: int +) -> None: + """Publish status string first + the two-bit field at status[reg] >> shift, on change.""" + last = cg.MockObj(f"esphome__{var}__last") + # 0xFF so the first poll publishes + declaration = VariableDeclarationExpression(cg.uint8, "", last, static=True) + STATUS_PUBLISHER.add( + hub, AssignmentExpression(None, None, declaration, HexIntLiteral(0xFF)) + ) + STATUS_PUBLISHER.add( + hub, + bq25186_ns.publish_status_text( + parent_ref(var), last, STATUS[reg] >> shift, first + ), + ) + + CONFIG_SCHEMA = cv.All( BQ25186_CHILD_SCHEMA.extend( - { - cv.Optional(key): text_sensor.text_sensor_schema(BQ25186TextSensor) - for key, *_ in _FIELDS - } + {cv.Optional(key): text_sensor.text_sensor_schema() for key, *_ in _FIELDS} ), cv.has_at_least_one_key(*(key for key, *_ in _FIELDS)), ) @@ -35,4 +59,5 @@ async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BQ25186_ID]) sensors = text_sensor.sub_text_sensors(config) for key, reg, shift, first in _FIELDS: - await sensors(key, partial(register_text_sensor, hub), reg, shift, first) + if (var := await sensors(key, None)) is not None: + _add_text_status(hub, var, reg, shift, first) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0ec5596e9f0..48649c3c519 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -62,8 +62,7 @@ #define USE_BLE_DEVICE_IRK #define USE_BMI270_BMM150 #define USE_BQ25186_PG_GPO -#define BQ25186_BINARY_SENSOR_COUNT 1 -#define BQ25186_TEXT_SENSOR_COUNT 1 +#define USE_BQ25186_STATUS_PUBLISHER #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE diff --git a/tests/component_tests/bq25186/config/hub_function.yaml b/tests/component_tests/bq25186/config/hub_function.yaml new file mode 100644 index 00000000000..65ae5c7f67b --- /dev/null +++ b/tests/component_tests/bq25186/config/hub_function.yaml @@ -0,0 +1,108 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + - id: i2c_bus + scl: GPIO16 + sda: GPIO17 + +bq25186: + - id: charger_a + i2c_id: i2c_bus + - id: charger_b + i2c_id: i2c_bus + address: 0x6B + - id: charger_c + i2c_id: i2c_bus + address: 0x6C + +# charger_a has every status entity, charger_b one, charger_c none. +# Items publish in config order, so these text sensors come first +text_sensor: + - platform: bq25186 + bq25186_id: charger_a + charge_status: + id: tx_charge_status + name: A charge_status + ts_status: + id: tx_ts_status + name: A ts_status + - platform: bq25186 + bq25186_id: charger_b + ts_status: + id: b_ts_status + name: B ts_status + +# Two blocks on one hub publish in block order +binary_sensor: + - platform: bq25186 + bq25186_id: charger_a + ts: + id: bs_ts + name: A ts + charging: + id: bs_charging + name: A charging + charge_done: + id: bs_charge_done + name: A charge_done + ilim: + id: bs_ilim + name: A ilim + vdppm: + id: bs_vdppm + name: A vdppm + vindpm: + id: bs_vindpm + name: A vindpm + thermreg: + id: bs_thermreg + name: A thermreg + power_good: + id: bs_power_good + name: A power_good + vin_ovp: + id: bs_vin_ovp + name: A vin_ovp + battery_uvlo: + id: bs_battery_uvlo + name: A battery_uvlo + - platform: bq25186 + bq25186_id: charger_a + safety_timer_flag: + id: bs_safety_timer_flag + name: A safety_timer_flag + wake1_flag: + id: bs_wake1_flag + name: A wake1_flag + wake2_flag: + id: bs_wake2_flag + name: A wake2_flag + ts_flag: + id: bs_ts_flag + name: A ts_flag + ilim_flag: + id: bs_ilim_flag + name: A ilim_flag + vdppm_flag: + id: bs_vdppm_flag + name: A vdppm_flag + vindpm_flag: + id: bs_vindpm_flag + name: A vindpm_flag + thermreg_flag: + id: bs_thermreg_flag + name: A thermreg_flag + vin_ovp_flag: + id: bs_vin_ovp_flag + name: A vin_ovp_flag + battery_uvlo_flag: + id: bs_battery_uvlo_flag + name: A battery_uvlo_flag + battery_ocp_flag: + id: bs_battery_ocp_flag + name: A battery_ocp_flag diff --git a/tests/component_tests/bq25186/test_hub_function.py b/tests/component_tests/bq25186/test_hub_function.py new file mode 100644 index 00000000000..7525cae0b51 --- /dev/null +++ b/tests/component_tests/bq25186/test_hub_function.py @@ -0,0 +1,93 @@ +"""Tests for the generated bq25186 status publishers.""" + +from collections.abc import Callable +from pathlib import Path +import re + +from esphome.components.bq25186.binary_sensor import _state_expression + +from .status_datasheet import BINARY_SENSORS, TEXT_SENSORS, field_value, status_strings + +PARAMS = "const bq25186::BQ25186StatusRegisters & esphome__arg_status" +BINARY = re.compile(r"::bs_(\w+)->publish_state\((.*)\);") +LAST = re.compile(r"static uint8_t esphome__tx_(\w+)__last = 0xFF;") +TEXT = re.compile( + r"bq25186::publish_status_text\(::tx_(\w+), esphome__tx_\1__last, (.*), (\d+)\);" +) +# The hand copy of the generated function in the C++ behaviour tests +CPP_TEST = Path(__file__).parents[2] / "components/bq25186/test_bq25186_status.cpp" +CPP_BINARY = re.compile(r"entities->binary\[(\w+)\]\.publish_state\((.*)\);") +CPP_TEXT = re.compile( + r"publish_status_text\(&entities->text\[(\w+)\], entities->\w+, (.*), (\d+)\);" +) + + +def _body(main_cpp: str, hub: str) -> list[str]: + head = f"static void esphome__{hub}__status_fn({PARAMS}) {{\n" + body = main_cpp.split(head, 1)[1].split("\n}", 1)[0] + return [line.strip() for line in body.splitlines()] + + +def _check(expr: str, reg: int, bits: int, expected: list[int]) -> None: + """Evaluate a generated C++ decode, which is also valid Python, for every register value; + `expected` is the result for each value of the field `bits`.""" + code = compile(expr, expr, "eval") + for value in range(256): + status = [0] * 3 + status[reg] = value + result = eval(code, {"__builtins__": {}}, {"esphome__arg_status": status}) # noqa: S307 + assert result == expected[field_value(value, bits)], (expr, value) + + +def test_status_publishers( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Every decode matches the datasheet; one function per hub with entities, items in config order.""" + main_cpp = generate_main(component_config_path("hub_function.yaml")) + body = _body(main_cpp, "charger_a") + n_text = 2 * len(TEXT_SENSORS) + pairs = zip(body[0:n_text:2], body[1:n_text:2], strict=True) + text = [(LAST.fullmatch(last), TEXT.fullmatch(call)) for last, call in pairs] + assert [(m[1], t[1]) for m, t in text] == [(key, key) for key in TEXT_SENSORS] + strings = status_strings() + for _, call in text: + reg, bits, states = TEXT_SENSORS[call[1]] + # publish_status_text keeps the two low bits + expected = [strings.index(state) for state in states] + _check(f"{call[3]} + (({call[2]}) & 0x03)", reg, bits, expected) + binary = [BINARY.fullmatch(line) for line in body[n_text:]] + assert [m[1] for m in binary] == list(BINARY_SENSORS) + for match in binary: + reg, bits, on_values = BINARY_SENSORS[match[1]] + _check(f"({match[2]}) != 0", reg, bits, [v in on_values for v in range(4)]) + assert _body(main_cpp, "charger_b") == [ + "static uint8_t esphome__b_ts_status__last = 0xFF;", + "bq25186::publish_status_text(::b_ts_status, esphome__b_ts_status__last, (esphome__arg_status[1] >> 3), 4);", + ] + for hub in ("charger_a", "charger_b"): + assert f"{hub}->set_status_publisher(esphome__{hub}__status_fn);" in main_cpp + assert "charger_c->set_status_publisher" not in main_cpp + + # The C++ tests run a copy of this function (parameter named status); it must decode the same + cpp = CPP_TEST.read_text().replace("status[", "esphome__arg_status[") + assert [(m[1].lower(), m[2]) for m in CPP_BINARY.finditer(cpp)] == [ + (m[1], m[2]) for m in binary + ] + assert [(m[1].lower(), m[2], m[3]) for m in CPP_TEXT.finditer(cpp)] == [ + (t[1], t[2], t[3]) for _, t in text + ] + + +def test_single_bit_shortcut_needs_a_one_bit_field() -> None: + """A wider field whose match looks like a single bit keeps the bitmap form.""" + for mask, match in ((0x01, 0b10), (0x01, 0b01), (0x03, 0b10), (0x03, 0b0110)): + expr = str(_state_expression(0, 2, mask, match)) + for value in range(256): + status = [value] + result = eval(expr, {"__builtins__": {}}, {"esphome__arg_status": status}) # noqa: S307 + assert (result != 0) == bool((match >> ((value >> 2) & mask)) & 1), ( + mask, + match, + value, + ) diff --git a/tests/components/bq25186/__init__.py b/tests/components/bq25186/__init__.py index ae4a1a1d38e..23e601a696f 100644 --- a/tests/components/bq25186/__init__.py +++ b/tests/components/bq25186/__init__.py @@ -20,7 +20,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("ESPHOME_ENTITY_BINARY_SENSOR_COUNT", 0) cg.add_define("USE_TEXT_SENSOR") cg.add_define("ESPHOME_ENTITY_TEXT_SENSOR_COUNT", 0) - cg.add_define("BQ25186_BINARY_SENSOR_COUNT", 21) - cg.add_define("BQ25186_TEXT_SENSOR_COUNT", 2) + cg.add_define("USE_BQ25186_STATUS_PUBLISHER") manifest.to_code = to_code diff --git a/tests/components/bq25186/test_bq25186_status.cpp b/tests/components/bq25186/test_bq25186_status.cpp index cf89b62d9ec..a857bea8973 100644 --- a/tests/components/bq25186/test_bq25186_status.cpp +++ b/tests/components/bq25186/test_bq25186_status.cpp @@ -1,29 +1,11 @@ #include "common.h" -#include "esphome/components/bq25186/bq25186_binary_sensor.h" -#include "esphome/components/bq25186/bq25186_text_sensor.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/text_sensor/text_sensor.h" -#include #include -#include namespace esphome::bq25186::testing { -// One entry per binary_sensor.py _FIELDS row, in its order: what code generation passes for each sensor. -// tests/component_tests/bq25186/test_status_fields.py checks _FIELDS against the datasheet. -struct BinaryField { - uint8_t reg; - uint8_t shift; - uint8_t mask; - uint8_t match; -}; -static constexpr std::array BINARY_FIELDS{{ - {0, 7, 0x01, 0b10}, {0, 5, 0x03, 0b0110}, {0, 5, 0x03, 0b1000}, {0, 4, 0x01, 0b10}, {0, 3, 0x01, 0b10}, - {0, 2, 0x01, 0b10}, {0, 1, 0x01, 0b10}, {0, 0, 0x01, 0b10}, {1, 7, 0x01, 0b10}, {1, 6, 0x01, 0b10}, - {1, 2, 0x01, 0b10}, {1, 1, 0x01, 0b10}, {1, 0, 0x01, 0b10}, {2, 7, 0x01, 0b10}, {2, 6, 0x01, 0b10}, - {2, 5, 0x01, 0b10}, {2, 4, 0x01, 0b10}, {2, 3, 0x01, 0b10}, {2, 2, 0x01, 0b10}, {2, 1, 0x01, 0b10}, - {2, 0, 0x01, 0b10}, -}}; - enum BinaryIndex : uint8_t { TS, CHARGING, @@ -78,24 +60,52 @@ static constexpr std::array SINGLE_BITS{{ {BATTERY_OCP_FLAG, BQ25186_REG_FLAG0, 0x01}, }}; +// Copy of the generated function for a hub with every status entity; +// tests/component_tests/bq25186/test_hub_function.py checks it against the generated code +struct StatusEntities { + std::array binary; + std::array text; + uint8_t charge_status_last{0xFF}; + uint8_t ts_status_last{0xFF}; +}; +static StatusEntities *entities = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static void publish_status(const BQ25186StatusRegisters &status) { + entities->binary[TS].publish_state(((status[0] >> 7) & 0x01)); + entities->binary[CHARGING].publish_state(((0b0110 >> ((status[0] >> 5) & 0x03)) & 1)); + entities->binary[CHARGE_DONE].publish_state(((0b1000 >> ((status[0] >> 5) & 0x03)) & 1)); + entities->binary[ILIM].publish_state(((status[0] >> 4) & 0x01)); + entities->binary[VDPPM].publish_state(((status[0] >> 3) & 0x01)); + entities->binary[VINDPM].publish_state(((status[0] >> 2) & 0x01)); + entities->binary[THERMREG].publish_state(((status[0] >> 1) & 0x01)); + entities->binary[POWER_GOOD].publish_state(((status[0] >> 0) & 0x01)); + entities->binary[VIN_OVP].publish_state(((status[1] >> 7) & 0x01)); + entities->binary[BATTERY_UVLO].publish_state(((status[1] >> 6) & 0x01)); + entities->binary[SAFETY_TIMER_FLAG].publish_state(((status[1] >> 2) & 0x01)); + entities->binary[WAKE1_FLAG].publish_state(((status[1] >> 1) & 0x01)); + entities->binary[WAKE2_FLAG].publish_state(((status[1] >> 0) & 0x01)); + entities->binary[TS_FLAG].publish_state(((status[2] >> 7) & 0x01)); + entities->binary[ILIM_FLAG].publish_state(((status[2] >> 6) & 0x01)); + entities->binary[VDPPM_FLAG].publish_state(((status[2] >> 5) & 0x01)); + entities->binary[VINDPM_FLAG].publish_state(((status[2] >> 4) & 0x01)); + entities->binary[THERMREG_FLAG].publish_state(((status[2] >> 3) & 0x01)); + entities->binary[VIN_OVP_FLAG].publish_state(((status[2] >> 2) & 0x01)); + entities->binary[BATTERY_UVLO_FLAG].publish_state(((status[2] >> 1) & 0x01)); + entities->binary[BATTERY_OCP_FLAG].publish_state(((status[2] >> 0) & 0x01)); + publish_status_text(&entities->text[CHARGE_STATUS], entities->charge_status_last, (status[0] >> 5), 0); + publish_status_text(&entities->text[TS_STATUS], entities->ts_status_last, (status[1] >> 3), 4); +} + class BQ25186StatusTest : public ::testing::Test { protected: void SetUp() override { this->hub_.set_i2c_bus(&this->bus_); this->hub_.set_i2c_address(ADDRESS); - for (size_t i = 0; i < BINARY_COUNT; i++) { - const BinaryField &f = BINARY_FIELDS[i]; - this->binary_sensors_[i] = std::make_unique(f.reg, f.shift, f.mask, f.match); - this->hub_.register_binary_sensor(this->binary_sensors_[i].get()); - } - this->text_sensors_[CHARGE_STATUS] = std::make_unique(0, 5, 0); - this->text_sensors_[TS_STATUS] = std::make_unique(1, 3, 4); - for (auto &sensor : this->text_sensors_) - this->hub_.register_text_sensor(sensor.get()); + this->hub_.set_status_publisher(publish_status); + entities = &this->entities_; } - binary_sensor::BinarySensor &binary_(size_t index) { return *this->binary_sensors_[index]; } - text_sensor::TextSensor &text_(size_t index) { return *this->text_sensors_[index]; } + binary_sensor::BinarySensor &binary_(size_t index) { return this->entities_.binary[index]; } + text_sensor::TextSensor &text_(size_t index) { return this->entities_.text[index]; } void poll_(uint8_t stat0, uint8_t stat1 = 0, uint8_t flag0 = 0) { this->bus_.registers[BQ25186_REG_STAT0] = stat0; @@ -106,8 +116,7 @@ class BQ25186StatusTest : public ::testing::Test { RegisterBus bus_; BQ25186Component hub_; - std::array, BINARY_COUNT> binary_sensors_; - std::array, TEXT_COUNT> text_sensors_; + StatusEntities entities_; }; TEST_F(BQ25186StatusTest, EachSingleBitSensorReadsOnlyItsBit) { @@ -207,24 +216,6 @@ TEST_F(BQ25186StatusTest, BinarySensorsPublishEveryPoll) { } } -TEST_F(BQ25186StatusTest, BinarySensorsPublishBeforeTextSensorsInFieldOrder) { - // Binary sensor i records i, text sensor i records TEXT_BASE + i - static constexpr size_t TEXT_BASE = 100; - std::vector order; - for (size_t i = 0; i < BINARY_COUNT; i++) - this->binary_(i).add_on_raw_state_callback([&order, i](bool) { order.push_back(i); }); - for (size_t i = 0; i < TEXT_COUNT; i++) - this->text_(i).add_on_state_callback([&order, i](const std::string &) { order.push_back(TEXT_BASE + i); }); - this->poll_(0); - std::vector expected; - expected.reserve(BINARY_COUNT + TEXT_COUNT); - for (size_t i = 0; i < BINARY_COUNT; i++) - expected.push_back(i); - for (size_t i = 0; i < TEXT_COUNT; i++) - expected.push_back(TEXT_BASE + i); - EXPECT_EQ(order, expected); -} - TEST_F(BQ25186StatusTest, FailedReadPublishesNothing) { this->bus_.fail_reads = true; this->poll_(0xFF, 0xFF, 0xFF); From d29268cc00c1c90043750b3b85523e5c562a32c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 12:47:35 -0600 Subject: [PATCH 22/55] [remote_base] Dispatch received frames from a span (#22269) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_pb2.h | 2 +- esphome/components/api/api_server.cpp | 4 +-- esphome/components/api/api_server.h | 3 +- esphome/components/ir_rf_base/ir_rf_base.cpp | 2 +- .../components/remote_base/gobox_protocol.cpp | 2 +- .../components/remote_base/gobox_protocol.h | 2 +- .../remote_base/pronto_protocol.cpp | 4 +-- .../components/remote_base/pronto_protocol.h | 2 +- esphome/components/remote_base/raw_protocol.h | 11 +++++- .../components/remote_base/remote_base.cpp | 8 ++--- esphome/components/remote_base/remote_base.h | 27 ++++++++++++--- esphome/components/vevor_dtx/vevor_dtx.cpp | 2 +- esphome/components/vevor_dtx/vevor_dtx.h | 2 +- .../components/api/bench_proto_proxy.cpp | 9 ++--- .../remote_base/raw_trigger_test.cpp | 34 +++++++++++++++++++ .../remote_base/receive_data_test.cpp | 23 +++++++++++++ .../remote_receiver/common-actions.yaml | 14 +++++--- 18 files changed, 123 insertions(+), 30 deletions(-) create mode 100644 tests/components/remote_base/raw_trigger_test.cpp create mode 100644 tests/components/remote_base/receive_data_test.cpp diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4a5b54c086c..41edb718916 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2850,7 +2850,7 @@ message InfraredRFReceiveEvent { uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance - repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods + repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::span"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods } // Sent only to the client that issued an InfraredRFTransmitRawTimingsRequest, once the diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 306f53e3c9f..6023801a156 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3861,7 +3861,7 @@ class InfraredRFReceiveEvent final : public ProtoMessage { uint32_t device_id{0}; #endif uint32_t key{0}; - const std::vector *timings{}; + const std::span *timings{}; 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_server.cpp b/esphome/components/api/api_server.cpp index 91ab7bd00ac..8fe148b073b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -520,13 +520,13 @@ void APIServer::send_serial_proxy_identity(const SerialProxyIdentity &msg) { #ifdef USE_IR_RF void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key, - const std::vector *timings) { + std::span timings) { InfraredRFReceiveEvent resp{}; #ifdef USE_DEVICES resp.device_id = device_id; #endif resp.key = key; - resp.timings = timings; + resp.timings = &timings; for (auto &c : this->active_clients()) c->send_infrared_rf_receive_event(resp); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 52310747fcf..e41cddd3e80 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -30,6 +30,7 @@ #include #include +#include #include namespace esphome::api { @@ -208,7 +209,7 @@ class APIServer final : public Component void send_serial_proxy_identity(const SerialProxyIdentity &msg); #endif #ifdef USE_IR_RF - void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); + void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, std::span timings); #endif bool is_connected() const { return this->api_connection_count_ != 0; } diff --git a/esphome/components/ir_rf_base/ir_rf_base.cpp b/esphome/components/ir_rf_base/ir_rf_base.cpp index b97841d0df0..93eeec62400 100644 --- a/esphome/components/ir_rf_base/ir_rf_base.cpp +++ b/esphome/components/ir_rf_base/ir_rf_base.cpp @@ -120,7 +120,7 @@ bool IrRfEntity::on_receive(remote_base::RemoteReceiveData data) { #else uint32_t device_id = 0; #endif - api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), data.get_raw_data()); } #endif return false; // Don't consume the event, allow other listeners to process it diff --git a/esphome/components/remote_base/gobox_protocol.cpp b/esphome/components/remote_base/gobox_protocol.cpp index affafa694ca..ef5d4fd66ad 100644 --- a/esphome/components/remote_base/gobox_protocol.cpp +++ b/esphome/components/remote_base/gobox_protocol.cpp @@ -13,7 +13,7 @@ constexpr uint64_t HEADER = 0b011001001100010uL; // 15 bits constexpr size_t HEADER_SIZE = 15; constexpr size_t CODE_SIZE = 17; -void GoboxProtocol::dump_timings_(const RawTimings &timings) const { +void GoboxProtocol::dump_timings_(RawTimingsView timings) const { ESP_LOGD(TAG, "Gobox: size=%u", timings.size()); for (int32_t timing : timings) { ESP_LOGD(TAG, "Gobox: timing=%ld", (long) timing); diff --git a/esphome/components/remote_base/gobox_protocol.h b/esphome/components/remote_base/gobox_protocol.h index 55e8137b752..516d2d3ddb8 100644 --- a/esphome/components/remote_base/gobox_protocol.h +++ b/esphome/components/remote_base/gobox_protocol.h @@ -28,7 +28,7 @@ enum { class GoboxProtocol : public RemoteProtocol { private: - void dump_timings_(const RawTimings &timings) const; + void dump_timings_(RawTimingsView timings) const; public: void encode(RemoteTransmitData *dst, const GoboxData &data); diff --git a/esphome/components/remote_base/pronto_protocol.cpp b/esphome/components/remote_base/pronto_protocol.cpp index e47ba892917..d8808c42d98 100644 --- a/esphome/components/remote_base/pronto_protocol.cpp +++ b/esphome/components/remote_base/pronto_protocol.cpp @@ -190,7 +190,7 @@ std::string ProntoProtocol::dump_duration_(uint32_t duration, uint16_t timebase, return dump_number_((duration + timebase / 2) / timebase, end); } -std::string ProntoProtocol::compensate_and_dump_sequence_(const RawTimings &data, uint16_t timebase) { +std::string ProntoProtocol::compensate_and_dump_sequence_(RawTimingsView data, uint16_t timebase) { std::string out; for (int32_t t_length : data) { @@ -211,7 +211,7 @@ optional ProntoProtocol::decode(RemoteReceiveData src) { ProntoData out; uint16_t frequency = 38000U; - auto &data = src.get_raw_data(); + auto data = src.get_raw_data(); std::string prontodata; prontodata += dump_number_(frequency > 0 ? LEARNED_TOKEN : LEARNED_NON_MODULATED_TOKEN); diff --git a/esphome/components/remote_base/pronto_protocol.h b/esphome/components/remote_base/pronto_protocol.h index 4119b87f286..a03ad574970 100644 --- a/esphome/components/remote_base/pronto_protocol.h +++ b/esphome/components/remote_base/pronto_protocol.h @@ -27,7 +27,7 @@ class ProntoProtocol : public RemoteProtocol { std::string dump_digit_(uint8_t x); std::string dump_number_(uint16_t number, bool end = false); std::string dump_duration_(uint32_t duration, uint16_t timebase, bool end = false); - std::string compensate_and_dump_sequence_(const RawTimings &data, uint16_t timebase); + std::string compensate_and_dump_sequence_(RawTimingsView data, uint16_t timebase); public: void encode(RemoteTransmitData *dst, const ProntoData &data); diff --git a/esphome/components/remote_base/raw_protocol.h b/esphome/components/remote_base/raw_protocol.h index 834ff1dddf7..ff0329b9c61 100644 --- a/esphome/components/remote_base/raw_protocol.h +++ b/esphome/components/remote_base/raw_protocol.h @@ -34,9 +34,18 @@ class RawBinarySensor : public RemoteReceiverBinarySensorBase { class RawTrigger final : public Trigger, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { - this->trigger(src.get_raw_data()); + if (const RawTimings *timings = src.get_raw_vector()) { + this->trigger(*timings); + return false; + } + // A receiver dispatching from its own buffer: x is a vector, so copy the frame into one kept here + RawTimingsView data = src.get_raw_data(); + this->timings_.assign(data.begin(), data.end()); + this->trigger(this->timings_); return false; } + + RawTimings timings_; }; template class RawAction : public RemoteTransmitterActionBase { diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index ddd992266fe..cc9140a2200 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -133,10 +133,10 @@ void RemoteReceiverBase::warn_extra_secondaries_() { } #endif -void RemoteReceiverBase::call_listeners_dumpers_() { +void RemoteReceiverBase::dispatch_(const RemoteReceiveData &src) { #ifdef REMOTE_BASE_LISTENER_COUNT for (auto *listener : this->listeners_) - listener->on_receive(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); + listener->on_receive(src); #endif #ifdef REMOTE_BASE_DUMPER_COUNT bool success = false; @@ -145,12 +145,12 @@ void RemoteReceiverBase::call_listeners_dumpers_() { if (dumper->is_secondary()) { if (secondary == nullptr) secondary = dumper; - } else if (dumper->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_))) { + } else if (dumper->dump(src)) { success = true; } } if (!success && secondary != nullptr) - secondary->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); + secondary->dump(src); #endif } diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 8cc06091cb6..71b733e0537 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -18,6 +19,8 @@ enum ToleranceMode : uint8_t { }; using RawTimings = std::vector; +/// A received frame's timings, viewed in whatever buffer the receiver keeps them. +using RawTimingsView = std::span; enum class BitOrder : uint8_t { BIT_ORDER_LSB_FIRST, @@ -87,10 +90,15 @@ class RemoteTransmitData { class RemoteReceiveData { public: - explicit RemoteReceiveData(const RawTimings &data, uint32_t tolerance, ToleranceMode tolerance_mode) + /// The timings are not copied: they must outlive this object. + explicit RemoteReceiveData(RawTimingsView data, uint32_t tolerance, ToleranceMode tolerance_mode) : data_(data), index_(0), tolerance_(tolerance), tolerance_mode_(tolerance_mode) {} + explicit RemoteReceiveData(const RawTimings &data, uint32_t tolerance, ToleranceMode tolerance_mode) + : data_(data), vector_(&data), index_(0), tolerance_(tolerance), tolerance_mode_(tolerance_mode) {} - const RawTimings &get_raw_data() const { return this->data_; } + RawTimingsView get_raw_data() const { return this->data_; } + /// The timings as a vector when they were given as one, else nullptr. + const RawTimings *get_raw_vector() const { return this->vector_; } uint32_t get_index() const { return index_; } int32_t operator[](uint32_t index) const { return this->data_[index]; } int32_t size() const { return this->data_.size(); } @@ -178,7 +186,8 @@ class RemoteReceiveData { return 0; } - const RawTimings &data_; + RawTimingsView data_; + const RawTimings *vector_{nullptr}; uint32_t index_; uint32_t tolerance_; ToleranceMode tolerance_mode_; @@ -364,7 +373,17 @@ class RemoteReceiverBase : public RemoteComponentBase { #ifdef REMOTE_BASE_DUMPER_COUNT void warn_extra_secondaries_(); #endif - void call_listeners_dumpers_(); + void call_listeners_dumpers_() { + this->dispatch_(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); + } + /// Dispatch a frame held in the receiver's own buffer, without copying it into temp_. + void call_listeners_dumpers_(RawTimingsView data) { + this->dispatch_(RemoteReceiveData(data, this->tolerance_, this->tolerance_mode_)); + } + /// A vector goes through call_listeners_dumpers_() (temp_), which on_raw passes on without a copy. + void call_listeners_dumpers_(const RawTimings &data) = delete; + /// Each listener and dumper gets its own copy of src, starting at index 0. + void dispatch_(const RemoteReceiveData &src); #ifdef REMOTE_BASE_LISTENER_COUNT StaticVector listeners_; diff --git a/esphome/components/vevor_dtx/vevor_dtx.cpp b/esphome/components/vevor_dtx/vevor_dtx.cpp index 364a6ba914f..9508745787e 100644 --- a/esphome/components/vevor_dtx/vevor_dtx.cpp +++ b/esphome/components/vevor_dtx/vevor_dtx.cpp @@ -65,7 +65,7 @@ bool VevorDtxComponent::on_receive(remote_base::RemoteReceiveData data) { // 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 { +bool VevorDtxComponent::decode_timings_(remote_base::RawTimingsView 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 diff --git a/esphome/components/vevor_dtx/vevor_dtx.h b/esphome/components/vevor_dtx/vevor_dtx.h index 56eea99415d..0ecc0f27456 100644 --- a/esphome/components/vevor_dtx/vevor_dtx.h +++ b/esphome/components/vevor_dtx/vevor_dtx.h @@ -53,7 +53,7 @@ class VevorDtxComponent : public Component, public remote_base::RemoteReceiverLi using Payload = std::array; - bool decode_timings_(const remote_base::RawTimings &timings, Payload &payload) const; + bool decode_timings_(remote_base::RawTimingsView timings, Payload &payload) const; bool publish_(const Payload &payload); uint16_t station_id_{0}; // 0 accepts every station in range diff --git a/tests/benchmarks/components/api/bench_proto_proxy.cpp b/tests/benchmarks/components/api/bench_proto_proxy.cpp index c8b56044de5..3ebc52ce848 100644 --- a/tests/benchmarks/components/api/bench_proto_proxy.cpp +++ b/tests/benchmarks/components/api/bench_proto_proxy.cpp @@ -161,15 +161,16 @@ static std::vector make_ir_timings_100() { return v; } -static const std::vector &get_ir_timings_100() { +static const std::span *get_ir_timings_100() { static const std::vector timings = make_ir_timings_100(); - return timings; + static const std::span span(timings); + return &span; } static void Encode_InfraredRFReceiveEvent(benchmark::State &state) { InfraredRFReceiveEvent msg; msg.key = 0xDEADBEEF; - msg.timings = &get_ir_timings_100(); + msg.timings = get_ir_timings_100(); APIBuffer buffer; (void) buffer.resize(msg.calculate_size()); @@ -187,7 +188,7 @@ BENCHMARK(Encode_InfraredRFReceiveEvent); static void CalculateSize_InfraredRFReceiveEvent(benchmark::State &state) { InfraredRFReceiveEvent msg; msg.key = 0xDEADBEEF; - msg.timings = &get_ir_timings_100(); + msg.timings = get_ir_timings_100(); for (auto _ : state) { uint32_t result = 0; diff --git a/tests/components/remote_base/raw_trigger_test.cpp b/tests/components/remote_base/raw_trigger_test.cpp new file mode 100644 index 00000000000..20d3e97f024 --- /dev/null +++ b/tests/components/remote_base/raw_trigger_test.cpp @@ -0,0 +1,34 @@ +#include +#include "esphome/components/remote_base/raw_protocol.h" +#include "esphome/core/base_automation.h" + +namespace esphome::remote_base::testing { + +namespace { + +// Records x from every firing of an on_raw trigger. +struct Recorder { + explicit Recorder(RawTrigger *trigger) : automation(trigger) { this->automation.add_action(&this->action); } + std::vector frames; + LambdaAction action{[this](const RawTimings &x) { this->frames.push_back(x); }}; + Automation automation; +}; + +} // namespace + +TEST(RawTriggerTest, CopiesFramesFromAReceiverBuffer) { + RawTrigger trigger; + Recorder recorder(&trigger); + RemoteReceiverListener *listener = &trigger; + + static const int32_t LONG_FRAME[] = {100, -200, 300, -400}; + static const int32_t SHORT_FRAME[] = {500, -600}; + listener->on_receive(RemoteReceiveData(RawTimingsView(LONG_FRAME), 25, TOLERANCE_MODE_PERCENTAGE)); + listener->on_receive(RemoteReceiveData(RawTimingsView(SHORT_FRAME), 25, TOLERANCE_MODE_PERCENTAGE)); + + ASSERT_EQ(recorder.frames.size(), 2u); + EXPECT_EQ(recorder.frames[0], (RawTimings{100, -200, 300, -400})); + EXPECT_EQ(recorder.frames[1], (RawTimings{500, -600})); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_base/receive_data_test.cpp b/tests/components/remote_base/receive_data_test.cpp new file mode 100644 index 00000000000..d01d6022bb7 --- /dev/null +++ b/tests/components/remote_base/receive_data_test.cpp @@ -0,0 +1,23 @@ +#include +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::remote_base::testing { + +TEST(RemoteReceiveDataTest, KeepsTheVectorItWasGiven) { + const RawTimings timings{100, -200, 300}; + RemoteReceiveData data(timings, 25, TOLERANCE_MODE_PERCENTAGE); + EXPECT_EQ(data.get_raw_vector(), &timings); + EXPECT_EQ(data.get_raw_data().data(), timings.data()); + EXPECT_EQ(data.size(), 3); +} + +TEST(RemoteReceiveDataTest, ViewsABufferWithoutAVector) { + static const int32_t BUFFER[] = {100, -200, 300, -400}; + RemoteReceiveData data(RawTimingsView(BUFFER, 3), 25, TOLERANCE_MODE_PERCENTAGE); + EXPECT_EQ(data.get_raw_vector(), nullptr); + EXPECT_EQ(data.get_raw_data().data(), BUFFER); + EXPECT_EQ(data.size(), 3); + EXPECT_TRUE(data.expect_mark(100)); +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index accfdf1b2cf..7720821aed3 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -129,10 +129,16 @@ on_pronto: format: "on_pronto: %s" args: ["x.data.c_str()"] on_raw: - then: - - logger.log: - format: "on_raw: %ld" - args: ["(long) x.front()"] + - then: + - logger.log: + format: "on_raw: %ld" + args: ["(long) x.front()"] + # A deferring automation gets its own copy of x + - then: + - delay: 10ms + - logger.log: + format: "on_raw later: %u" + args: ["(unsigned) x.size()"] on_rc5: then: - logger.log: From c64d5f149f5d715be4cdc732d1e3ac91d6b7ee74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 12:52:42 -0600 Subject: [PATCH 23/55] [ai] Prefer generated decoding for fixed register fields in the codegen skill (#22093) --- .agents/skills/component-codegen/SKILL.md | 34 +++++++++++++++++++++-- .agents/skills/embedded-memory/SKILL.md | 7 +++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/.agents/skills/component-codegen/SKILL.md b/.agents/skills/component-codegen/SKILL.md index f7d99d43736..5d6364b3560 100644 --- a/.agents/skills/component-codegen/SKILL.md +++ b/.agents/skills/component-codegen/SKILL.md @@ -188,9 +188,37 @@ Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `res drive a state when the restore mode is disabled. A hub that applies its switches' restored states itself reads `sw->get_initial_write_state()`, which already applies `inverted`. -**Entities that each watch a register field:** one class taking register, shift, mask and match from a -Python tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a -virtual listener base (a second vptr per entity). +**A hub that forwards data to its children:** when what each child does with the data is known at build time +(decode a register field, match a key or tag, publish a value), declare a `cg.HubFunction` once in the hub's +`__init__.py` and have each platform add one item per entity, built from codegen expressions. Code generation writes +one function per hub. Pass `define=` and put the hub's `HUB_PUBLISHER(, args...)` (in a public section; it +ends on `public:`) and its call under that `#ifdef`; call the pointer only when it is not null. The children stay +plain entities, with no listener class or list. A child that lost a `Component` base keeps its dump lines through +a second hub function (`"dump"`, a `const char *` tag parameter, no define of its own) that the hub's +`dump_config()` calls inside the value function's `#ifdef`; every child adds to both. + +**Entities that each watch a register field:** when every field (register, shift, mask, match) is known at +build time, use the rule above (see `bq25186`). A binary sensor tests its field value against an on-values +bitmap (`match`); a one-bit field reads the bit (simplified from bq25186's `_state_expression`): +```python +# hub __init__.py +STATUS_PUBLISHER = cg.HubFunction( + "status", # esphome____status_fn, passed to HUB_PUBLISHER(status, ...)'s setter + cg.void, + [(BQ25186StatusRegisters.const_ref(), "status")], + define="USE_BQ25186_STATUS_PUBLISHER", +) +STATUS = STATUS_PUBLISHER.param("status") + +# platform to_code, per configured entity +value = (STATUS[reg] >> shift) & mask +if mask != 0x01 or match != 0b10: # 0b10: a one-bit field that is on when set + value = (cg.MockObj(f"{match:#06b}") >> value) & 1 +STATUS_PUBLISHER.add(hub, parent_ref(var).publish_state(value)) +``` +Store a field description per entity (a struct in a `StaticVector` sized by `cg.slot_counter`) only when the +field is chosen at run time, as in `sen6x`, where the status bit depends on the variant read in `setup()`. +Never a template per bit or a virtual listener base (a second vptr per entity). ## Other helpers diff --git a/.agents/skills/embedded-memory/SKILL.md b/.agents/skills/embedded-memory/SKILL.md index ab66687841b..8e16ded4423 100644 --- a/.agents/skills/embedded-memory/SKILL.md +++ b/.agents/skills/embedded-memory/SKILL.md @@ -58,8 +58,11 @@ APIs. Use `cg.add_define("MAX_SERVICES", count)` to set the size from Python configuration. **Listener and child-entity registration lists are the most common case, and the most commonly - missed.** A `register_*()` method called once per child at code generation time has a count that is - known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it returns a + missed.** When the hub only forwards data to its children and each child's handling is known at build + time, keep no list at all: use `cg.HubFunction` (see the component-codegen skill). When the hub must + store the children (it iterates them for other reasons, or picks fields at run time), a `register_*()` + method called once per child at code generation time has a count that is known at compile time, so it + should never be a `std::vector`. Use `cg.slot_counter()`: it returns a function that each consumer calls once per slot it will occupy, and after every `to_code` has run it emits the define with the final count. When nothing registers, no define is emitted and the storage plus its registration method compile out entirely. From 9b138d17de16002cb7447b432915744ea7349122 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:32:10 +0000 Subject: [PATCH 24/55] Bump platformdirs from 4.14.2 to 4.14.3 (#22271) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 19c52a9aeb6..3a268cf4620 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.2.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.4 -platformdirs==4.14.2 # native esp-idf toolchain global cache dir +platformdirs==4.14.3 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.2.6 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From de24754b4dc297cd932529db8be2e6fc9310aac7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:32:33 +0000 Subject: [PATCH 25/55] Bump zeroconf from 0.152.0 to 0.153.0 (#22273) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3a268cf4620..0ca8e3ee364 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ esptool==5.6.0 click==8.5.0 aioesphomeapi==46.25.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi -zeroconf==0.152.0 +zeroconf==0.153.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 124969267d4917c39b2016041ea70dd8a44d2c4e Mon Sep 17 00:00:00 2001 From: mtmatteo79 <74114415+mtmatteo79@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:45:33 +0100 Subject: [PATCH 26/55] [bthome_mithermometer] Add opt-in replay protection (#19957) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../bthome_mithermometer/__init__.py | 16 +++ .../bthome_mithermometer/bthome_ble.cpp | 53 ++++++++ .../bthome_mithermometer/bthome_ble.h | 22 ++++ esphome/core/defines.h | 1 + .../bthome_mithermometer/__init__.py | 12 ++ .../bthome_mithermometer/common-ln.yaml | 1 + .../components/bthome_mithermometer/common.h | 39 ++++++ .../bthome_mithermometer/common.yaml | 1 + .../sensor/bthome_objects_test.cpp | 21 +-- .../sensor/bthome_replay_test.cpp | 121 ++++++++++++++++++ 10 files changed, 268 insertions(+), 19 deletions(-) create mode 100644 tests/components/bthome_mithermometer/__init__.py create mode 100644 tests/components/bthome_mithermometer/common.h create mode 100644 tests/components/bthome_mithermometer/sensor/bthome_replay_test.cpp diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index fe78162c650..4fb814b8103 100644 --- a/esphome/components/bthome_mithermometer/__init__.py +++ b/esphome/components/bthome_mithermometer/__init__.py @@ -10,6 +10,8 @@ CODEOWNERS = ["@nagyrobi"] AUTO_LOAD = ["ble_device_base"] DOMAIN = "bthome_mithermometer" +CONF_REPLAY_PROTECTION = "replay_protection" + bthome_mithermometer_ns = cg.esphome_ns.namespace("bthome_mithermometer") BTHomeMiThermometer = bthome_mithermometer_ns.class_( @@ -29,14 +31,25 @@ def bthome_mithermometer_base_schema( cv.GenerateID(CONF_ID): cv.declare_id(BTHomeMiThermometer), cv.Required(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_BINDKEY): cv.bind_key, + cv.Optional(CONF_REPLAY_PROTECTION, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA) .extend(extra_schema) .extend(ble_device_base.BLE_DEVICE_SCHEMA), + _validate_replay_protection, ) +def _validate_replay_protection(config: ConfigType) -> ConfigType: + if config[CONF_REPLAY_PROTECTION] and CONF_BINDKEY not in config: + raise cv.Invalid( + f"'{CONF_REPLAY_PROTECTION}' requires '{CONF_BINDKEY}'", + path=[CONF_REPLAY_PROTECTION], + ) + return config + + async def setup_bthome_mithermometer(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) await ble_device_base.register_ble_device(var, config) @@ -47,3 +60,6 @@ async def setup_bthome_mithermometer(var: MockObj, config: ConfigType) -> None: for index in range(0, len(bindkey), 2) ] cg.add(var.set_bindkey(cg.ArrayInitializer(*bindkey_bytes))) + if config[CONF_REPLAY_PROTECTION]: + cg.add_define("USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION") + cg.add(var.set_replay_protection(True)) diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index 17982d77b40..776d29b97f0 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -1,10 +1,12 @@ #include "bthome_ble.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include #include +#include #include #include @@ -31,6 +33,9 @@ static constexpr size_t BTHOME_BINDKEY_SIZE = 16; static constexpr size_t BTHOME_NONCE_SIZE = 13; static constexpr size_t BTHOME_MIC_SIZE = 4; static constexpr size_t BTHOME_COUNTER_SIZE = 4; +// If no frame with a higher counter has been accepted for this long, a lower +// counter is accepted again (the sensor rebooted, e.g. after a battery swap). +static constexpr uint32_t BTHOME_REPLAY_RESYNC_MS = 10 * 60 * 1000; // Both callers are log macros (LOGCONFIG / LOGVV); below CONFIG level they // compile away and an ungated helper trips -Wunused-function. @@ -156,6 +161,9 @@ void BTHomeMiThermometer::dump_config() { if (this->has_bindkey_) { char bindkey_hex[format_hex_pretty_size(BTHOME_BINDKEY_SIZE)]; ESP_LOGCONFIG(TAG, " Bindkey: %s", format_hex_pretty_to(bindkey_hex, this->bindkey_, BTHOME_BINDKEY_SIZE, '.')); +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + ESP_LOGCONFIG(TAG, " Replay Protection: %s", YESNO(this->replay_protection_)); +#endif } LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); @@ -253,6 +261,40 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da return true; } +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION +// PVVX sends new data under an unchanged counter, so only an exact repeat (counter and MIC) is a duplicate and +// earlier frames under the current counter can be replayed; the lower-counter rule is the defence. It holds only +// while the sensor keeps being heard: after the resync window one old frame becomes the baseline and recorded +// frames above it can follow in order, the price of recovering from a counter restart. RAM only. +bool BTHomeMiThermometer::check_replay_counter_(uint32_t counter, uint32_t mic) { + const uint32_t now = App.get_loop_component_start_time(); + if (counter == this->last_counter_ && mic == this->last_mic_) { + ESP_LOGVV(TAG, "Duplicate BTHome frame (counter %" PRIu32 ")", counter); + return false; + } + if (counter < this->last_counter_) { + if (now - this->last_accepted_ms_ < BTHOME_REPLAY_RESYNC_MS) { + // One warning per run of rejections + if (this->replay_rejected_) { + ESP_LOGVV(TAG, "BTHome frame rejected: counter %" PRIu32 " lower than last %" PRIu32, counter, + this->last_counter_); + } else { + ESP_LOGW(TAG, "BTHome frame rejected: counter %" PRIu32 " lower than last %" PRIu32 " (replay?)", counter, + this->last_counter_); + this->replay_rejected_ = true; + } + return false; + } + ESP_LOGW(TAG, "BTHome counter restarted (%" PRIu32 " -> %" PRIu32 "), resync", this->last_counter_, counter); + } + this->last_counter_ = counter; + this->last_mic_ = mic; + this->last_accepted_ms_ = now; + this->replay_rejected_ = false; + return true; +} +#endif + bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceData &service_data, const ble_device_base::ESPBTDevice &device) { if (!service_data.uuid.contains(0xD2, 0xFC)) { @@ -339,6 +381,17 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat return false; } +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + // After the MIC and address checks, so other devices never touch the state + if (is_encrypted && this->replay_protection_) { + const uint8_t *tail = data.data() + data.size() - BTHOME_COUNTER_SIZE - BTHOME_MIC_SIZE; + if (!this->check_replay_counter_(encode_value_le(tail), + encode_value_le(tail + BTHOME_COUNTER_SIZE))) { + return false; + } + } +#endif + if (payload_size == 0) { ESP_LOGVV(TAG, "BTHome payload empty after header"); return false; diff --git a/esphome/components/bthome_mithermometer/bthome_ble.h b/esphome/components/bthome_mithermometer/bthome_ble.h index 0978e569511..cd222225083 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.h +++ b/esphome/components/bthome_mithermometer/bthome_ble.h @@ -13,6 +13,10 @@ // decryption availability is selected per platform in the .cpp. namespace esphome::bthome_mithermometer { +namespace testing { +class ReplayClockAccess; +} // namespace testing + class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, public Component { SUB_SENSOR(temperature) SUB_SENSOR(humidity) @@ -23,18 +27,36 @@ class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, p public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(std::initializer_list bindkey); +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + void set_replay_protection(bool replay_protection) { this->replay_protection_ = replay_protection; } +#endif void dump_config() override; bool parse_device(const ble_device_base::ESPBTDevice &device) override; protected: +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + friend class testing::ReplayClockAccess; +#endif bool handle_service_data_(const ble_device_base::ServiceData &service_data, const ble_device_base::ESPBTDevice &device); bool decrypt_bthome_payload_(const std::vector &data, uint64_t source_address, std::vector &payload) const; +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + bool check_replay_counter_(uint32_t counter, uint32_t mic); +#endif uint64_t address_{0}; +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + uint32_t last_counter_{0}; + uint32_t last_mic_{0}; + uint32_t last_accepted_ms_{0}; +#endif optional last_packet_id_{}; +#ifdef USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION + bool replay_protection_{false}; + bool replay_rejected_{false}; +#endif bool has_bindkey_{false}; uint8_t bindkey_[16]; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 48649c3c519..f531cfe5946 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -63,6 +63,7 @@ #define USE_BMI270_BMM150 #define USE_BQ25186_PG_GPO #define USE_BQ25186_STATUS_PUBLISHER +#define USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE diff --git a/tests/components/bthome_mithermometer/__init__.py b/tests/components/bthome_mithermometer/__init__.py new file mode 100644 index 00000000000..63589054d5f --- /dev/null +++ b/tests/components/bthome_mithermometer/__init__.py @@ -0,0 +1,12 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The replay check is compiled only when a sensor enables replay_protection; the + # unit-test build has no sensors, so emit the define here to put it under test. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION") + + manifest.to_code = to_code_testing diff --git a/tests/components/bthome_mithermometer/common-ln.yaml b/tests/components/bthome_mithermometer/common-ln.yaml index 947f8890f86..e2a85fd38e2 100644 --- a/tests/components/bthome_mithermometer/common-ln.yaml +++ b/tests/components/bthome_mithermometer/common-ln.yaml @@ -3,6 +3,7 @@ sensor: mac_address: A4:C1:38:4E:16:78 # bindkey compiles ble_device_base::aes_ccm_auth_decrypt off Espressif bindkey: eef418daf699a0c188f3bfd17e4565d9 + replay_protection: true temperature: name: BTHome Temperature humidity: diff --git a/tests/components/bthome_mithermometer/common.h b/tests/components/bthome_mithermometer/common.h new file mode 100644 index 00000000000..e0cdfffbc63 --- /dev/null +++ b/tests/components/bthome_mithermometer/common.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "esphome/components/bthome_mithermometer/bthome_ble.h" + +namespace esphome::bthome_mithermometer::testing { + +static constexpr uint64_t SENSOR_ADDRESS = 0xA4C1384E1678ULL; + +// BTHome v2 service data advertisement (UUID 0xFCD2) sent from `address`. +inline ble_device_base::ESPBTDevice advert(const uint8_t *service_data, size_t size, + uint64_t address = SENSOR_ADDRESS) { + std::vector adv = {static_cast(size + 3), 0x16, 0xD2, 0xFC}; + adv.insert(adv.end(), service_data, service_data + size); + uint8_t mac[6]; + for (size_t i = 0; i < 6; i++) + mac[i] = static_cast(address >> (i * 8)); + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac, -60, 0, adv.data(), static_cast(adv.size())); + return device; +} + +inline ble_device_base::ESPBTDevice advert(std::initializer_list service_data, + uint64_t address = SENSOR_ADDRESS) { + return advert(service_data.begin(), service_data.size(), address); +} + +template +inline ble_device_base::ESPBTDevice advert(const std::array &service_data, + uint64_t address = SENSOR_ADDRESS) { + return advert(service_data.data(), N, address); +} + +} // namespace esphome::bthome_mithermometer::testing diff --git a/tests/components/bthome_mithermometer/common.yaml b/tests/components/bthome_mithermometer/common.yaml index d61738bbe51..f70d8997344 100644 --- a/tests/components/bthome_mithermometer/common.yaml +++ b/tests/components/bthome_mithermometer/common.yaml @@ -7,6 +7,7 @@ sensor: ble_hub_id: ble_tracker_hub mac_address: A4:C1:38:4E:16:78 bindkey: eef418daf699a0c188f3bfd17e4565d9 + replay_protection: true temperature: name: "BTHome Temperature" humidity: diff --git a/tests/components/bthome_mithermometer/sensor/bthome_objects_test.cpp b/tests/components/bthome_mithermometer/sensor/bthome_objects_test.cpp index 2e3275cc015..0242721cc1e 100644 --- a/tests/components/bthome_mithermometer/sensor/bthome_objects_test.cpp +++ b/tests/components/bthome_mithermometer/sensor/bthome_objects_test.cpp @@ -1,29 +1,12 @@ #include -#include -#include -#include - -#include "esphome/components/bthome_mithermometer/bthome_ble.h" +#include "../common.h" namespace esphome::bthome_mithermometer::testing { namespace { -constexpr uint64_t SENSOR_ADDRESS = 0xA4C1384E1678ULL; - -// Unencrypted BTHome v2 service data (UUID 0xFCD2) sent from SENSOR_ADDRESS. -ble_device_base::ESPBTDevice advert(std::initializer_list service_data) { - std::vector adv = {static_cast(service_data.size() + 3), 0x16, 0xD2, 0xFC}; - adv.insert(adv.end(), service_data.begin(), service_data.end()); - uint8_t mac[6]; - for (size_t i = 0; i < 6; i++) - mac[i] = static_cast(SENSOR_ADDRESS >> (i * 8)); - ble_device_base::ESPBTDevice device; - device.from_scan_result(mac, -60, 0, adv.data(), static_cast(adv.size())); - return device; -} - +// Service data in these tests is unencrypted BTHome v2. struct Harness { Harness() { this->thermometer.set_address(SENSOR_ADDRESS); diff --git a/tests/components/bthome_mithermometer/sensor/bthome_replay_test.cpp b/tests/components/bthome_mithermometer/sensor/bthome_replay_test.cpp new file mode 100644 index 00000000000..84f852a0cc1 --- /dev/null +++ b/tests/components/bthome_mithermometer/sensor/bthome_replay_test.cpp @@ -0,0 +1,121 @@ +#include + +#include +#include + +#include "../common.h" + +namespace esphome::bthome_mithermometer::testing { + +// Ages the last accepted frame past the resync window; the host loop clock stays at 0. +class ReplayClockAccess { + public: + static void expire_window(BTHomeMiThermometer &thermometer) { + thermometer.last_accepted_ms_ = static_cast(0) - (10 * 60 * 1000 + 1); + } +}; + +namespace { + +// Encrypted BTHome v2 frames for SENSOR_ADDRESS, generated with Python `cryptography` +// AESCCM(tag_length=4) and the bindkey below. Layout: device info (0x41), ciphertext, +// counter (little-endian), MIC. Each plaintext is a single temperature object (0x02). +// Bindkey: eef418daf699a0c188f3bfd17e4565d9 +using Frame = std::array; + +// Counter 10, 22.50 C +constexpr Frame COUNTER_10 = {0x41, 0xB9, 0xBD, 0x25, 0x0A, 0x00, 0x00, 0x00, 0x47, 0x51, 0x79, 0x04}; +// Counter 11, 23.48 C +constexpr Frame COUNTER_11 = {0x41, 0xD8, 0x9D, 0x54, 0x0B, 0x00, 0x00, 0x00, 0x4F, 0x5D, 0x89, 0xF2}; +// Counter 11 again, 4.00 C +constexpr Frame COUNTER_11_OTHER = {0x41, 0xD8, 0x21, 0x5C, 0x0B, 0x00, 0x00, 0x00, 0x72, 0xF8, 0x2B, 0x55}; +// Counter 9, 4.00 C +constexpr Frame COUNTER_9 = {0x41, 0x17, 0xB6, 0x8D, 0x09, 0x00, 0x00, 0x00, 0xFB, 0x42, 0xC4, 0x2D}; + +// Counter 100 from another device sharing the bindkey (address SENSOR_ADDRESS + 1), 30.00 C +constexpr uint64_t OTHER_ADDRESS = SENSOR_ADDRESS + 1; +constexpr Frame OTHER_COUNTER_100 = {0x41, 0x48, 0xD7, 0xD1, 0x64, 0x00, 0x00, 0x00, 0x09, 0x5B, 0xEC, 0x22}; + +struct Harness { + explicit Harness(bool replay_protection = true) { + this->thermometer.set_address(SENSOR_ADDRESS); + this->thermometer.set_bindkey( + {0xEE, 0xF4, 0x18, 0xDA, 0xF6, 0x99, 0xA0, 0xC1, 0x88, 0xF3, 0xBF, 0xD1, 0x7E, 0x45, 0x65, 0xD9}); + this->thermometer.set_replay_protection(replay_protection); + this->thermometer.set_temperature_sensor(&this->temperature); + } + + BTHomeMiThermometer thermometer; + sensor::Sensor temperature; +}; + +} // namespace + +TEST(BTHomeMiThermometerReplay, AcceptsIncreasingCounters) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_10))); + EXPECT_NEAR(h.temperature.state, 22.5f, 0.001f); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_11))); + EXPECT_NEAR(h.temperature.state, 23.48f, 0.001f); +} + +TEST(BTHomeMiThermometerReplay, DropsExactRepeat) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_11))); + EXPECT_FALSE(h.thermometer.parse_device(advert(COUNTER_11))); +} + +// Some firmwares (e.g. PVVX) send new data under the same counter. +TEST(BTHomeMiThermometerReplay, AcceptsNewDataWithSameCounter) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_11))); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_11_OTHER))); + EXPECT_NEAR(h.temperature.state, 4.0f, 0.001f); +} + +TEST(BTHomeMiThermometerReplay, RejectsLowerCounter) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_10))); + EXPECT_FALSE(h.thermometer.parse_device(advert(COUNTER_9))); + EXPECT_NEAR(h.temperature.state, 22.5f, 0.001f); +} + +// The first frame after boot has nothing to compare against and is accepted. +TEST(BTHomeMiThermometerReplay, AcceptsFirstFrameWithAnyCounter) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_9))); + EXPECT_NEAR(h.temperature.state, 4.0f, 0.001f); +} + +// Without replay_protection the counter is not checked at all. +TEST(BTHomeMiThermometerReplay, DisabledAcceptsLowerCounter) { + Harness h(false); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_10))); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_9))); + EXPECT_NEAR(h.temperature.state, 4.0f, 0.001f); +} + +// A frame from another device that decrypts with the same bindkey is rejected +// by the address check and must not move the replay counter. +TEST(BTHomeMiThermometerReplay, OtherDeviceDoesNotAffectCounter) { + Harness h; + EXPECT_FALSE(h.thermometer.parse_device(advert(OTHER_COUNTER_100, OTHER_ADDRESS))); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_10))); + EXPECT_NEAR(h.temperature.state, 22.5f, 0.001f); +} + +// A lower counter is accepted again once no frame has been accepted for ten minutes, so a sensor whose counter +// restarted recovers; the warning flag set by the rejection clears with the accepted frame. +TEST(BTHomeMiThermometerReplay, AcceptsLowerCounterAfterTheResyncWindow) { + Harness h; + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_11))); + EXPECT_FALSE(h.thermometer.parse_device(advert(COUNTER_9))); + ReplayClockAccess::expire_window(h.thermometer); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_9))); + EXPECT_NEAR(h.temperature.state, 4.0f, 0.001f); + ASSERT_TRUE(h.thermometer.parse_device(advert(COUNTER_10))); + EXPECT_NEAR(h.temperature.state, 22.5f, 0.001f); + EXPECT_FALSE(h.thermometer.parse_device(advert(COUNTER_9))); +} + +} // namespace esphome::bthome_mithermometer::testing From f926fabbd8d133c4bf0415b22c7dab8a960d24a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:46:21 -0600 Subject: [PATCH 27/55] Bump pytest-asyncio from 1.4.1 to 1.4.1.post1 (#22272) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9a8e9e9ac34..6ea2c15f48b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,7 +9,7 @@ pytest==9.2.0 pytest-cov==7.1.0 pytest-mock==3.17.0 pytest-timeout==2.4.0 -pytest-asyncio==1.4.1 +pytest-asyncio==1.4.1.post1 pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 From e1c781871bcb6a3fd623b9e3443eba505470d550 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:54:30 +0000 Subject: [PATCH 28/55] Bump aioesphomeapi from 46.25.1 to 46.26.0 (#22274) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0ca8e3ee364..6254446ca4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.6.0 click==8.5.0 -aioesphomeapi==46.25.1 +aioesphomeapi==46.26.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.153.0 puremagic==2.2.0 From e83e892514483093be6ee1127040f8d7e5396ff2 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:04:52 -0500 Subject: [PATCH 29/55] [usb_host] Read `iInterface` for connected interfaces (#21781) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 1 + esphome/components/api/api_pb2.cpp | 2 + esphome/components/api/api_pb2.h | 1 + esphome/components/api/api_pb2_dump.cpp | 1 + .../components/serial_proxy/serial_proxy.cpp | 4 +- esphome/components/usb_host/__init__.py | 9 +- esphome/components/usb_host/usb_host.h | 38 ++++- .../components/usb_host/usb_host_client.cpp | 136 +++++++++++++++++- esphome/components/usb_uart/usb_uart.cpp | 30 +++- esphome/components/usb_uart/usb_uart.h | 11 ++ .../test_usb_device_descriptor.cpp | 12 ++ .../usb_host/test.esp32-p4-idf.yaml | 1 + .../usb_host/test.esp32-s3-idf.yaml | 1 + tests/components/usb_uart/common.yaml | 1 + tests/unit_tests/components/test_usb_host.py | 25 ++++ 15 files changed, 261 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 41edb718916..6c75a569b81 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -3078,6 +3078,7 @@ message UsbDeviceDescriptor { uint32 interface_number = 4; // bInterfaceNumber the port is bound through uint32 interface_class = 5; // bInterfaceClass of that interface uint32 port_number = 6; + string interface_description = 7; // iInterface string of that interface, empty when it has none } // 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 0f31c82596f..1c9bd13eaab 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4333,6 +4333,7 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ 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); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.interface_description); return pos; } uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { @@ -4344,6 +4345,7 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.interface_number); size += ProtoSize::calc_uint32(1, msg.interface_class); size += ProtoSize::calc_uint32(1, msg.port_number); + size += ProtoSize::calc_length(1, msg.interface_description.size()); 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 6023801a156..ddf342dd62f 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -4122,6 +4122,7 @@ class UsbDeviceDescriptor final : public ProtoMessage { uint32_t interface_number{0}; uint32_t interface_class{0}; uint32_t port_number{0}; + StringRef interface_description{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 51460174d01..bfd393c70d4 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2987,6 +2987,7 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const { 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); + dump_field(out, ESPHOME_PSTR("interface_description"), this->interface_description); 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 3cbc3ab7f90..11a00df96fa 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -427,6 +427,7 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: return; } auto *client = this->usb_channel_->get_parent(); + const uint8_t interface_number = this->usb_channel_->get_interface_number(); msg.flags = api::enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED; if (!client->get_device_info(scratch)) { msg.flags |= api::enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR; @@ -435,9 +436,10 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: msg.usb.vendor_id = scratch.vendor_id; 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_number = interface_number; msg.usb.interface_class = this->usb_channel_->get_interface_class(); msg.usb.port_number = this->usb_channel_->get_port_number(); + msg.usb.interface_description = StringRef(this->usb_channel_->get_interface_description()); msg.manufacturer = StringRef(scratch.manufacturer); msg.product = StringRef(scratch.product); msg.serial_number = StringRef(scratch.serial_number); diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 7dbe6188f4f..a6991e269de 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -38,16 +38,18 @@ DOMAIN = "usb_host" CONF_VID = "vid" CONF_PID = "pid" CONF_PRODUCT = "product" +CONF_INTERFACE_DESCRIPTION = "interface_description" CONF_ENABLE_HUBS = "enable_hubs" CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests" -# VID/PID set to 0 or `None` product/manufacturer are wildcards +# VID/PID set to 0 or `None` strings are wildcards _FILTER_WILDCARDS = { CONF_VID: 0, CONF_PID: 0, CONF_MANUFACTURER: None, CONF_PRODUCT: None, + CONF_INTERFACE_DESCRIPTION: None, } @@ -72,6 +74,7 @@ def usb_device_schema( { cv.Optional(CONF_MANUFACTURER): cv.string_strict, cv.Optional(CONF_PRODUCT): cv.string_strict, + cv.Optional(CONF_INTERFACE_DESCRIPTION): cv.string_strict, } ) @@ -297,6 +300,10 @@ async def register_usb_client(config: ConfigType) -> MockObj: ) if (product := config.get(CONF_PRODUCT)) is not None: cg.add(var.set_product_filter(cg.RawExpression(cpp_u16string_escape(product)))) + if (interface := config.get(CONF_INTERFACE_DESCRIPTION)) is not None: + cg.add( + var.set_interface_filter(cg.RawExpression(cpp_u16string_escape(interface))) + ) return var diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index bbb6f15f292..2a9ead39c9a 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -57,6 +57,12 @@ static constexpr uint8_t USB_DIR_MASK = 1 << 7; static constexpr uint8_t USB_DIR_IN = 1 << 7; static constexpr uint8_t USB_DIR_OUT = 0; static constexpr size_t SETUP_PACKET_SIZE = 8; +// A string descriptor is at most 255 bytes, since bLength is one byte +static constexpr size_t STRING_DESC_MAX_SIZE = 255; +// Room for the setup packet and the largest string, read in whole packets of a 64-byte EP0 +static constexpr size_t STRING_TRANSFER_SIZE = SETUP_PACKET_SIZE + 256; +// The host stack enumerates with this language only, so the strings it caches use it too +static constexpr uint16_t STRING_DESC_LANGID = 0x0409; static constexpr size_t MAX_REQUESTS = USB_HOST_MAX_REQUESTS; // maximum number of outstanding requests possible. static_assert(MAX_REQUESTS >= 1 && MAX_REQUESTS <= 32, "MAX_REQUESTS must be between 1 and 32"); @@ -135,9 +141,9 @@ struct UsbDeviceInfo { }; /// Copy a USB string descriptor into a NUL-terminated buffer. A missing descriptor copies as -/// an empty string. Returns false when a descriptor contains non-ASCII characters, -/// UTF-16 to UTF-8 conversion is not currently implemented. -bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer); +/// an empty string. Returns false, leaving the buffer empty, when the string does not fit or +/// contains non-ASCII characters; UTF-16 to UTF-8 conversion is not currently implemented. +bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer); enum ClientState { USB_CLIENT_INIT = 0, @@ -167,6 +173,8 @@ class USBClient : public Component { const std::vector &data = {}); void set_manufacturer_filter(const char16_t *manufacturer) { this->manufacturer_filter_ = manufacturer; } void set_product_filter(const char16_t *product) { this->product_filter_ = product; } + /// A device matches when any of its interfaces carries exactly this iInterface string + void set_interface_filter(const char16_t *interface) { this->interface_filter_ = interface; } /// Whether a device has been opened and its setup by the subclass has finished bool is_connected() const { return this->connection_reported_; } @@ -199,7 +207,21 @@ class USBClient : public Component { // with their own work check for a single disable_loop() decision. bool process_usb_events_(); void handle_open_state_(); + void handle_get_desc_state_(); + void finish_open_(); + /// Start reading the iInterface string of an interface; the host stack does not cache + /// those. Completes on the main loop through on_interface_string(), one read at a time. + /// Returns false, with no completion to follow, when the interface has no string or the + /// read could not be started. + bool read_interface_string_(uint8_t interface_number); + /// The string read_interface_string_() asked for, or null when the read failed. It lives + /// in the transfer buffer only until the next read, so copy what is needed. + virtual void on_interface_string(uint8_t interface_number, const usb_str_desc_t *desc) {} + const usb_str_desc_t *string_result_() const; + static void string_transfer_callback(usb_transfer_t *xfer); TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe) + /// Must not be called while a control transfer is in flight: the host stack asserts on + /// closing a device with one pending, and has no way to cancel it virtual void disconnect(); virtual void on_connected() {} @@ -220,6 +242,8 @@ class USBClient : public Component { // Members ordered to minimize struct padding on 32-bit platforms TransferRequest requests_[MAX_REQUESTS]{}; + // Reads iInterface strings: a string can outgrow the packet-sized pool buffers + usb_transfer_t *string_transfer_{nullptr}; TaskHandle_t usb_task_handle_{nullptr}; usb_host_client_handle_t handle_{}; usb_device_handle_t device_handle_{}; @@ -232,8 +256,16 @@ class USBClient : public Component { std::atomic trq_in_use_; const char16_t *manufacturer_filter_{nullptr}; const char16_t *product_filter_{nullptr}; + const char16_t *interface_filter_{nullptr}; uint16_t vid_{}; uint16_t pid_{}; + // Set by the USB task when the string transfer completes, cleared by the main loop + std::atomic string_done_{0}; + // The interface whose string was last asked for + uint8_t string_interface_{0}; + // The next interface the filter pass reads, and whether one has matched the filter so far + uint8_t filter_interface_{0}; + bool interface_matched_{false}; // Whether the connection callbacks were told about the current device, so a removal is // only ever reported for a device that was reported connected bool connection_reported_{false}; diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 1b50aa75baf..254689a7a70 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -147,14 +147,15 @@ static void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc #endif // bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType), // so character count = (bLength - 2) / 2. -bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { +bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { buffer[0] = '\0'; if (desc == nullptr || desc->bLength < 2) return true; - int char_count = (desc->bLength - 2) / 2; + const size_t char_count = (desc->bLength - 2) / 2; + if (char_count >= buffer.size()) + return false; char *p = buffer.data(); - char *end = p + buffer.size() - 1; - for (int i = 0; i != char_count && p < end; i++) { + for (size_t i = 0; i != char_count; i++) { auto c = desc->wData[i]; // TODO: encode non-ASCII code units as UTF-8 if a device with such descriptors turns up if (c >= 0x80) { @@ -287,9 +288,17 @@ void USBClient::setup() { // Pre-allocate USB transfer buffers for all slots at startup // This avoids any dynamic allocation during runtime for (auto &request : this->requests_) { - usb_host_transfer_alloc(USB_MAX_PACKET_SIZE, 0, &request.transfer); + if (usb_host_transfer_alloc(USB_MAX_PACKET_SIZE, 0, &request.transfer) != ESP_OK) { + this->mark_failed(LOG_STR("Transfer alloc failed")); + return; + } request.client = this; // Set once, never changes } + if (usb_host_transfer_alloc(STRING_TRANSFER_SIZE, 0, &this->string_transfer_) != ESP_OK) { + this->mark_failed(LOG_STR("Transfer alloc failed")); + return; + } + this->string_transfer_->context = this; // Create and start USB task xTaskCreate(usb_task_fn, "usb_task", @@ -342,6 +351,19 @@ bool USBClient::process_usb_events_() { if (this->state_ == USB_CLIENT_OPEN) { had_work = true; this->handle_open_state_(); + } else if (this->string_done_.load(std::memory_order_acquire)) { + // Acquire: pairs with the release in string_transfer_callback() + had_work = true; + this->string_done_.store(0); + // The device may have gone away while the read was in flight; then nobody is waiting + if (this->state_ == USB_CLIENT_GET_DESC) { + const usb_str_desc_t *desc = this->string_result_(); + if (desc != nullptr && descriptor_string_equals(desc, this->interface_filter_)) + this->interface_matched_ = true; + this->handle_get_desc_state_(); + } else if (this->state_ == USB_CLIENT_CONNECTED) { + this->on_interface_string(this->string_interface_, this->string_result_()); + } } return had_work; @@ -397,7 +419,52 @@ void USBClient::handle_open_state_() { this->disconnect(); return; } + if (this->interface_filter_ == nullptr) { + this->finish_open_(); + return; + } + // The interface strings are not cached by the host stack, so the filter reads them itself + this->state_ = USB_CLIENT_GET_DESC; + this->interface_matched_ = false; + this->filter_interface_ = 0; + this->handle_get_desc_state_(); +} + +// The filter pass: reads the iInterface string of each interface in turn until one matches the +// filter, then finishes opening the device or closes it. Runs on the main +// loop, from handle_open_state_() first and then after each string. +void USBClient::handle_get_desc_state_() { + const usb_config_desc_t *config_desc; + esp_err_t err = usb_host_get_active_config_descriptor(this->device_handle_, &config_desc); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Config descriptor query failed: %s", esp_err_to_name(err)); + this->disconnect(); + return; + } + while (!this->interface_matched_ && this->filter_interface_ < config_desc->bNumInterfaces) { + if (this->read_interface_string_(this->filter_interface_++)) + return; + } + if (!this->interface_matched_) { + ESP_LOGD(TAG, "Device does not match interface filter, closing"); + this->disconnect(); + return; + } + this->finish_open_(); +} + +// The device passed every filter: hand it to the subclass +void USBClient::finish_open_() { + usb_device_info_t dev_info; + esp_err_t err = usb_host_device_info(this->device_handle_, &dev_info); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Device info failed: %s", esp_err_to_name(err)); + this->disconnect(); + return; + } this->state_ = USB_CLIENT_CONNECTED; + char buf_manuf[DESC_STRING_BUF_SIZE]; + char buf_product[DESC_STRING_BUF_SIZE]; char buf_serial[DESC_STRING_BUF_SIZE]; ESP_LOGD(TAG, "Device connected: Manuf: %s; Prod: %s; Serial: %s", get_descriptor_string(dev_info.str_desc_manufacturer, buf_manuf), @@ -421,6 +488,61 @@ void USBClient::handle_open_state_() { } } +bool USBClient::read_interface_string_(uint8_t interface_number) { + const usb_config_desc_t *config_desc; + const usb_device_desc_t *device_desc; + if (usb_host_get_active_config_descriptor(this->device_handle_, &config_desc) != ESP_OK || + usb_host_get_device_descriptor(this->device_handle_, &device_desc) != ESP_OK) { + ESP_LOGW(TAG, "Descriptor query failed"); + return false; + } + int offset = 0; + const usb_intf_desc_t *intf = usb_parse_interface_descriptor(config_desc, interface_number, 0, &offset); + if (intf == nullptr || intf->iInterface == 0) + return false; + usb_transfer_t *xfer = this->string_transfer_; + USB_SETUP_PACKET_INIT_GET_STR_DESC(reinterpret_cast(xfer->data_buffer), intf->iInterface, + STRING_DESC_LANGID, STRING_DESC_MAX_SIZE); + xfer->device_handle = this->device_handle_; + xfer->bEndpointAddress = USB_DIR_IN; + // The data stage is read in whole packets, as the host stack's own descriptor reads are + xfer->num_bytes = SETUP_PACKET_SIZE + usb_round_up_to_mps(STRING_DESC_MAX_SIZE, device_desc->bMaxPacketSize0); + xfer->callback = string_transfer_callback; + esp_err_t err = usb_host_transfer_submit_control(this->handle_, xfer); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Interface %u string read failed: %s", interface_number, esp_err_to_name(err)); + return false; + } + this->string_interface_ = interface_number; + return true; +} + +// The reply to the last read_interface_string_(), when it is a well-formed string descriptor +const usb_str_desc_t *USBClient::string_result_() const { + const usb_transfer_t *xfer = this->string_transfer_; + // actual_num_bytes counts the setup packet too + const size_t received = xfer->actual_num_bytes > SETUP_PACKET_SIZE ? xfer->actual_num_bytes - SETUP_PACKET_SIZE : 0; + const auto *desc = reinterpret_cast(xfer->data_buffer + SETUP_PACKET_SIZE); + if (xfer->status != USB_TRANSFER_STATUS_COMPLETED || received < USB_STR_DESC_SIZE || + desc->bDescriptorType != USB_B_DESCRIPTOR_TYPE_STRING || desc->bLength > received) { + ESP_LOGW(TAG, "Interface %u string read failed: status %d, %zu bytes", this->string_interface_, xfer->status, + received); + return nullptr; + } + char buf[DESC_STRING_BUF_SIZE]; + ESP_LOGD(TAG, "Interface %u: %s", this->string_interface_, get_descriptor_string(desc, buf)); + return desc; +} + +// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) +void USBClient::string_transfer_callback(usb_transfer_t *xfer) { + auto *client = static_cast(xfer->context); + // Release: publishes the transfer result before the loop observes string_done_ + client->string_done_.store(1, std::memory_order_release); + client->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); +} + void USBClient::report_connected_() { if (this->state_ != USB_CLIENT_CONNECTED || this->connection_reported_) { return; @@ -509,6 +631,7 @@ void USBClient::disconnect() { this->state_ = USB_CLIENT_INIT; this->device_handle_ = nullptr; this->device_addr_ = -1; + this->string_done_.store(0); if (was_reported) { this->connection_callback_.call(false); } @@ -662,6 +785,9 @@ void USBClient::dump_config() { if (this->product_filter_ != nullptr) { ESP_LOGCONFIG(TAG, " Product %s", filter_string(this->product_filter_, buf)); } + if (this->interface_filter_ != nullptr) { + ESP_LOGCONFIG(TAG, " Interface %s", filter_string(this->interface_filter_, buf)); + } } // THREAD CONTEXT: Called from both USB task and main loop threads // - USB task: Immediately after transfer callback completes diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index ebba760434c..9a5e2f31a26 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -392,7 +392,6 @@ void USBUartTypeCdcAcm::on_connected() { channel->cdc_dev_ = cdc_devs[i++]; fix_mps(channel->cdc_dev_.in_ep); fix_mps(channel->cdc_dev_.out_ep); - channel->initialised_.store(true); // Claim the communication (interrupt) interface so CDC class requests are accepted // by the device. Some CDC ACM implementations (e.g. EFR32 NCP) require this before // they enable data flow on the bulk endpoints. @@ -424,11 +423,13 @@ void USBUartTypeCdcAcm::on_connected() { } } this->status_clear_error(); - this->enable_channels(); + this->read_interface_descriptions_(); } void USBUartTypeCdcAcm::on_disconnected() { + this->string_channel_ = 0; for (auto *channel : this->channels_) { + channel->interface_description_[0] = '\0'; // Not set up for this device: it was rejected before on_connected() ran, or it has // fewer ports than there are channels. Nothing was claimed for it. if (channel->cdc_dev_.in_ep == nullptr) @@ -520,6 +521,31 @@ void USBUartTypeCdcAcm::modem_control_transfer(USBUartChannelBase *channel) { channel->cdc_dev_.interrupt_interface_number); } +void USBUartComponent::read_interface_descriptions_() { + while (this->string_channel_ < this->channels_.size()) { + auto *channel = this->channels_[this->string_channel_]; + if (channel->cdc_dev_.in_ep != nullptr && this->read_interface_string_(channel->get_interface_number())) + return; + this->string_channel_++; + } + this->string_channel_ = 0; + // Mark the channels initialised after the string reads, not in on_connected(): a settings or + // modem transfer started during the reads would be cut off by the init config + for (auto *channel : this->channels_) { + if (channel->cdc_dev_.in_ep != nullptr) + channel->initialised_.store(true); + } + this->enable_channels(); +} + +void USBUartComponent::on_interface_string(uint8_t interface_number, const usb_str_desc_t *desc) { + auto *channel = this->channels_[this->string_channel_++]; + if (!usb_host::copy_descriptor_string(desc, channel->interface_description_)) { + ESP_LOGW(TAG, "Interface %u string descriptor is not ASCII", interface_number); + } + this->read_interface_descriptions_(); +} + void USBUartComponent::enable_channels() { this->cfg_single_ = nullptr; // Init sends every channel's current line settings and DTR/RTS diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 443dfc388fc..f2654e491e8 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -186,6 +186,10 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedinterface_description_; } + /// Store DTR/RTS and send them to the device. False when not connected. bool set_modem_control(bool dtr, bool rts); bool get_dtr() const { return this->dtr_; } @@ -198,6 +202,7 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented input_buffer_; LockFreeQueue output_queue_; @@ -296,6 +301,11 @@ class USBUartComponent : public usb_host::USBClient { // settings, so the connected report waits for run_config_machine_() to finish the init bool reports_connection_itself() const override { return true; } + // Copy each channel's interface string, one read at a time, then enable the channels. + // Called at the end of on_connected(); continues in on_interface_string(). + void read_interface_descriptions_(); + void on_interface_string(uint8_t interface_number, const usb_str_desc_t *desc) override; + std::vector channels_{}; // Config state machine @@ -307,6 +317,7 @@ class USBUartComponent : public usb_host::USBClient { uint8_t cfg_response_[8]{}; // last IN transfer payload uint8_t cfg_channel_idx_{0}; uint8_t cfg_step_{0}; + uint8_t string_channel_{0}; // the channel whose interface string is being read ConfigMode cfg_mode_{ConfigMode::CONFIG_MODE_INIT}; bool cfg_active_{false}; bool cfg_device_phase_{false}; diff --git a/tests/components/serial_proxy/test_usb_device_descriptor.cpp b/tests/components/serial_proxy/test_usb_device_descriptor.cpp index c76757bbee6..7559eb886e5 100644 --- a/tests/components/serial_proxy/test_usb_device_descriptor.cpp +++ b/tests/components/serial_proxy/test_usb_device_descriptor.cpp @@ -37,6 +37,18 @@ TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) { EXPECT_EQ(encode(msg), expected); } +TEST(UsbDeviceDescriptor, EncodesTheInterfaceDescription) { + UsbDeviceDescriptor msg; + msg.vendor_id = 0x303A; + msg.product_id = 0x1001; + msg.interface_number = 1; + msg.interface_description = StringRef("Zigbee"); + + const std::vector expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01, + 0x3A, 0x06, 'Z', 'i', 'g', 'b', 'e', 'e'}; + 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; diff --git a/tests/components/usb_host/test.esp32-p4-idf.yaml b/tests/components/usb_host/test.esp32-p4-idf.yaml index a71140feca3..c0acbe288f3 100644 --- a/tests/components/usb_host/test.esp32-p4-idf.yaml +++ b/tests/components/usb_host/test.esp32-p4-idf.yaml @@ -9,3 +9,4 @@ usb_host: pid: 0xABCD manufacturer: Example Corp product: Example Widget + interface_description: Example Port diff --git a/tests/components/usb_host/test.esp32-s3-idf.yaml b/tests/components/usb_host/test.esp32-s3-idf.yaml index a71140feca3..c0acbe288f3 100644 --- a/tests/components/usb_host/test.esp32-s3-idf.yaml +++ b/tests/components/usb_host/test.esp32-s3-idf.yaml @@ -9,3 +9,4 @@ usb_host: pid: 0xABCD manufacturer: Example Corp product: Example Widget + interface_description: Example Port diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml index 02500035cfd..ac4dd37719a 100644 --- a/tests/components/usb_uart/common.yaml +++ b/tests/components/usb_uart/common.yaml @@ -75,6 +75,7 @@ usb_uart: pid: 0x4001 manufacturer: Nabu Casa product: ZBT-2 + interface_description: Zigbee channels: - id: channel_9_1 - id: uart_10 diff --git a/tests/unit_tests/components/test_usb_host.py b/tests/unit_tests/components/test_usb_host.py index 8d572dba968..c10e7f4efcf 100644 --- a/tests/unit_tests/components/test_usb_host.py +++ b/tests/unit_tests/components/test_usb_host.py @@ -45,6 +45,21 @@ from esphome.types import ConfigType }, id="different_manufacturer", ), + pytest.param( + { + "id": "a", + "vid": 0x303A, + "pid": 0x4001, + "interface_description": "Zigbee", + }, + { + "id": "b", + "vid": 0x303A, + "pid": 0x4001, + "interface_description": "Thread", + }, + id="different_interface_description", + ), pytest.param( {"id": "a", "vid": 0x303A, "pid": 0, "product": "ZBT-2"}, {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZWA-2"}, @@ -80,6 +95,16 @@ def test_disjoint_clients_are_accepted(first: ConfigType, second: ConfigType) -> {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, id="identical_filters", ), + pytest.param( + {"id": "a", "vid": 0x303A, "pid": 0x4001}, + { + "id": "b", + "vid": 0x303A, + "pid": 0x4001, + "interface_description": "Zigbee", + }, + id="unfiltered_shadows_interface_filtered", + ), pytest.param( {"id": "a", "vid": 0, "pid": 0}, {"id": "b", "vid": 0x303A, "pid": 0x4001, "product": "ZBT-2"}, From a37e3bba26b2fd8898058e2543ad1905dee74ff0 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 4 Nov 2026 14:31:55 -0600 Subject: [PATCH 30/55] [nfc] Fix out-of-bounds read in NDEF record parsing (#22276) Co-authored-by: Claude Fable 5.1 --- esphome/components/nfc/ndef_message.cpp | 11 ++++- tests/components/nfc/test_ndef_message.cpp | 50 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/components/nfc/test_ndef_message.cpp diff --git a/esphome/components/nfc/ndef_message.cpp b/esphome/components/nfc/ndef_message.cpp index 552dfe99d30..b4d98862562 100644 --- a/esphome/components/nfc/ndef_message.cpp +++ b/esphome/components/nfc/ndef_message.cpp @@ -48,17 +48,24 @@ NdefMessage::NdefMessage(const std::span data) { ESP_LOGVV(TAG, "Lengths: type=%d, payload=%" PRIu32 ", id=%d", type_length, payload_length, id_length); + if (index + type_length > data.size()) { + ESP_LOGE(TAG, "Truncated type; aborting"); + break; + } std::string type_str(data.begin() + index, data.begin() + index + type_length); - index += type_length; std::string id_str; if (il) { + if (index + id_length > data.size()) { + ESP_LOGE(TAG, "Truncated ID; aborting"); + break; + } id_str = std::string(data.begin() + index, data.begin() + index + id_length); index += id_length; } - if ((data.begin() + index > data.end()) || (data.begin() + index + payload_length > data.end())) { + if (payload_length > data.size() - index) { ESP_LOGE(TAG, "Corrupt record encountered; NdefMessage constructor aborting"); break; } diff --git a/tests/components/nfc/test_ndef_message.cpp b/tests/components/nfc/test_ndef_message.cpp new file mode 100644 index 00000000000..f18cc16a2fb --- /dev/null +++ b/tests/components/nfc/test_ndef_message.cpp @@ -0,0 +1,50 @@ +#include + +#include +#include + +#include "esphome/components/nfc/ndef_message.h" + +namespace esphome::nfc::testing { + +static size_t record_count(const std::vector &data) { return NdefMessage(data).get_records().size(); } + +// Header: MB|ME|SR|TNF=well-known, type length 1, payload length 4, type "T", payload (status, "en", "x") +TEST(NdefMessage, ParsesShortTextRecord) { + NdefMessage message(std::vector{0xD1, 0x01, 0x04, 'T', 0x02, 'e', 'n', 'x'}); + ASSERT_EQ(message.get_records().size(), 1u); + EXPECT_EQ(message.get_records()[0]->get_type(), "T"); + EXPECT_EQ(message.get_records()[0]->get_payload(), "x"); +} + +TEST(NdefMessage, ParsesRecordWithId) { + NdefMessage message(std::vector{0xD9, 0x01, 0x04, 0x02, 'T', 'i', 'd', 0x02, 'e', 'n', 'x'}); + ASSERT_EQ(message.get_records().size(), 1u); + EXPECT_EQ(message.get_records()[0]->get_type(), "T"); + EXPECT_EQ(message.get_records()[0]->get_id(), "id"); + EXPECT_EQ(message.get_records()[0]->get_payload(), "x"); +} + +TEST(NdefMessage, RejectsTypeLengthPastEnd) { + EXPECT_EQ(record_count({0xD1, 0xFF, 0x00, 'T'}), 0u); + EXPECT_EQ(record_count({0xD1, 0x02, 0x00, 'T'}), 0u); +} + +TEST(NdefMessage, RejectsIdLengthPastEnd) { + EXPECT_EQ(record_count({0xD9, 0x01, 0x00, 0xFF, 'T'}), 0u); + EXPECT_EQ(record_count({0xD9, 0x01, 0x00, 0x02, 'T', 'i'}), 0u); +} + +TEST(NdefMessage, RejectsPayloadLengthPastEnd) { EXPECT_EQ(record_count({0xD1, 0x01, 0x05, 'T', 'x'}), 0u); } + +// Long record with a 4-byte payload length that would wrap a 32-bit pointer +TEST(NdefMessage, RejectsHugePayloadLength) { + EXPECT_EQ(record_count({0xC1, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 'T', 'x'}), 0u); +} + +TEST(NdefMessage, RejectsTruncatedHeader) { + EXPECT_EQ(record_count({0xD1}), 0u); + EXPECT_EQ(record_count({0xD1, 0x01}), 0u); +} + +} // namespace esphome::nfc::testing From a6b2d2003889d6a6d7306ea979eefaecc6e2a608 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:15:04 -0500 Subject: [PATCH 31/55] [docker] Bump base image to 2026.11.0 (#22275) --- .devcontainer/Dockerfile | 2 +- docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8527cf491b0..930aa622030 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG BUILD_BASE_VERSION=2026.06.1 +ARG BUILD_BASE_VERSION=2026.11.0 FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base diff --git a/docker/Dockerfile b/docker/Dockerfile index a6211cd000b..a01c6d70204 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2026.06.1 +ARG BUILD_BASE_VERSION=2026.11.0 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker From 95278ea4d86a33bc8dcb9cc2b4bfccafcd704cff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 16:23:26 -0600 Subject: [PATCH 32/55] [pipsolar] Fix flaky switch integration test (#22282) --- .../fixtures/uart_mock_pipsolar_switches.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/integration/fixtures/uart_mock_pipsolar_switches.yaml b/tests/integration/fixtures/uart_mock_pipsolar_switches.yaml index a43e5b5ae34..7c66e058436 100644 --- a/tests/integration/fixtures/uart_mock_pipsolar_switches.yaml +++ b/tests/integration/fixtures/uart_mock_pipsolar_switches.yaml @@ -22,9 +22,14 @@ uart_mock: id: mock_uart baud_rate: 2400 auto_start: false + # The switches enable the QPIRI poll, sent once at boot. A bare CR fails it at + # once, so queued commands don't wait out the 5 s poll timeout + responses: + - expect_tx: [0x51, 0x50, 0x49, 0x52, 0x49] + inject_rx: [0x0D] -# No polling, so only the switch writes reach the command queue and no -# polled status publishes the switch states +# No polling after boot, so only the switch writes reach the command queue and +# no polled status publishes the switch states pipsolar: id: inverter0 uart_id: mock_uart From 3f330fcdb23966304fb7d11aa00b7a69bc96bc50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 16:24:38 -0600 Subject: [PATCH 33/55] [mqtt] Cap incoming message size with max_payload_size (#22278) --- esphome/components/mqtt/__init__.py | 10 +++++ .../components/mqtt/mqtt_backend_esp32.cpp | 6 +++ esphome/components/mqtt/mqtt_client.cpp | 12 +++++- esphome/core/defines.h | 1 + .../mqtt/config/max_payload_size.yaml | 14 +++++++ .../mqtt/test_max_payload_size.py | 37 +++++++++++++++++++ tests/components/mqtt/common.yaml | 1 + 7 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/mqtt/config/max_payload_size.yaml create mode 100644 tests/component_tests/mqtt/test_max_payload_size.py diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index fb33c9d48d3..418190ea1e5 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -110,6 +110,7 @@ CONF_WAIT_FOR_CONNECTION = "wait_for_connection" CONF_PERSIST_SUBSCRIPTIONS = "persist_subscriptions" CONF_TRANSPORT = "transport" CONF_WS_PATH = "ws_path" +CONF_MAX_PAYLOAD_SIZE = "max_payload_size" MQTT_TRANSPORT_TCP = "tcp" MQTT_TRANSPORT_WS = "ws" @@ -405,6 +406,14 @@ CONFIG_SCHEMA = cv.All( CONF_TRANSPORT, default=MQTT_TRANSPORT_TCP ): _validate_transport, cv.Optional(CONF_WS_PATH): cv.string, + cv.SplitDefault( + CONF_MAX_PAYLOAD_SIZE, + esp8266=8192, + esp32=32768, + bk72xx=16384, + rtl87xx=16384, + ln882x=16384, + ): cv.All(cv.validate_bytes, cv.int_range(min=512, max=1048576)), cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All( cv.boolean, cv.only_on_esp32 ), @@ -664,6 +673,7 @@ async def to_code(config): request_tls() cg.add_define("USE_MQTT") + cg.add_define("MQTT_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE]) cg.add_global(mqtt_ns.using) cg.add(var.set_broker_address(config[CONF_BROKER])) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 14b26468929..283fe910235 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -235,6 +235,12 @@ void MQTTBackendESP32::mqtt_event_handler(void *handler_args, esp_event_base_t b instance->subacks_.add(mqtt_event->data_len > 0 ? static_cast(mqtt_event->data[0]) : SUBACK_FAILURE); } #endif + if (event_id == MQTT_EVENT_DATA) { + const auto *mqtt_event = static_cast(event_data); + // Only the first chunk of an oversized message is queued, so the client logs the drop + if (mqtt_event->total_data_len > MQTT_MAX_PAYLOAD_SIZE && mqtt_event->current_data_offset != 0) + return; + } // queue event to decouple processing from ESP-IDF MQTT task to main loop auto *event = instance->mqtt_event_pool_.allocate(); if (event == nullptr) { diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 5fcc3b3fb9c..10a918481bb 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -103,6 +103,13 @@ static void format_esphome_topic(char (&topic)[ESPHOME_TOPIC_BUFFER_SIZE], const void MQTTClientComponent::setup() { this->mqtt_backend_.set_on_message( [this](const char *topic, const char *payload, size_t len, size_t index, size_t total) { + // total comes from the broker; reserving it unchecked can exhaust the heap + if (total > MQTT_MAX_PAYLOAD_SIZE) { + if (index == 0) { + ESP_LOGW(TAG, "Dropped %zu byte message on '%s' (max %d)", total, topic, MQTT_MAX_PAYLOAD_SIZE); + } + return; + } if (index == 0) { this->payload_buffer_.clear(); this->payload_buffer_.reserve(total); @@ -299,7 +306,10 @@ void MQTTClientComponent::dump_config() { " Discovery retain: %s", this->discovery_info_.prefix.c_str(), YESNO(this->discovery_info_.retain)); } - ESP_LOGCONFIG(TAG, " Topic Prefix: '%s'", this->topic_prefix_.c_str()); + ESP_LOGCONFIG(TAG, + " Topic Prefix: '%s'\n" + " Max Payload Size: %d", + this->topic_prefix_.c_str(), MQTT_MAX_PAYLOAD_SIZE); if (!this->log_message_.topic.empty()) { ESP_LOGCONFIG(TAG, " Log Topic: '%s'", this->log_message_.topic.c_str()); } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f531cfe5946..8c8814a1e20 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -411,6 +411,7 @@ #define USE_SHA256 #if !defined(USE_RP2) && !defined(USE_HOST) // no MQTT backend or esp_wireguard library on RP2 and host #define USE_MQTT +#define MQTT_MAX_PAYLOAD_SIZE 32768 #define USE_MQTT_ABBREVIATIONS #define USE_MQTT_SESSION_PERSISTENCE #define USE_MQTT_COVER_JSON diff --git a/tests/component_tests/mqtt/config/max_payload_size.yaml b/tests/component_tests/mqtt/config/max_payload_size.yaml new file mode 100644 index 00000000000..9e4241cae6b --- /dev/null +++ b/tests/component_tests/mqtt/config/max_payload_size.yaml @@ -0,0 +1,14 @@ +--- +esphome: + name: test-device + +esp8266: + board: d1_mini_lite + +wifi: + ssid: MySSID + password: password1 + +mqtt: + broker: 192.168.1.2 + max_payload_size: 4kB diff --git a/tests/component_tests/mqtt/test_max_payload_size.py b/tests/component_tests/mqtt/test_max_payload_size.py new file mode 100644 index 00000000000..804b9950743 --- /dev/null +++ b/tests/component_tests/mqtt/test_max_payload_size.py @@ -0,0 +1,37 @@ +"""Tests for the mqtt max_payload_size option.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.mqtt import CONF_MAX_PAYLOAD_SIZE, CONFIG_SCHEMA +from esphome.const import CONF_BROKER, PlatformFramework +from esphome.core import CORE + +from ..types import SetCoreConfigCallable + + +@pytest.mark.parametrize( + ("config_file", "expected"), + [("no_mac_suffix.yaml", 8192), ("max_payload_size.yaml", 4000)], +) +def test_define( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + expected: int, +) -> None: + generate_main(component_config_path(config_file)) + defines = {d.name: str(d.value) for d in CORE.defines} + assert defines["MQTT_MAX_PAYLOAD_SIZE"] == str(expected) + + +@pytest.mark.parametrize("value", [0, 511, 1048577]) +def test_out_of_range_rejected( + set_core_config: SetCoreConfigCallable, value: int +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match=CONF_MAX_PAYLOAD_SIZE): + CONFIG_SCHEMA({CONF_BROKER: "192.168.1.2", CONF_MAX_PAYLOAD_SIZE: value}) diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index aae80b33573..2cc8c89d942 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -31,6 +31,7 @@ mqtt: payload: hi qos: 2 retain: true + max_payload_size: 4096 keepalive: 60s reboot_timeout: 60s on_message: From ca8be3e9531ef1b1d0e1feac6550e8398dbb9192 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 16:27:31 -0600 Subject: [PATCH 34/55] [bme68x_bsec2] Publish pending readings from fixed storage instead of a heap queue (#22191) --- .../components/bme68x_bsec2/bme68x_bsec2.cpp | 87 ++++++++++++++----- .../components/bme68x_bsec2/bme68x_bsec2.h | 28 +++++- .../components/bme68x_bsec2/test_publish.cpp | 65 ++++++++++++-- 3 files changed, 151 insertions(+), 29 deletions(-) diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index 1be5c3835e4..c973ae3bf43 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -130,14 +130,62 @@ void BME68xBSEC2Component::check_status_() { } 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 { + // Publish a single waiting state. All of them in one call take too long. + if (this->pending_.empty()) { this->disable_loop(); + return; + } + const PendingPublish pending = this->pending_.front(); + this->pending_.pop(); + const float value = pending.value; + switch (pending.slot) { +#ifdef USE_SENSOR + case PUBLISH_SLOT_IAQ: + this->publish_sensor_(this->iaq_sensor_, value); + break; + case PUBLISH_SLOT_IAQ_STATIC: + this->publish_sensor_(this->iaq_static_sensor_, value); + break; + case PUBLISH_SLOT_CO2_EQUIVALENT: + this->publish_sensor_(this->co2_equivalent_sensor_, value); + break; + case PUBLISH_SLOT_BREATH_VOC_EQUIVALENT: + this->publish_sensor_(this->breath_voc_equivalent_sensor_, value); + break; + case PUBLISH_SLOT_PRESSURE: + this->publish_sensor_(this->pressure_sensor_, value); + break; + case PUBLISH_SLOT_GAS_RESISTANCE: + this->publish_sensor_(this->gas_resistance_sensor_, value); + break; + case PUBLISH_SLOT_TEMPERATURE: + this->publish_sensor_(this->temperature_sensor_, value); + break; + case PUBLISH_SLOT_HUMIDITY: + this->publish_sensor_(this->humidity_sensor_, value); + break; + case PUBLISH_SLOT_IAQ_ACCURACY: + this->publish_sensor_(this->iaq_accuracy_sensor_, value, true); + break; +#endif +#ifdef USE_TEXT_SENSOR + case PUBLISH_SLOT_IAQ_ACCURACY_TEXT: + this->publish_sensor_(this->iaq_accuracy_text_sensor_, IAQ_ACCURACY_STATES[static_cast(value)]); + break; +#endif + case PUBLISH_SLOT_SAVE_STATE: + this->save_state_(static_cast(value)); + break; + default: + break; + } +} + +void BME68xBSEC2Component::queue_publish_(PublishSlot slot, float value) { + // Only full when the loop stalled for seconds; drop the oldest so the newest reading still goes out + if (!this->pending_.push({value, slot})) { + this->pending_.pop(); + this->pending_.push({value, slot}); } } @@ -414,44 +462,44 @@ void BME68xBSEC2Component::publish_(const bsec_output_t *outputs, uint8_t num_ou max_accuracy = std::max(outputs[i].accuracy, max_accuracy); update_accuracy = true; #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->iaq_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_IAQ, signal); #endif break; case BSEC_OUTPUT_STATIC_IAQ: max_accuracy = std::max(outputs[i].accuracy, max_accuracy); update_accuracy = true; #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->iaq_static_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_IAQ_STATIC, signal); #endif break; case BSEC_OUTPUT_CO2_EQUIVALENT: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->co2_equivalent_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_CO2_EQUIVALENT, signal); #endif break; case BSEC_OUTPUT_BREATH_VOC_EQUIVALENT: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->breath_voc_equivalent_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_BREATH_VOC_EQUIVALENT, signal); #endif break; case BSEC_OUTPUT_RAW_PRESSURE: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->pressure_sensor_, signal / 100.0f); }); + this->queue_publish_(PUBLISH_SLOT_PRESSURE, signal / 100.0f); #endif break; case BSEC_OUTPUT_RAW_GAS: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->gas_resistance_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_GAS_RESISTANCE, signal); #endif break; case BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->temperature_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_TEMPERATURE, signal); #endif break; case BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY: #ifdef USE_SENSOR - this->queue_push_([this, signal]() { this->publish_sensor_(this->humidity_sensor_, signal); }); + this->queue_publish_(PUBLISH_SLOT_HUMIDITY, signal); #endif break; } @@ -459,16 +507,13 @@ void BME68xBSEC2Component::publish_(const bsec_output_t *outputs, uint8_t num_ou if (update_accuracy) { max_accuracy = std::min(max_accuracy, std::size(IAQ_ACCURACY_STATES) - 1); #ifdef USE_SENSOR - this->queue_push_( - [this, max_accuracy]() { this->publish_sensor_(this->iaq_accuracy_sensor_, max_accuracy, true); }); + this->queue_publish_(PUBLISH_SLOT_IAQ_ACCURACY, max_accuracy); #endif #ifdef USE_TEXT_SENSOR - this->queue_push_([this, max_accuracy]() { - this->publish_sensor_(this->iaq_accuracy_text_sensor_, IAQ_ACCURACY_STATES[max_accuracy]); - }); + this->queue_publish_(PUBLISH_SLOT_IAQ_ACCURACY_TEXT, max_accuracy); #endif // Queue up an opportunity to save state - this->queue_push_([this, max_accuracy]() { this->save_state_(max_accuracy); }); + this->queue_publish_(PUBLISH_SLOT_SAVE_STATE, max_accuracy); } this->enable_loop(); } diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.h b/esphome/components/bme68x_bsec2/bme68x_bsec2.h index deb83e81b26..7893ae381d3 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.h +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #ifdef USE_BSEC2 @@ -15,7 +16,6 @@ #endif #include -#include #include @@ -83,6 +83,28 @@ class BME68xBSEC2Component : public Component { virtual uint32_t get_hash() = 0; protected: + // States waiting for loop() to publish them, one per pass + enum PublishSlot : uint8_t { + PUBLISH_SLOT_IAQ, + PUBLISH_SLOT_IAQ_STATIC, + PUBLISH_SLOT_CO2_EQUIVALENT, + PUBLISH_SLOT_BREATH_VOC_EQUIVALENT, + PUBLISH_SLOT_PRESSURE, + PUBLISH_SLOT_GAS_RESISTANCE, + PUBLISH_SLOT_TEMPERATURE, + PUBLISH_SLOT_HUMIDITY, + PUBLISH_SLOT_IAQ_ACCURACY, + PUBLISH_SLOT_IAQ_ACCURACY_TEXT, + PUBLISH_SLOT_SAVE_STATE, + PUBLISH_SLOT_COUNT, + }; + struct __attribute__((packed)) PendingPublish { + float value; // the accuracy for the accuracy and save slots + PublishSlot slot; + }; + // A read returns up to 3 fields in parallel mode, each published in full + static constexpr uint8_t PUBLISH_QUEUE_SIZE = 3 * PUBLISH_SLOT_COUNT; + void set_config_(const uint8_t *config, u_int32_t len); float calc_sensor_sample_rate_(SampleRate sample_rate); void update_subscription_(); @@ -103,14 +125,14 @@ class BME68xBSEC2Component : public Component { void load_state_(); void save_state_(uint8_t accuracy); - void queue_push_(std::function &&f) { this->queue_.push(std::move(f)); } + void queue_publish_(PublishSlot slot, float value); struct bme68x_dev bme68x_; bsec_bme_settings_t bsec_settings_; bsec_version_t version_; struct bme68x_heatr_conf bme68x_heatr_conf_; - std::queue> queue_; + StaticRingBuffer pending_; ESPPreferenceObject bsec_state_; uint8_t const *bsec2_configuration_{nullptr}; diff --git a/tests/components/bme68x_bsec2/test_publish.cpp b/tests/components/bme68x_bsec2/test_publish.cpp index 3669b2323bb..dfba5c30c18 100644 --- a/tests/components/bme68x_bsec2/test_publish.cpp +++ b/tests/components/bme68x_bsec2/test_publish.cpp @@ -16,10 +16,17 @@ class TestBSEC2 : public BME68xBSEC2Component { public: uint32_t get_hash() override { return 0; } using BME68xBSEC2Component::publish_; + using BME68xBSEC2Component::PUBLISH_QUEUE_SIZE; // setup() leaves the loop disabled until the first reading. void start_idle() { this->disable_loop(); } }; +std::string format_state(const char *name, float value) { + char buf[64]; + snprintf(buf, sizeof(buf), "%s=%g", name, value); + return buf; +} + bsec_output_t output(uint8_t sensor_id, float signal, uint8_t accuracy = 0) { bsec_output_t out{}; out.sensor_id = sensor_id; @@ -89,11 +96,7 @@ class BSEC2PublishTest : public ::testing::Test { private: void add_(sensor::Sensor &sensor, const char *name) { - sensor.add_on_state_callback([this, name](float value) { - char buf[64]; - snprintf(buf, sizeof(buf), "%s=%g", name, value); - this->events.push_back(buf); - }); + sensor.add_on_state_callback([this, name](float value) { this->events.push_back(format_state(name, value)); }); } }; @@ -220,6 +223,58 @@ TEST_F(BSEC2PublishTest, NewReadingBeforeTheLastWentOut) { EXPECT_EQ(this->drain(), (Passes{{"humidity=40"}, {"temperature=21"}, {"humidity=41"}, {}})); } +TEST_F(BSEC2PublishTest, ParallelModeFieldsAllGoOutInOrder) { + this->attach_all(); + static constexpr const char *STATES[] = {"Stabilizing", "Uncertain", "Calibrating"}; + // One read in parallel mode returns up to 3 fields, each published in full before loop() runs. + Passes expected; + for (int field = 0; field < 3; field++) { + const float f = field; + this->publish({ + output(BSEC_OUTPUT_IAQ, 50.0f + f, field), + output(BSEC_OUTPUT_STATIC_IAQ, 40.0f + f), + output(BSEC_OUTPUT_CO2_EQUIVALENT, 600.0f + f), + output(BSEC_OUTPUT_BREATH_VOC_EQUIVALENT, 1.0f + f), + output(BSEC_OUTPUT_RAW_PRESSURE, 100000.0f + 100.0f * f), + output(BSEC_OUTPUT_RAW_GAS, 120000.0f + f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 20.0f + f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 45.0f + f), + }); + for (const auto &[name, value] : std::initializer_list>{ + {"iaq", 50 + f}, + {"iaq_static", 40 + f}, + {"co2_equivalent", 600 + f}, + {"breath_voc_equivalent", 1 + f}, + {"pressure", 1000 + f}, + {"gas_resistance", 120000 + f}, + {"temperature", 20 + f}, + {"humidity", 45 + f}, + {"iaq_accuracy", f}, + }) { + expected.push_back({format_state(name, value)}); + } + expected.push_back({std::string("iaq_accuracy_text=") + STATES[field]}); + expected.push_back({}); // state save opportunity + } + expected.push_back({}); + // 3 full fields fill the queue exactly; nothing is dropped. + ASSERT_EQ(expected.size(), TestBSEC2::PUBLISH_QUEUE_SIZE + 1u); + EXPECT_EQ(this->drain(), expected); +} + +TEST_F(BSEC2PublishTest, FullQueueDropsTheOldestState) { + this->component.set_temperature_sensor(&this->temperature); + const int size = TestBSEC2::PUBLISH_QUEUE_SIZE; + // One state more than fits pushes out the first. + for (int i = 0; i <= size; i++) + this->publish({output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, static_cast(i))}); + auto passes = this->drain(); + ASSERT_EQ(passes.size(), static_cast(size) + 1); + EXPECT_EQ(passes.front(), (std::vector{"temperature=1"})); + EXPECT_EQ(passes[size - 1], (std::vector{format_state("temperature", size)})); + EXPECT_TRUE(passes.back().empty()); +} + } // namespace } // namespace esphome::bme68x_bsec2::testing #endif // USE_BSEC2 From 92723c905c72b39a60a56bfc3f110284f0a72e6c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 4 Nov 2026 16:47:29 -0600 Subject: [PATCH 35/55] [voice_assistant] Stream buffered wake word audio for verification (#21885) --- esphome/components/api/api.proto | 8 + esphome/components/api/api_connection.cpp | 2 +- esphome/components/api/api_pb2.cpp | 14 + esphome/components/api/api_pb2.h | 9 +- esphome/components/api/api_pb2_dump.cpp | 9 + .../micro_wake_word/micro_wake_word.cpp | 1 + .../micro_wake_word/micro_wake_word.h | 14 + .../micro_wake_word/streaming_model.cpp | 2 + .../micro_wake_word/streaming_model.h | 2 + .../components/voice_assistant/__init__.py | 39 +++ .../voice_assistant/voice_assistant.cpp | 258 +++++++++++++++++- .../voice_assistant/voice_assistant.h | 57 +++- esphome/core/defines.h | 1 + .../voice_assistant/common-idf.yaml | 5 + 14 files changed, 410 insertions(+), 11 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 6c75a569b81..ffe8897d353 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2199,6 +2199,8 @@ message BluetoothScannerSetModeRequest { enum VoiceAssistantSubscribeFlag { VOICE_ASSISTANT_SUBSCRIBE_NONE = 0; VOICE_ASSISTANT_SUBSCRIBE_API_AUDIO = 1; + // Not 2 or 4: aioesphomeapi sends API audio as 1 << 2 + VOICE_ASSISTANT_SUBSCRIBE_WAKE_WORD_AUDIO = 8; } message SubscribeVoiceAssistantRequest { @@ -2232,6 +2234,11 @@ message VoiceAssistantRequest { uint32 flags = 3; VoiceAssistantAudioSettings audio_settings = 4; string wake_word_phrase = 5; + string wake_word_id = 6; + uint32 wake_word_audio_bytes = 7; + float wake_word_probability = 8; + float wake_word_probability_cutoff = 9; + uint32 wake_word_audio_bytes2 = 10; } message VoiceAssistantResponse { @@ -2257,6 +2264,7 @@ enum VoiceAssistantEvent { VOICE_ASSISTANT_WAKE_WORD_END = 10; VOICE_ASSISTANT_STT_VAD_START = 11; VOICE_ASSISTANT_STT_VAD_END = 12; + VOICE_ASSISTANT_WAKE_WORD_REJECTED = 13; VOICE_ASSISTANT_TTS_STREAM_START = 98; VOICE_ASSISTANT_TTS_STREAM_END = 99; VOICE_ASSISTANT_INTENT_PROGRESS = 100; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 92333e4917b..baf6352c4db 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1353,7 +1353,7 @@ bool APIConnection::check_voice_assistant_api_connection_() const { void APIConnection::on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg) { if (voice_assistant::global_voice_assistant != nullptr) { - voice_assistant::global_voice_assistant->client_subscription(this, msg.subscribe); + voice_assistant::global_voice_assistant->client_subscription(this, msg.subscribe, msg.flags); } } void APIConnection::on_voice_assistant_response(const VoiceAssistantResponse &msg) { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 1c9bd13eaab..560da29e27e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -3047,6 +3047,15 @@ uint8_t *VoiceAssistantRequest::encode_msg(const void *self, uint8_t *__restrict pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.flags); pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.audio_settings); pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wake_word_phrase); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.wake_word_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.wake_word_audio_bytes); + if (uint32_t raw = float_to_raw(msg.wake_word_probability); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); + } + if (uint32_t raw = float_to_raw(msg.wake_word_probability_cutoff); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.wake_word_audio_bytes2); return pos; } uint32_t VoiceAssistantRequest::calc_size_msg(const void *self) { @@ -3057,6 +3066,11 @@ uint32_t VoiceAssistantRequest::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.flags); size += ProtoSize::calc_message(1, msg.audio_settings.calculate_size()); size += ProtoSize::calc_length(1, msg.wake_word_phrase.size()); + size += ProtoSize::calc_length(1, msg.wake_word_id.size()); + size += ProtoSize::calc_uint32(1, msg.wake_word_audio_bytes); + size += ProtoSize::calc_float(1, msg.wake_word_probability); + size += ProtoSize::calc_float(1, msg.wake_word_probability_cutoff); + size += ProtoSize::calc_uint32(1, msg.wake_word_audio_bytes2); return size; } void VoiceAssistantResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index ddf342dd62f..5c08455212d 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -262,6 +262,7 @@ enum BluetoothScannerMode : uint32_t { enum VoiceAssistantSubscribeFlag : uint32_t { VOICE_ASSISTANT_SUBSCRIBE_NONE = 0, VOICE_ASSISTANT_SUBSCRIBE_API_AUDIO = 1, + VOICE_ASSISTANT_SUBSCRIBE_WAKE_WORD_AUDIO = 8, }; enum VoiceAssistantRequestFlag : uint32_t { VOICE_ASSISTANT_REQUEST_NONE = 0, @@ -283,6 +284,7 @@ enum VoiceAssistantEvent : uint32_t { VOICE_ASSISTANT_WAKE_WORD_END = 10, VOICE_ASSISTANT_STT_VAD_START = 11, VOICE_ASSISTANT_STT_VAD_END = 12, + VOICE_ASSISTANT_WAKE_WORD_REJECTED = 13, VOICE_ASSISTANT_TTS_STREAM_START = 98, VOICE_ASSISTANT_TTS_STREAM_END = 99, VOICE_ASSISTANT_INTENT_PROGRESS = 100, @@ -2973,7 +2975,7 @@ class VoiceAssistantAudioSettings final : public ProtoMessage { class VoiceAssistantRequest final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 90; - static constexpr uint8_t ESTIMATED_SIZE = 41; + static constexpr uint8_t ESTIMATED_SIZE = 68; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_request"); } #endif @@ -2982,6 +2984,11 @@ class VoiceAssistantRequest final : public ProtoMessage { uint32_t flags{0}; VoiceAssistantAudioSettings audio_settings{}; StringRef wake_word_phrase{nullptr, 0}; // null until set, encode only + StringRef wake_word_id{nullptr, 0}; // null until set, encode only + uint32_t wake_word_audio_bytes{0}; + float wake_word_probability{0.0f}; + float wake_word_probability_cutoff{0.0f}; + uint32_t wake_word_audio_bytes2{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 bfd393c70d4..ab1af92fabc 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -675,6 +675,8 @@ const char *proto_enum_to_string(enums::Voic return ESPHOME_PSTR("VOICE_ASSISTANT_SUBSCRIBE_NONE"); case enums::VOICE_ASSISTANT_SUBSCRIBE_API_AUDIO: return ESPHOME_PSTR("VOICE_ASSISTANT_SUBSCRIBE_API_AUDIO"); + case enums::VOICE_ASSISTANT_SUBSCRIBE_WAKE_WORD_AUDIO: + return ESPHOME_PSTR("VOICE_ASSISTANT_SUBSCRIBE_WAKE_WORD_AUDIO"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -720,6 +722,8 @@ template<> const char *proto_enum_to_string(enums::V return ESPHOME_PSTR("VOICE_ASSISTANT_STT_VAD_START"); case enums::VOICE_ASSISTANT_STT_VAD_END: return ESPHOME_PSTR("VOICE_ASSISTANT_STT_VAD_END"); + case enums::VOICE_ASSISTANT_WAKE_WORD_REJECTED: + return ESPHOME_PSTR("VOICE_ASSISTANT_WAKE_WORD_REJECTED"); case enums::VOICE_ASSISTANT_TTS_STREAM_START: return ESPHOME_PSTR("VOICE_ASSISTANT_TTS_STREAM_START"); case enums::VOICE_ASSISTANT_TTS_STREAM_END: @@ -2403,6 +2407,11 @@ const char *VoiceAssistantRequest::dump_to(DumpBuffer &out) const { this->audio_settings.dump_to(out); out.append("\n"); dump_field(out, ESPHOME_PSTR("wake_word_phrase"), this->wake_word_phrase); + dump_field(out, ESPHOME_PSTR("wake_word_id"), this->wake_word_id); + dump_field(out, ESPHOME_PSTR("wake_word_audio_bytes"), this->wake_word_audio_bytes); + dump_field(out, ESPHOME_PSTR("wake_word_probability"), this->wake_word_probability); + dump_field(out, ESPHOME_PSTR("wake_word_probability_cutoff"), this->wake_word_probability_cutoff); + dump_field(out, ESPHOME_PSTR("wake_word_audio_bytes2"), this->wake_word_audio_bytes2); return out.c_str(); } const char *VoiceAssistantResponse::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 937e12e441c..4fe700760e2 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -503,6 +503,7 @@ void MicroWakeWord::loop() { ESP_LOGD(TAG, "Detected '%s' with sliding average probability is %.2f and max probability is %.2f", detection_event.wake_word->c_str(), (detection_event.average_probability / uint8_to_float_divisor), (detection_event.max_probability / uint8_to_float_divisor)); + this->wake_word_detected_callback_.call(detection_event); this->wake_word_detected_trigger_.trigger(*detection_event.wake_word); if (this->stop_after_detection_) { this->stop(); diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 5aa87cf1b73..bfafd386429 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -68,6 +68,11 @@ class MicroWakeWord final : public Component Trigger *get_wake_word_detected_trigger() { return &this->wake_word_detected_trigger_; } + /// The event's string pointers are only valid during the call. + template void add_on_wake_word_detected_callback(F &&callback) { + this->wake_word_detected_callback_.add(std::forward(callback)); + } + void add_wake_word_model(WakeWordModel *model); /// @brief Adds a runtime-downloaded wake word model. Must be called from the main loop. @@ -102,6 +107,14 @@ class MicroWakeWord final : public Component // Intended for the voice assistant component to fetch VAD status bool get_vad_state() { return this->vad_state_; } + + void set_vad_enabled(bool enabled) { + if (enabled) { + this->vad_model_->enable(); + } else { + this->vad_model_->disable(); + } + } #endif // Intended for the voice assistant component to access which wake words are available @@ -111,6 +124,7 @@ class MicroWakeWord final : public Component protected: microphone::MicrophoneSource *microphone_source_{nullptr}; Trigger wake_word_detected_trigger_; + LazyCallbackManager wake_word_detected_callback_; State state_{State::STOPPED}; std::weak_ptr ring_buffer_; diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 14e6046ba04..fbdf618ce0a 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -374,8 +374,10 @@ void WakeWordModel::disable() { DetectionEvent WakeWordModel::determine_detected() { DetectionEvent detection_event; detection_event.wake_word = &this->wake_word_; + detection_event.id = &this->id_; detection_event.max_probability = 0; detection_event.average_probability = 0; + detection_event.probability_cutoff = this->probability_cutoff_; if ((this->ignore_windows_ < 0) || !this->enabled_) { detection_event.detected = false; diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 730afe32bf7..2fe26a94a46 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -20,11 +20,13 @@ static const uint32_t STREAMING_MODEL_VARIABLE_ARENA_SIZE = 1024; struct DetectionEvent { std::string *wake_word; + const std::string *id{nullptr}; bool detected; bool partially_detection; // Set if the most recent probability exceed the threshold, but the sliding window average // hasn't yet uint8_t max_probability; uint8_t average_probability; + uint8_t probability_cutoff{0}; bool blocked_by_vad = false; }; diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 4ab389da784..94b9d712481 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv from esphome.const import ( + CONF_BUFFER_DURATION, CONF_ID, CONF_MEDIA_PLAYER, CONF_MICROPHONE, @@ -12,6 +13,7 @@ from esphome.const import ( CONF_ON_IDLE, CONF_ON_START, CONF_SPEAKER, + CONF_TIMEOUT, ) from esphome.types import ConfigType @@ -34,6 +36,7 @@ CONF_ON_TTS_START = "on_tts_start" CONF_ON_TTS_STREAM_START = "on_tts_stream_start" CONF_ON_TTS_STREAM_END = "on_tts_stream_end" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" +CONF_ON_WAKE_WORD_REJECTED = "on_wake_word_rejected" CONF_SILENCE_DETECTION = "silence_detection" CONF_USE_WAKE_WORD = "use_wake_word" @@ -45,6 +48,7 @@ CONF_VOLUME_MULTIPLIER = "volume_multiplier" CONF_MICRO_WAKE_WORD = "micro_wake_word" CONF_WAKE_WORD = "wake_word" +CONF_WAKE_WORD_AUDIO = "wake_word_audio" CONF_CONVERSATION_TIMEOUT = "conversation_timeout" CONF_TTS_PLAYBACK_START_TIMEOUT = "tts_playback_start_timeout" @@ -92,6 +96,21 @@ CONFIG_SCHEMA = cv.All( cv.Exclusive(CONF_SPEAKER, "output"): cv.use_id(speaker.Speaker), cv.Optional(CONF_USE_WAKE_WORD, default=False): cv.boolean, cv.Optional(CONF_MICRO_WAKE_WORD): cv.use_id(micro_wake_word.MicroWakeWord), + cv.Optional(CONF_WAKE_WORD_AUDIO): cv.Schema( + { + cv.Optional(CONF_BUFFER_DURATION, default="1500ms"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range( + min=core.TimePeriod(milliseconds=100), + max=core.TimePeriod(seconds=5), + ), + ), + cv.Optional(CONF_TIMEOUT, default="2s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=core.TimePeriod(milliseconds=1)), + ), + } + ), cv.Optional(CONF_VAD_THRESHOLD): cv.invalid( "VAD threshold is no longer supported, as it requires the deprecated esp_adf external component. Use an i2s_audio microphone/speaker instead. Additionally, you may need to configure the audio_adc and audio_dac components depending on your hardware." ), @@ -118,6 +137,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_WAKE_WORD_DETECTED): automation.validate_automation( single=True ), + cv.Optional(CONF_ON_WAKE_WORD_REJECTED): automation.validate_automation( + single=True + ), cv.Optional(CONF_ON_STT_END): automation.validate_automation(single=True), cv.Optional(CONF_ON_TTS_START): automation.validate_automation(single=True), cv.Optional(CONF_ON_TTS_END): automation.validate_automation(single=True), @@ -169,6 +191,8 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.key_requires((CONF_ON_TTS_STREAM_START, CONF_ON_TTS_STREAM_END), CONF_SPEAKER), + cv.key_requires(CONF_WAKE_WORD_AUDIO, CONF_MICRO_WAKE_WORD), + cv.key_requires(CONF_ON_WAKE_WORD_REJECTED, CONF_WAKE_WORD_AUDIO), ) FINAL_VALIDATE_SCHEMA = cv.All( @@ -203,6 +227,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_use_wake_word(config[CONF_USE_WAKE_WORD])) + if wake_word_audio := config.get(CONF_WAKE_WORD_AUDIO): + cg.add_define("USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO") + cg.add( + var.set_wake_word_audio( + wake_word_audio[CONF_BUFFER_DURATION], wake_word_audio[CONF_TIMEOUT] + ) + ) + cg.add_optional(var.set_vad_threshold, config.get(CONF_VAD_THRESHOLD)) cg.add(var.set_noise_suppression_level(config[CONF_NOISE_SUPPRESSION_LEVEL])) @@ -231,6 +263,13 @@ async def to_code(config: ConfigType) -> None: config[CONF_ON_WAKE_WORD_DETECTED], ) + if CONF_ON_WAKE_WORD_REJECTED in config: + await automation.build_automation( + var.get_wake_word_rejected_trigger(), + [], + config[CONF_ON_WAKE_WORD_REJECTED], + ) + if CONF_ON_STT_END in config: await automation.build_automation( var.get_stt_end_trigger(), [(cg.std_string, "x")], config[CONF_ON_STT_END] diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 7ea22d756d2..d82350efbb4 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -18,6 +18,7 @@ static constexpr uint32_t RESET_CONVERSATION_TIMEOUT_ID = 0; static constexpr uint32_t PLAYBACK_TIMEOUT_ID = 1; static constexpr uint32_t SPEAKER_IDLE_TIMEOUT_ID = 2; static constexpr uint32_t TIMER_TICK_INTERVAL_ID = 3; +static constexpr uint32_t WAKE_WORD_VERDICT_TIMEOUT_ID = 4; #ifdef SAMPLE_RATE_HZ #undef SAMPLE_RATE_HZ @@ -41,6 +42,31 @@ static const size_t SPEAKER_BUFFER_SIZE = 16 * RECEIVE_SIZE; // SEND_BUFFER_SAMPLES (32 ms), so this is far longer than any legitimate gap between chunks. static const uint32_t AUDIO_CHANNEL_STALL_TIMEOUT_MS = 2000; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO +// Room for live audio while the buffered audio is sent +static constexpr uint32_t WAKE_WORD_AUDIO_HEADROOM_MS = 1000; +static constexpr uint32_t WAKE_WORD_DETECTION_MAX_AGE_MS = 1000; + +static size_t trim_audio_source(audio::RingBufferAudioSource *source, + const std::weak_ptr &weak_ring_buffer, size_t target_bytes) { + std::shared_ptr ring_buffer = weak_ring_buffer.lock(); + if ((source == nullptr) || (ring_buffer == nullptr)) { + return 0; + } + while (true) { + source->fill(0); + const size_t exposed = source->available(); + const size_t buffered = exposed + ring_buffer->available(); + // Whole samples only + const size_t excess = (buffered > target_bytes) ? ((buffered - target_bytes) & ~(sizeof(int16_t) - 1)) : 0; + if ((excess == 0) || (exposed == 0)) { + return buffered; + } + source->consume(std::min(exposed, excess)); + } +} +#endif + VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; } void VoiceAssistant::setup() { @@ -61,6 +87,16 @@ void VoiceAssistant::setup() { }); } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->micro_wake_word_->add_on_wake_word_detected_callback([this](const micro_wake_word::DetectionEvent &event) { + constexpr float uint8_to_float_divisor = 255.0f; + this->detection_.id = (event.id != nullptr) ? *event.id : ""; + this->detection_.probability = event.average_probability / uint8_to_float_divisor; + this->detection_.probability_cutoff = event.probability_cutoff / uint8_to_float_divisor; + this->detection_.detected_at = millis(); + }); +#endif + #ifdef USE_MEDIA_PLAYER if (this->media_player_ != nullptr) { this->media_player_->add_on_state_callback([this](media_player::MediaPlayerState state) { @@ -145,7 +181,8 @@ bool VoiceAssistant::allocate_buffers_() { #endif if (this->audio_source_ == nullptr) { - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); + std::shared_ptr temp_ring_buffer = + ring_buffer::RingBuffer::create(this->ring_buffer_size_()); if (temp_ring_buffer == nullptr) { ESP_LOGE(TAG, "Could not allocate ring buffer"); return false; @@ -161,7 +198,8 @@ bool VoiceAssistant::allocate_buffers_() { // Second microphone channel if ((this->mic_source2_ != nullptr) && (this->audio_source2_ == nullptr)) { - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE); + std::shared_ptr temp_ring_buffer = + ring_buffer::RingBuffer::create(this->ring_buffer_size_()); if (temp_ring_buffer == nullptr) { ESP_LOGE(TAG, "Could not allocate second ring buffer"); return false; @@ -177,13 +215,23 @@ bool VoiceAssistant::allocate_buffers_() { return true; } -void VoiceAssistant::clear_buffers_() { - if (this->audio_source_ != nullptr) { +size_t VoiceAssistant::ring_buffer_size_() const { +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + if (this->is_wake_word_audio_active()) { + return (this->wake_word_audio_buffer_duration_ + WAKE_WORD_AUDIO_HEADROOM_MS) * SAMPLE_RATE_HZ / 1000 * + sizeof(int16_t); + } +#endif + return RING_BUFFER_SIZE; +} + +void VoiceAssistant::clear_buffers_(bool clear_microphone_audio) { + if (clear_microphone_audio && (this->audio_source_ != nullptr)) { this->audio_source_->clear_buffered_data(); } // Second microphone channel - if (this->audio_source2_ != nullptr) { + if (clear_microphone_audio && (this->audio_source2_ != nullptr)) { this->audio_source2_->clear_buffered_data(); } @@ -313,13 +361,43 @@ void VoiceAssistant::loop() { this->continuous_ = false; this->signal_stop_(); this->clear_buffers_(); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->clear_wake_word_verdict_(); +#endif return; } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->update_wake_word_audio_(); +#endif switch (this->state_) { case State::IDLE: { if (this->continuous_ && this->desired_state_ == State::IDLE) { this->idle_trigger_.trigger(); this->set_state_(State::START_MICROPHONE, State::START_PIPELINE); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + } else if (this->wake_word_audio_buffering_) { + // Only microWakeWord detections use the buffered audio, so the microphone idles while it is stopped + // (a software mute, say); the buffers stay allocated for when it runs again. + if (this->micro_wake_word_->is_running()) { + // Until the microphone runs again, drop what it buffered before it stopped, so a clip never joins audio + // from before a mute to audio after it + if (!this->mic_source_->is_running() && (this->audio_source_ != nullptr)) { + this->audio_source_->clear_buffered_data(); + if (this->audio_source2_ != nullptr) { + this->audio_source2_->clear_buffered_data(); + } + } + this->mic_source_->start(); + if (this->mic_source2_) { + this->mic_source2_->start(); + } + } else { + this->mic_source_->stop(); + if (this->mic_source2_) { + this->mic_source2_->stop(); + } + } +#endif } else { this->deallocate_buffers_(); } @@ -334,7 +412,11 @@ void VoiceAssistant::loop() { if (this->status_has_error()) { this->status_clear_error(); } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->clear_buffers_(!this->wake_word_audio_buffering_); +#else this->clear_buffers_(); +#endif this->mic_source_->start(); if (this->mic_source2_) { @@ -367,6 +449,30 @@ void VoiceAssistant::loop() { msg.flags = flags; msg.audio_settings = std::move(audio_settings); msg.wake_word_phrase = StringRef(this->wake_word_); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + if (this->wake_word_audio_buffering_) { + const size_t bytes_per_ms = SAMPLE_RATE_HZ / 1000 * sizeof(int16_t); + // Audio from after the detection belongs to the command, not the wake word + const size_t command_bytes = + this->start_with_detection_ ? (millis() - this->detection_.detected_at) * bytes_per_ms : 0; + const size_t target_bytes = + this->start_with_detection_ ? (this->wake_word_audio_buffer_duration_ * bytes_per_ms) + command_bytes : 0; + const size_t buffered = trim_audio_source(this->audio_source_.get(), this->ring_buffer_, target_bytes); + const size_t buffered2 = trim_audio_source(this->audio_source2_.get(), this->ring_buffer2_, target_bytes); + if (this->start_with_detection_) { + const size_t wake_word_bytes = buffered - std::min(buffered, command_bytes); + const size_t wake_word_bytes2 = buffered2 - std::min(buffered2, command_bytes); + msg.wake_word_id = StringRef(this->detection_.id); + msg.wake_word_audio_bytes = wake_word_bytes; + msg.wake_word_audio_bytes2 = wake_word_bytes2; + msg.wake_word_probability = this->detection_.probability; + msg.wake_word_probability_cutoff = this->detection_.probability_cutoff; + ESP_LOGD(TAG, "Sending %zu/%zu bytes of wake word audio for %s", wake_word_bytes, wake_word_bytes2, + this->detection_.id.c_str()); + } + } + this->detection_.detected_at = 0; +#endif // Reset media player state tracking #ifdef USE_MEDIA_PLAYER @@ -382,9 +488,26 @@ void VoiceAssistant::loop() { this->set_state_(State::IDLE, State::IDLE); break; } - this->set_state_(State::STARTING_PIPELINE); this->set_timeout(RESET_CONVERSATION_TIMEOUT_ID, this->conversation_timeout_, [this]() { this->reset_conversation_id(); }); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->streaming_before_response_ = this->start_with_detection_; + if (this->start_with_detection_) { + this->start_with_detection_ = false; + // Waiting for the response would let live audio overwrite the buffered audio + ESP_LOGD(TAG, "Streaming microphone"); + this->audio_mode_ = AUDIO_MODE_API; + this->set_state_(State::STREAMING_MICROPHONE, State::STREAMING_MICROPHONE); + this->set_timeout(WAKE_WORD_VERDICT_TIMEOUT_ID, this->wake_word_audio_timeout_, [this]() { + ESP_LOGW(TAG, "Wake word was not accepted or rejected in time"); + this->request_stop(); + this->error_trigger_.trigger("wake-word-verification-timeout", + "Wake word was not accepted or rejected in time"); + }); + break; + } +#endif + this->set_state_(State::STARTING_PIPELINE); break; } case State::STARTING_PIPELINE: { @@ -416,6 +539,12 @@ void VoiceAssistant::loop() { break; } case State::STOP_MICROPHONE: { +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + if (this->wake_word_audio_buffering_) { + this->set_state_(this->desired_state_); + break; + } +#endif // Check both microphone channels bool is_running = this->mic_source_->is_running(); bool is_running2 = false; @@ -579,13 +708,16 @@ void VoiceAssistant::write_speaker_() { } #endif -void VoiceAssistant::client_subscription(api::APIConnection *client, bool subscribe) { +void VoiceAssistant::client_subscription(api::APIConnection *client, bool subscribe, uint32_t flags) { if (!subscribe) { if (this->api_client_ == nullptr || client != this->api_client_) { ESP_LOGE(TAG, "Client attempting to unsubscribe that is not the current API Client"); return; } this->api_client_ = nullptr; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->wake_word_audio_requested_ = false; +#endif this->client_disconnected_trigger_.trigger(); return; } @@ -603,6 +735,13 @@ void VoiceAssistant::client_subscription(api::APIConnection *client, bool subscr } this->api_client_ = client; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->wake_word_audio_requested_ = (flags & api::enums::VOICE_ASSISTANT_SUBSCRIBE_WAKE_WORD_AUDIO) != 0; + ESP_LOGD(TAG, "Wake word audio %s", + this->wake_word_audio_requested_ ? LOG_STR_LITERAL("requested") : LOG_STR_LITERAL("not requested")); + // So is_wake_word_audio_active() reflects a failed allocation in on_client_connected + this->update_wake_word_audio_(); +#endif this->client_connected_trigger_.trigger(); } @@ -639,11 +778,24 @@ static const LogString *voice_assistant_state_to_string(State state) { } }; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO +static bool is_microphone_streaming_state(State state) { + return (state == State::START_PIPELINE) || (state == State::STARTING_PIPELINE) || + (state == State::STREAMING_MICROPHONE); +} +#endif + void VoiceAssistant::set_state_(State state) { State old_state = this->state_; this->state_ = state; ESP_LOGD(TAG, "State changed from %s to %s", LOG_STR_ARG(voice_assistant_state_to_string(old_state)), LOG_STR_ARG(voice_assistant_state_to_string(state))); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + if (this->wake_word_audio_buffering_ && is_microphone_streaming_state(old_state) && + !is_microphone_streaming_state(state)) { + this->end_wake_word_audio_stream_(); + } +#endif } void VoiceAssistant::set_state_(State state, State desired_state) { @@ -669,6 +821,12 @@ void VoiceAssistant::failed_to_start() { } void VoiceAssistant::start_streaming() { +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + if (this->streaming_before_response_) { + this->streaming_before_response_ = false; + return; + } +#endif if (this->state_ != State::STARTING_PIPELINE) { this->signal_stop_(); return; @@ -733,6 +891,13 @@ void VoiceAssistant::request_start(bool continuous, bool silence_detection) { if (this->state_ == State::IDLE) { this->continuous_ = continuous; this->silence_detection_ = silence_detection; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->start_with_detection_ = this->wake_word_audio_buffering_ && (this->detection_.detected_at != 0) && + ((millis() - this->detection_.detected_at) <= WAKE_WORD_DETECTION_MAX_AGE_MS); + if (!this->start_with_detection_) { + this->detection_.detected_at = 0; + } +#endif this->set_state_(State::START_MICROPHONE, State::START_PIPELINE); } @@ -741,6 +906,11 @@ void VoiceAssistant::request_start(bool continuous, bool silence_detection) { void VoiceAssistant::request_stop() { this->continuous_ = false; this->continue_conversation_ = false; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->start_with_detection_ = false; + this->detection_.detected_at = 0; + this->clear_wake_word_verdict_(); +#endif switch (this->state_) { case State::IDLE: @@ -850,9 +1020,23 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { break; case api::enums::VOICE_ASSISTANT_WAKE_WORD_END: { ESP_LOGD(TAG, "Wake word detected"); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->clear_wake_word_verdict_(); +#endif this->defer([this]() { this->wake_word_detected_trigger_.trigger(); }); break; } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + case api::enums::VOICE_ASSISTANT_WAKE_WORD_REJECTED: { + ESP_LOGD(TAG, "Wake word rejected"); + this->clear_wake_word_verdict_(); + if ((this->state_ == State::STARTING_PIPELINE) || (this->state_ == State::STREAMING_MICROPHONE)) { + this->set_state_(State::STOP_MICROPHONE, State::IDLE); + } + this->defer([this]() { this->wake_word_rejected_trigger_.trigger(); }); + break; + } +#endif case api::enums::VOICE_ASSISTANT_STT_START: ESP_LOGD(TAG, "STT started"); this->defer([this]() { this->listening_trigger_.trigger(); }); @@ -976,6 +1160,9 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { } case api::enums::VOICE_ASSISTANT_RUN_END: { ESP_LOGD(TAG, "Assist Pipeline ended"); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->clear_wake_word_verdict_(); +#endif if ((this->state_ == State::START_PIPELINE) || (this->state_ == State::STARTING_PIPELINE) || (this->state_ == State::STREAMING_MICROPHONE)) { // Microphone is running, stop it @@ -1012,6 +1199,9 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { return; } ESP_LOGE(TAG, "Error: %s - %s", code.c_str(), message.c_str()); +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + this->clear_wake_word_verdict_(); +#endif if (this->state_ != State::IDLE) { this->signal_stop_(); this->set_state_(State::STOP_MICROPHONE, State::IDLE); @@ -1215,6 +1405,60 @@ const Configuration &VoiceAssistant::get_configuration() { return this->config_; }; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO +void VoiceAssistant::update_wake_word_audio_() { + const bool active = this->is_wake_word_audio_active(); + if (active == this->wake_word_audio_buffering_) { + return; + } + + if (active) { + if (this->state_ != State::IDLE) { + // Retried every loop, so the buffers are resized once the run ends + return; + } + // Buffers left over from a run without wake word audio are too small + this->deallocate_buffers_(); + if (!this->allocate_buffers_()) { + // Detect wake words without verification rather than retry large allocations every loop + ESP_LOGE(TAG, "Not enough memory for wake word audio"); + this->wake_word_audio_requested_ = false; + return; + } + // The IDLE branch of loop() starts the microphone while microWakeWord runs + ESP_LOGD(TAG, "Buffering wake word audio"); + } else { + this->clear_wake_word_verdict_(); + if (this->state_ == State::IDLE) { + this->mic_source_->stop(); + if (this->mic_source2_) { + this->mic_source2_->stop(); + } + } + ESP_LOGD(TAG, "Stopped buffering wake word audio"); + } + this->wake_word_audio_buffering_ = active; + +#ifdef USE_MICRO_WAKE_WORD_VAD + // The VAD model blocks true detections at the lower cutoffs + this->micro_wake_word_->set_vad_enabled(!active); +#endif +} + +void VoiceAssistant::clear_wake_word_verdict_() { this->cancel_timeout(WAKE_WORD_VERDICT_TIMEOUT_ID); } + +void VoiceAssistant::end_wake_word_audio_stream_() { + // A chunk still held by an audio source stops its ring buffer from replacing old audio + if (this->audio_source_ != nullptr) { + this->audio_source_->clear_buffered_data(); + } + if (this->audio_source2_ != nullptr) { + this->audio_source2_->clear_buffered_data(); + } + this->clear_wake_word_verdict_(); +} +#endif + VoiceAssistant *global_voice_assistant = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::voice_assistant diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 75e382543b3..ec66e5f5e9d 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -42,6 +42,7 @@ enum VoiceAssistantFeature : uint32_t { FEATURE_ANNOUNCE = 1 << 4, FEATURE_START_CONVERSATION = 1 << 5, FEATURE_MULTI_CHANNEL_AUDIO = 1 << 6, + FEATURE_WAKE_WORD_AUDIO = 1 << 7, }; enum class State : uint8_t { @@ -89,6 +90,16 @@ struct WakeWord { std::vector trained_languages; }; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO +struct WakeWordDetection { + std::string id; + float probability; + float probability_cutoff; + // 0 once used + uint32_t detected_at{0}; +}; +#endif + struct Configuration { std::vector available_wake_words; std::vector active_wake_words; @@ -149,6 +160,9 @@ class VoiceAssistant final : public Component { if (this->mic_source2_ != nullptr) { flags |= VoiceAssistantFeature::FEATURE_MULTI_CHANNEL_AUDIO; } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + flags |= VoiceAssistantFeature::FEATURE_WAKE_WORD_AUDIO; +#endif #ifdef USE_SPEAKER if (this->speaker_ != nullptr) { flags |= VoiceAssistantFeature::FEATURE_SPEAKER; @@ -187,6 +201,20 @@ class VoiceAssistant final : public Component { void set_use_wake_word(bool use_wake_word) { this->use_wake_word_ = use_wake_word; } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + void set_wake_word_audio(uint32_t buffer_duration_ms, uint32_t timeout_ms) { + this->wake_word_audio_buffer_duration_ = buffer_duration_ms; + this->wake_word_audio_timeout_ = timeout_ms; + } +#endif + bool is_wake_word_audio_active() const { +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + return this->wake_word_audio_requested_ && (this->api_client_ != nullptr); +#else + return false; +#endif + } + void set_noise_suppression_level(uint8_t noise_suppression_level) { this->noise_suppression_level_ = noise_suppression_level; } @@ -209,6 +237,9 @@ class VoiceAssistant final : public Component { Trigger<> *get_tts_stream_end_trigger() { return &this->tts_stream_end_trigger_; } #endif Trigger<> *get_wake_word_detected_trigger() { return &this->wake_word_detected_trigger_; } +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + Trigger<> *get_wake_word_rejected_trigger() { return &this->wake_word_rejected_trigger_; } +#endif Trigger *get_stt_end_trigger() { return &this->stt_end_trigger_; } Trigger *get_tts_end_trigger() { return &this->tts_end_trigger_; } Trigger *get_tts_start_trigger() { return &this->tts_start_trigger_; } @@ -218,7 +249,7 @@ class VoiceAssistant final : public Component { Trigger<> *get_client_connected_trigger() { return &this->client_connected_trigger_; } Trigger<> *get_client_disconnected_trigger() { return &this->client_disconnected_trigger_; } - void client_subscription(api::APIConnection *client, bool subscribe); + void client_subscription(api::APIConnection *client, bool subscribe, uint32_t flags = 0); api::APIConnection *get_api_connection() const { return this->api_client_; } void set_wake_word(const std::string &wake_word) { this->wake_word_ = wake_word; } @@ -233,8 +264,15 @@ class VoiceAssistant final : public Component { protected: bool allocate_buffers_(); - void clear_buffers_(); + void clear_buffers_(bool clear_microphone_audio = true); void deallocate_buffers_(); + size_t ring_buffer_size_() const; + +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + void update_wake_word_audio_(); + void clear_wake_word_verdict_(); + void end_wake_word_audio_stream_(); +#endif void set_state_(State state); void set_state_(State state, State desired_state); @@ -268,6 +306,9 @@ class VoiceAssistant final : public Component { #endif Trigger intent_progress_trigger_; Trigger<> wake_word_detected_trigger_; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + Trigger<> wake_word_rejected_trigger_; +#endif Trigger stt_end_trigger_; Trigger tts_end_trigger_; Trigger tts_start_trigger_; @@ -332,6 +373,12 @@ class VoiceAssistant final : public Component { micro_wake_word::MicroWakeWord *micro_wake_word_{nullptr}; #endif +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + WakeWordDetection detection_{}; + uint32_t wake_word_audio_buffer_duration_{0}; + uint32_t wake_word_audio_timeout_{0}; +#endif + // 1 byte members grouped at the end so they share padding instead of each taking a word State state_{State::IDLE}; State desired_state_{State::IDLE}; @@ -354,6 +401,12 @@ class VoiceAssistant final : public Component { bool silence_detection_; bool continue_conversation_{false}; bool udp_socket_running_{false}; +#ifdef USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO + bool wake_word_audio_requested_{false}; + bool wake_word_audio_buffering_{false}; + bool start_with_detection_{false}; + bool streaming_before_response_{false}; +#endif }; extern VoiceAssistant *global_voice_assistant; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8c8814a1e20..50ad8eb2abe 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -692,6 +692,7 @@ #define USE_MICRO_WAKE_WORD #define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD +#define USE_VOICE_ASSISTANT_WAKE_WORD_AUDIO #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD #define USE_OPENTHREAD_SRP diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 9cf8382cb4c..6a5c00b2f73 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -54,6 +54,11 @@ voice_assistant: channels: 0 speaker: va_speaker_id micro_wake_word: mww_id + wake_word_audio: + buffer_duration: 1500ms + timeout: 2s + on_wake_word_rejected: + - logger.log: "Wake word rejected" conversation_timeout: 60s tts_playback_start_timeout: 10s on_listening: From cdc0d69e4c1c8d0be3c5a6ab18274513b6c171a2 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 4 Nov 2026 18:27:20 -0500 Subject: [PATCH 36/55] [canbus] Add support for CAN FD frames (#21950) Co-authored-by: J. Nick Koston --- esphome/components/canbus/__init__.py | 115 +++++++- esphome/components/canbus/canbus.cpp | 127 +++++++-- esphome/components/canbus/canbus.h | 187 +++++++++++-- esphome/core/defines.h | 1 + .../canbus/test_canbus_payload_tables.py | 107 +++++++- tests/components/canbus/fake_canbus.h | 47 ++++ tests/components/canbus/fd_test.cpp | 254 ++++++++++++++++++ .../components/canbus/frame_callback_test.cpp | 29 +- tests/components/canbus/send_test.cpp | 136 ++++++++++ 9 files changed, 920 insertions(+), 83 deletions(-) create mode 100644 tests/components/canbus/fake_canbus.h create mode 100644 tests/components/canbus/fd_test.cpp create mode 100644 tests/components/canbus/send_test.cpp diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 1936bba54e8..f099105bd12 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -4,10 +4,12 @@ from typing import Any from esphome import automation import esphome.codegen as cg +from esphome.config import iter_ids import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -24,10 +26,21 @@ CONF_CANBUS_ID = "canbus_id" CONF_BIT_RATE = "bit_rate" CONF_ON_FRAME = "on_frame" CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop" +CONF_FD = "fd" +CONF_BIT_RATE_SWITCH = "bit_rate_switch" +CONF_PAD_DATA = "pad_data" +CONF_DEFAULT_FD = "default_fd" +CONF_DEFAULT_BIT_RATE_SWITCH = "default_bit_rate_switch" +CONF_DEFAULT_PAD_DATA = "default_pad_data" DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h # All 29 id bits; received ids never use more, so on_frame skips the & for this mask CAN_ID_MASK_ALL = 0x1FFFFFFF +CAN_MAX_DATA_LENGTH = 8 +# Payload length of each CAN FD DLC, keep in sync with CAN_FD_DLC_LENGTHS in canbus.h +CAN_FD_DATA_LENGTHS = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64) +# canbus.send options that need a platform built on CANBUS_FD_SCHEMA +_FD_SEND_KEYS = (CONF_FD, CONF_BIT_RATE_SWITCH, CONF_PAD_DATA) def _flag(name: str, value: bool) -> str: @@ -84,6 +97,21 @@ CAN_SPEEDS = CanSpeed.options( prefix="CAN_", ) +# Data phase rates for a CAN FD platform's own schema +CAN_DATA_SPEEDS = { + **CAN_SPEEDS, + **CanSpeed.options( + "2000KBPS", + "3000KBPS", + "4000KBPS", + "5000KBPS", + "6000KBPS", + "7000KBPS", + "8000KBPS", + prefix="CAN_", + ), +} + def get_rate(value: str) -> int: match = re.match(r"(\d+)(?:K(\d+)?)?BPS", value, re.IGNORECASE) @@ -120,12 +148,37 @@ CANBUS_SCHEMA = cv.Schema( CANBUS_SCHEMA.add_extra(validate_id) +# A platform whose controller supports CAN FD extends this instead of CANBUS_SCHEMA. Its buses then accept the +# CAN FD options of canbus.send, and register_canbus() turns on USE_CANBUS_FD, which makes every CanFrame +# 64 bytes long. +CANBUS_FD_SCHEMA = CANBUS_SCHEMA.extend( + { + cv.Optional(CONF_DEFAULT_FD, default=False): cv.boolean, + cv.Optional(CONF_DEFAULT_BIT_RATE_SWITCH, default=False): cv.boolean, + cv.Optional(CONF_DEFAULT_PAD_DATA, default=False): cv.boolean, + } +) + + +def _is_fd_bus(config: ConfigType) -> bool: + return CONF_DEFAULT_FD in config + async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_can_id(config[CONF_CAN_ID])) if config[CONF_USE_EXTENDED_ID]: cg.add(var.set_use_extended_id(True)) + if _is_fd_bus(config): + cg.add_define("USE_CANBUS_FD") + cg.add(var.set_supports_fd(True)) + for key, setter in ( + (CONF_DEFAULT_FD, var.set_default_fd), + (CONF_DEFAULT_BIT_RATE_SWITCH, var.set_default_bit_rate_switch), + (CONF_DEFAULT_PAD_DATA, var.set_default_pad_data), + ): + if config.get(key): + cg.add(setter(True)) if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE: cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate])) if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP: @@ -175,6 +228,53 @@ async def register_canbus( await setup_canbus_core_(var, config) +def validate_send(action: ConfigType, bus: ConfigType) -> None: + """Reject a canbus.send that its bus cannot send, as far as the values are known. + + Lambda values are checked when the action runs. + """ + data = action[CONF_DATA] + length = None if cg.is_template(data) else len(data) + if not _is_fd_bus(bus): + for key in _FD_SEND_KEYS: + if key in action: + raise cv.Invalid( + f"{key} needs a canbus platform that supports CAN FD", path=[key] + ) + fd = False + else: + fd = action.get(CONF_FD, bus[CONF_DEFAULT_FD]) + if fd is False and action.get(CONF_BIT_RATE_SWITCH) is True: + raise cv.Invalid("bit_rate_switch requires fd: true") + if fd is True and action[CONF_REMOTE_TRANSMISSION_REQUEST]: + raise cv.Invalid("remote_transmission_request cannot be used with fd: true") + if length is None: + return + if fd is False and length > CAN_MAX_DATA_LENGTH: + raise cv.Invalid("Classic CAN data must be at most 8 bytes", path=[CONF_DATA]) + if ( + fd is True + and length not in CAN_FD_DATA_LENGTHS + and action.get(CONF_PAD_DATA, bus[CONF_DEFAULT_PAD_DATA]) is False + ): + raise cv.Invalid("CAN FD data length requires pad_data: true", path=[CONF_DATA]) + + +def final_validate(config: list[ConfigType]) -> None: + full_config = fv.full_config.get() + for bus_id, path in iter_ids(full_config): + if len(path) < 2 or path[-2:] != ["canbus.send", CONF_CANBUS_ID]: + continue + action_path = path[:-1] + action = full_config.get_config_for_path(action_path) + bus = fv.get_declaring_config(bus_id, full_config) + with cv.prepend_path([cv.ROOT_CONFIG_PATH, *action_path]): + validate_send(action, bus) + + +FINAL_VALIDATE_SCHEMA = final_validate + + # Actions CANBUS_SEND_ACTION_SCHEMA = cv.maybe_simple_value( { @@ -182,9 +282,12 @@ CANBUS_SEND_ACTION_SCHEMA = cv.maybe_simple_value( cv.Optional(CONF_CAN_ID): cv.int_range(min=0, max=CAN_ID_MASK_ALL), cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, cv.Optional(CONF_REMOTE_TRANSMISSION_REQUEST, default=False): cv.boolean, - # One classic CAN frame + cv.Optional(CONF_FD): cv.templatable(cv.boolean), + cv.Optional(CONF_BIT_RATE_SWITCH): cv.templatable(cv.boolean), + cv.Optional(CONF_PAD_DATA): cv.templatable(cv.boolean), + # One CAN FD frame; final_validate holds a classic bus to 8 bytes cv.Required(CONF_DATA): cv.templatable( - cv.All(validate_raw_data, cv.Length(max=8)) + cv.All(validate_raw_data, cv.Length(max=CAN_FD_DATA_LENGTHS[-1])) ), }, validate_id, @@ -214,6 +317,14 @@ async def canbus_action_to_code( cg.add( var.set_remote_transmission_request(config[CONF_REMOTE_TRANSMISSION_REQUEST]) ) + # Only set on a CAN FD bus, which final_validate checks + for key, setter in ( + (CONF_FD, var.set_fd), + (CONF_BIT_RATE_SWITCH, var.set_bit_rate_switch), + (CONF_PAD_DATA, var.set_pad_data), + ): + if (value := config.get(key)) is not None: + cg.add(setter(await cg.templatable(value, args, cg.bool_))) await automation.templatable_bytes( config[CONF_DATA], diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index 8d2831556d4..b60a4134476 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -22,35 +22,107 @@ void Canbus::dump_config() { } } +canbus::Error Canbus::send_data(const CanFrame &frame) { + if (frame.can_id > (frame.use_extended_id ? CAN_EXTENDED_ID_MAX : CAN_STANDARD_ID_MAX)) { + ESP_LOGE(TAG, "CAN ID 0x%08" PRIx32 " exceeds frame identifier width", frame.can_id); + return ERROR_FAILTX; + } +#ifdef USE_CANBUS_FD + if (frame.fd && !this->supports_fd_) { + ESP_LOGE(TAG, "CAN FD frame on a bus without CAN FD"); + return ERROR_FAILTX; + } + if (frame.fd ? (frame.remote_transmission_request || can_dlc_to_len(frame.can_data_length_code) == CAN_INVALID_DLC) + : frame.bit_rate_switch) { + ESP_LOGE(TAG, "Invalid CAN frame: fd=%d rtr=%d brs=%d dlc=%" PRIu8, frame.fd, frame.remote_transmission_request, + frame.bit_rate_switch, frame.can_data_length_code); + return ERROR_FAILTX; + } +#endif + return this->send_frame_(frame); +} + +#ifdef USE_CANBUS_FD +canbus::Error Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, + const uint8_t *data, size_t len, bool fd, bool bit_rate_switch, bool pad_data) { +#else canbus::Error Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, const uint8_t *data, size_t len) { - struct CanFrame can_message; - +#endif + CanFrame frame; + frame.use_extended_id = use_extended_id; + frame.remote_transmission_request = remote_transmission_request; + frame.can_id = can_id; +#ifdef USE_CANBUS_FD + frame.fd = fd; + frame.bit_rate_switch = bit_rate_switch; + frame.error_state_indicator = false; + if (fd) { + frame.can_data_length_code = can_len_to_dlc(len, pad_data); + if (frame.can_data_length_code == CAN_INVALID_DLC) { + ESP_LOGE(TAG, "CAN FD cannot carry %zu bytes without pad_data", len); + return ERROR_FAILTX; + } + if (!remote_transmission_request && len != 0) { + std::memcpy(frame.data, data, len); + std::memset(frame.data + len, 0, can_dlc_to_len(frame.can_data_length_code) - len); + } + return this->send_data(frame); + } +#endif + if (len > CAN_MAX_DATA_LENGTH) { + ESP_LOGW(TAG, "Classic CAN carries 8 bytes, %zu given, sent 8", len); + } // Clamp before narrowing, so a lambda payload of 256 bytes or more cannot wrap to a short length - const uint8_t size = static_cast(std::min(len, CAN_MAX_DATA_LENGTH)); - if (use_extended_id) { - ESP_LOGD(TAG, "send extended id=0x%08" PRIx32 " rtr=%s size=%d", can_id, TRUEFALSE(remote_transmission_request), - size); + frame.can_data_length_code = static_cast(std::min(len, CAN_MAX_DATA_LENGTH)); + // A remote request carries no data, so its bytes are zeroed rather than left from the stack. An empty vector may + // hand over a null pointer, which memcpy must not get even for 0 bytes. + if (remote_transmission_request) { + std::memset(frame.data, 0, frame.can_data_length_code); + } else if (frame.can_data_length_code != 0) { + std::memcpy(frame.data, data, frame.can_data_length_code); + } +#ifdef USE_CANBUS_FD + // The caller's flags can still contradict each other + return this->send_data(frame); +#else + return this->send_frame_(frame); +#endif +} + +bool Canbus::lambda_payload_fits_(const CanPayload &payload, [[maybe_unused]] bool fd) { + if (!payload.truncated()) + return true; +#ifdef USE_CANBUS_FD + if (fd) { + ESP_LOGE(TAG, "CAN FD carries at most %u bytes, frame not sent", CAN_FRAME_DATA_LENGTH); + return false; + } + // The payload still holds 64 bytes, so send_data() warns as it cuts a classic frame to 8 +#else + ESP_LOGW(TAG, "Classic CAN carries 8 bytes, the rest is cut"); +#endif + return true; +} + +canbus::Error Canbus::send_frame_(const CanFrame &frame) { + const uint8_t size = frame.payload_length(); + if (frame.use_extended_id) { + ESP_LOGD(TAG, "send extended id=0x%08" PRIx32 " rtr=%s size=%d", frame.can_id, + TRUEFALSE(frame.remote_transmission_request), size); } else { - ESP_LOGD(TAG, "send standard id=0x%03" PRIx32 " rtr=%s size=%d", can_id, TRUEFALSE(remote_transmission_request), - size); + ESP_LOGD(TAG, "send standard id=0x%03" PRIx32 " rtr=%s size=%d", frame.can_id, + TRUEFALSE(frame.remote_transmission_request), size); } - can_message.can_data_length_code = size; - can_message.can_id = can_id; - can_message.use_extended_id = use_extended_id; - can_message.remote_transmission_request = remote_transmission_request; + ESP_LOGV_HEX(TAG, frame.data, size, CAN_FRAME_DATA_LENGTH, " data=%s"); - for (int i = 0; i < size; i++) { - can_message.data[i] = data[i]; - ESP_LOGVV(TAG, " data[%d]=%02x", i, can_message.data[i]); - } - - canbus::Error error = this->send_message(&can_message); + // Drivers take a mutable pointer but do not change the frame + canbus::Error error = this->send_message(const_cast(&frame)); if (error != canbus::ERROR_OK) { - if (use_extended_id) { - ESP_LOGW(TAG, "send to extended id=0x%08" PRIx32 " failed with error %d!", can_id, error); + if (frame.use_extended_id) { + ESP_LOGW(TAG, "send to extended id=0x%08" PRIx32 " failed with error %d!", frame.can_id, error); } else { - ESP_LOGW(TAG, "send to standard id=0x%03" PRIx32 " failed with error %d!", can_id, error); + ESP_LOGW(TAG, "send to standard id=0x%03" PRIx32 " failed with error %d!", frame.can_id, error); } } return error; @@ -66,19 +138,26 @@ void Canbus::loop() { struct CanFrame can_message; uint32_t message_counter = 0; for (; message_counter < this->max_frames_per_loop_; message_counter++) { +#ifdef USE_CANBUS_FD + // Classic drivers leave the CAN FD flags alone + can_message.fd = false; + can_message.bit_rate_switch = false; + can_message.error_state_indicator = false; +#endif if (this->read_message(&can_message) != canbus::ERROR_OK) { break; } + const uint8_t size = can_message.payload_length(); if (can_message.use_extended_id) { ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1, - can_message.can_id, can_message.can_data_length_code); + can_message.can_id, size); } else { ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1, - can_message.can_id, can_message.can_data_length_code); + can_message.can_id, size); } - ESP_LOGV_HEX(TAG, can_message.data, can_message.payload_length(), CAN_MAX_DATA_LENGTH, " data=%s"); + ESP_LOGV_HEX(TAG, can_message.data, size, CAN_FRAME_DATA_LENGTH, " data=%s"); this->frame_callbacks_.call(can_message); } diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 73094872277..f46d92bff01 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -2,12 +2,16 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/optional.h" #include #include +#include #include +#include +#include #include namespace esphome::canbus { @@ -42,43 +46,125 @@ enum CanSpeed : uint8_t { CAN_250KBPS, CAN_500KBPS, CAN_800KBPS, - CAN_1000KBPS + CAN_1000KBPS, +#ifdef USE_CANBUS_FD + // Data phase rates of a CAN FD controller + CAN_2000KBPS, + CAN_3000KBPS, + CAN_4000KBPS, + CAN_5000KBPS, + CAN_6000KBPS, + CAN_7000KBPS, + CAN_8000KBPS, +#endif }; template class CanbusSendAction; /* CAN payload length definitions according to ISO 11898-1 */ -static const uint8_t CAN_MAX_DATA_LENGTH = 8; +static constexpr uint8_t CAN_MAX_DATA_LENGTH = 8; +#ifdef USE_CANBUS_FD +static constexpr uint8_t CAN_FD_MAX_DATA_LENGTH = 64; +/// Payload bytes a CanFrame holds: one CAN FD frame when a platform that supports it is configured +static constexpr uint8_t CAN_FRAME_DATA_LENGTH = CAN_FD_MAX_DATA_LENGTH; +#else +/// Payload bytes a CanFrame holds: one classic frame +static constexpr uint8_t CAN_FRAME_DATA_LENGTH = CAN_MAX_DATA_LENGTH; +#endif -static const uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128; +static constexpr uint32_t CAN_STANDARD_ID_MAX = 0x7FF; +static constexpr uint32_t CAN_EXTENDED_ID_MAX = 0x1FFFFFFF; + +static constexpr uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128; + +#ifdef USE_CANBUS_FD +/// Returned by the length conversions for a value that cannot be represented +static constexpr uint8_t CAN_INVALID_DLC = UINT8_MAX; + +/// Payload length of each CAN FD DLC; a classic frame carries min(DLC, 8) bytes +static constexpr uint8_t CAN_FD_DLC_COUNT = 16; +static constexpr uint8_t CAN_FD_DLC_LENGTHS[CAN_FD_DLC_COUNT] PROGMEM = {0, 1, 2, 3, 4, 5, 6, 7, + 8, 12, 16, 20, 24, 32, 48, 64}; + +/// CAN_FD_DLC_LENGTHS[dlc] for dlc below CAN_FD_DLC_COUNT; read from flash at run time +constexpr uint8_t can_fd_dlc_length(uint8_t dlc) { + return std::is_constant_evaluated() ? CAN_FD_DLC_LENGTHS[dlc] : progmem_read_byte(&CAN_FD_DLC_LENGTHS[dlc]); +} + +/// Payload length of a CAN FD DLC, or CAN_INVALID_DLC above 15 +constexpr uint8_t can_dlc_to_len(uint8_t dlc) { + return dlc < CAN_FD_DLC_COUNT ? can_fd_dlc_length(dlc) : CAN_INVALID_DLC; +} + +/// CAN FD DLC for a payload length. With pad, the next longer length is used and the caller zero fills +/// the extra bytes; otherwise a length CAN FD cannot carry returns CAN_INVALID_DLC. +constexpr uint8_t can_len_to_dlc(size_t len, bool pad) { + for (uint8_t dlc = 0; dlc < CAN_FD_DLC_COUNT; dlc++) { + const uint8_t length = can_fd_dlc_length(dlc); + if (length >= len) + return (length == len || pad) ? dlc : CAN_INVALID_DLC; + } + return CAN_INVALID_DLC; +} +#else +/// Payload length of a classic DLC; 9 to 15 still carry 8 bytes +constexpr uint8_t can_dlc_to_len(uint8_t dlc) { return std::min(dlc, CAN_MAX_DATA_LENGTH); } +#endif /// Frame payload without heap; converts to and from std::vector for older lambdas. -class CanPayload : public StaticVector { +class CanPayload : public StaticVector { public: - using StaticVector::StaticVector; + using StaticVector::StaticVector; // Not = default, so value-init does not zero the buffer CanPayload() {} + // Every constructor that can be given more than one frame records that it cut the rest + template CanPayload(InputIt first, InputIt last) { + for (; first != last; ++first) { + if (this->size() == CAN_FRAME_DATA_LENGTH) { + this->truncated_ = true; + break; + } + this->push_back(*first); + } + } + CanPayload(std::initializer_list data) : CanPayload(data.begin(), data.end()) {} CanPayload(const std::vector &data) // NOLINT(google-explicit-constructor) - : StaticVector(data.begin(), data.end()) {} + : CanPayload(data.begin(), data.end()) {} operator std::vector() const { return {this->begin(), this->end()}; } // NOLINT(google-explicit-constructor) + /// Whether it was built from more bytes than one frame can carry + bool truncated() const { return this->truncated_; } + + protected: + bool truncated_{false}; }; -/* -Can Frame describes a normative CAN Frame -The RTR = Remote Transmission Request is implemented in every CAN controller but rarely used -So currently the flag is passed to and from the hardware but currently ignored to the user application. -*/ +/// One CAN frame as passed between the bus and its driver. The CAN FD flags come after data, so code that +/// brace-initializes a classic frame keeps working. struct CanFrame { bool use_extended_id = false; bool remote_transmission_request = false; uint32_t can_id; /* 29 or 11 bit CAN_ID; drivers clear bits 29-31 (on_frame relies on it) */ - uint8_t can_data_length_code; /* frame payload length in byte (0 .. CAN_MAX_DATA_LENGTH) */ - uint8_t data[CAN_MAX_DATA_LENGTH] __attribute__((aligned(8))); + uint8_t can_data_length_code; /* DLC as on the wire, not a byte count (see payload_length()) */ + uint8_t data[CAN_FRAME_DATA_LENGTH] __attribute__((aligned(8))); +#ifdef USE_CANBUS_FD + bool fd = false; /* CAN FD frame; it cannot be a remote transmission request */ + bool bit_rate_switch = false; /* CAN FD only: data phase sent at the data bit rate */ + bool error_state_indicator = false; /* CAN FD only: received transmitter was error passive */ +#endif - uint8_t payload_length() const { return std::min(this->can_data_length_code, CAN_MAX_DATA_LENGTH); } + /// Payload bytes for the DLC: at most 8 for a classic frame, 0 for an invalid CAN FD DLC + uint8_t payload_length() const { +#ifdef USE_CANBUS_FD + if (this->fd) { + const uint8_t len = can_dlc_to_len(this->can_data_length_code); + return len == CAN_INVALID_DLC ? 0 : len; + } +#endif + return std::min(this->can_data_length_code, CAN_MAX_DATA_LENGTH); + } CanPayload payload() const { CanPayload out; - std::memcpy(out.data(), this->data, CAN_MAX_DATA_LENGTH); + std::memcpy(out.data(), this->data, CAN_FRAME_DATA_LENGTH); out.resize(this->payload_length()); return out; } @@ -112,8 +198,18 @@ class Canbus : public Component { float get_setup_priority() const override { return setup_priority::HARDWARE; } void loop() override; + /// Validates the frame and passes it to the driver. + canbus::Error send_data(const CanFrame &frame); +#ifdef USE_CANBUS_FD + /// Sends up to 8 bytes as a classic frame, longer data is cut. An fd frame carries up to 64 bytes; pad_data + /// zero fills a length CAN FD cannot carry up to the next one. + canbus::Error send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, const uint8_t *data, + size_t len, bool fd = false, bool bit_rate_switch = false, bool pad_data = false); +#else + /// Sends up to 8 bytes as a classic frame, longer data is cut. canbus::Error send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, const uint8_t *data, size_t len); +#endif canbus::Error send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, const std::vector &data) { return this->send_data(can_id, use_extended_id, remote_transmission_request, data.data(), data.size()); @@ -126,6 +222,23 @@ class Canbus : public Component { void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; } void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } +#ifdef USE_CANBUS_FD + // Defaults for canbus.send actions only, not for send_data() + void set_default_fd(bool default_fd) { this->default_fd_ = default_fd; } + void set_default_bit_rate_switch(bool default_bit_rate_switch) { + this->default_bit_rate_switch_ = default_bit_rate_switch; + } + void set_default_pad_data(bool default_pad_data) { this->default_pad_data_ = default_pad_data; } + void set_supports_fd(bool supports_fd) { this->supports_fd_ = supports_fd; } +#endif + /// Whether this bus sends and receives CAN FD frames; a classic bus can share a build with a CAN FD one + bool supports_fd() const { +#ifdef USE_CANBUS_FD + return this->supports_fd_; +#else + return false; +#endif + } virtual CanStatus get_status() { return {}; } /// The frame is only valid during the call. @@ -156,6 +269,12 @@ class Canbus : public Component { uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py bool use_extended_id_{false}; +#ifdef USE_CANBUS_FD + bool default_fd_{false}; + bool default_bit_rate_switch_{false}; + bool default_pad_data_{false}; + bool supports_fd_{false}; +#endif uint8_t events_to_log_{0}; bool bus_off_{false}; CallbackManager frame_callbacks_{}; @@ -178,6 +297,10 @@ class Canbus : public Component { #endif void log_events_(CanEventFlags events, uint32_t now); + /// Logs and sends a frame that is already valid. + canbus::Error send_frame_(const CanFrame &frame); + /// For a canbus.send lambda: a CAN FD frame that did not fit is refused, a classic one is cut to 8 bytes. + bool lambda_payload_fits_(const CanPayload &payload, [[maybe_unused]] bool fd); virtual bool setup_internal() = 0; virtual Error send_message(struct CanFrame *frame) = 0; virtual Error read_message(struct CanFrame *frame) = 0; @@ -186,6 +309,11 @@ class Canbus : public Component { template class CanbusSendAction final : public Action, public Parented { TEMPLATABLE_BUFFER(data, CanPayload) +#ifdef USE_CANBUS_FD + TEMPLATABLE_VALUE(bool, fd) + TEMPLATABLE_VALUE(bool, bit_rate_switch) + TEMPLATABLE_VALUE(bool, pad_data) +#endif void set_can_id(uint32_t can_id) { this->can_id_ = can_id; } @@ -199,11 +327,30 @@ template class CanbusSendAction final : public Action, pu auto can_id = this->can_id_.has_value() ? *this->can_id_ : this->parent_->can_id_; auto use_extended_id = this->use_extended_id_.has_value() ? *this->use_extended_id_ : this->parent_->use_extended_id_; - this->data_.template visit( - [&](const uint8_t *data, size_t len) { - this->parent_->send_data(can_id, use_extended_id, this->remote_transmission_request_, data, len); - }, - x...); +#ifdef USE_CANBUS_FD + const bool fd = this->fd_.value_or(x..., this->parent_->default_fd_); + const bool bit_rate_switch = this->bit_rate_switch_.value_or(x..., fd && this->parent_->default_bit_rate_switch_); + const bool pad_data = this->pad_data_.value_or(x..., this->parent_->default_pad_data_); +#endif +#ifndef USE_CANBUS_FD + const bool fd = false; +#endif + auto send = [&](const uint8_t *data, size_t len) { +#ifdef USE_CANBUS_FD + this->parent_->send_data(can_id, use_extended_id, this->remote_transmission_request_, data, len, fd, + bit_rate_switch, pad_data); +#else + this->parent_->send_data(can_id, use_extended_id, this->remote_transmission_request_, data, len); +#endif + }; + if (this->data_.is_static()) { + this->data_.template visit(send, x...); + return; + } + // The conversion from a lambda's vector cuts it to one frame, so the length it had is checked here + const CanPayload payload = this->data_.value(x...); + if (this->parent_->lambda_payload_fits_(payload, fd)) + send(payload.data(), payload.size()); } protected: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 50ad8eb2abe..1fae710743e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -66,6 +66,7 @@ #define USE_BTHOME_MITHERMOMETER_REPLAY_PROTECTION #define USE_BUTTON #define USE_CAMERA +#define USE_CANBUS_FD #define USE_CLIMATE #define USE_CLIMATE_VISUAL_OVERRIDES #define USE_CONTROLLER_REGISTRY diff --git a/tests/component_tests/canbus/test_canbus_payload_tables.py b/tests/component_tests/canbus/test_canbus_payload_tables.py index 958ec082e45..ab03e2d122d 100644 --- a/tests/component_tests/canbus/test_canbus_payload_tables.py +++ b/tests/component_tests/canbus/test_canbus_payload_tables.py @@ -1,13 +1,16 @@ -"""Tests for canbus constant payloads in shared PROGMEM tables.""" +"""Tests for canbus.send payloads and the CAN FD options.""" from collections.abc import Callable from pathlib import Path import re import pytest +import yaml -from esphome.components.canbus import CANBUS_SEND_ACTION_SCHEMA +from esphome.components import canbus +from esphome.config import read_config import esphome.config_validation as cv +from esphome.core import CORE def test_constant_payloads_share_progmem_tables( @@ -25,17 +28,103 @@ def test_constant_payloads_share_progmem_tables( ] assert main_cpp.count(f"set_data_static({tables[0][0]}, 8);") == 2 assert "set_data_template(" in main_cpp + for setter in ( + "set_default_fd", + "set_supports_fd", + "set_default_bit_rate_switch", + "set_fd", + "set_bit_rate_switch", + "set_pad_data", + ): + assert f"->{setter}(" not in main_cpp + assert "USE_CANBUS_FD" not in {define.name for define in CORE.defines} + + +def _read_send_config( + send: dict, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> tuple[bool, str]: + config = { + "esphome": { + "name": "test", + "on_boot": [{"then": [{"canbus.send": send}]}], + }, + "esp32": {"board": "esp32dev"}, + "canbus": [ + { + "platform": "esp32_can", + "id": "can_bus", + "can_id": 4, + "rx_pin": 4, + "tx_pin": 5, + } + ], + } + path = tmp_path / "test.yaml" + path.write_text(yaml.safe_dump(config), encoding="utf-8") + CORE.config_path = path + result = read_config({}) + return result is not None, capsys.readouterr().out @pytest.mark.parametrize("size", [8, 9]) @pytest.mark.parametrize("make", [lambda n: [0x01] * n, lambda n: "a" * n]) def test_send_one_frame_limit( - size: int, make: Callable[[int], list[int] | str] + size: int, + make: Callable[[int], list[int] | str], + tmp_path: Path, + capsys: pytest.CaptureFixture[str], ) -> None: - """Payloads up to one 8 byte CAN frame are accepted, longer ones rejected.""" - config = {"canbus_id": "can_bus", "data": make(size)} - if size <= 8: - assert len(CANBUS_SEND_ACTION_SCHEMA(config)["data"]) == size + """Final validation limits classic CAN payloads to eight bytes.""" + valid, output = _read_send_config({"data": make(size)}, tmp_path, capsys) + assert valid is (size <= 8), output + if size > 8: + assert "Classic CAN data must be at most 8 bytes" in output + assert "canbus.send" in output + + +def test_classic_bus_rejects_fd_options( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """esp32_can does not support CAN FD, so its sends cannot ask for it.""" + valid, output = _read_send_config({"data": [1], "fd": False}, tmp_path, capsys) + assert not valid + assert "fd needs a canbus platform that supports CAN FD" in output + + +def test_only_the_fd_schema_has_bus_defaults() -> None: + """Classic platforms keep the dev schema; CAN FD platforms extend CANBUS_FD_SCHEMA.""" + config = {"can_id": 4} + assert canbus.CONF_DEFAULT_FD not in canbus.CANBUS_SCHEMA(config) + fd_config = canbus.CANBUS_FD_SCHEMA(config) + assert fd_config[canbus.CONF_DEFAULT_FD] is False + assert fd_config[canbus.CONF_DEFAULT_PAD_DATA] is False + + +FD_BUS = { + "default_fd": True, + "default_bit_rate_switch": False, + "default_pad_data": False, +} + + +@pytest.mark.parametrize( + ("action", "bus", "error"), + [ + ({"data": [0] * 64}, FD_BUS, None), + ({"data": [0] * 13}, FD_BUS, "requires pad_data"), + ({"data": [0] * 13, "pad_data": True}, FD_BUS, None), + ({"data": [0] * 13}, {**FD_BUS, "default_pad_data": True}, None), + ({"data": [0] * 9, "fd": False}, FD_BUS, "at most 8 bytes"), + ({"data": [0], "fd": False, "bit_rate_switch": True}, FD_BUS, "requires fd"), + ({"data": [], "remote_transmission_request": True}, FD_BUS, "cannot be used"), + ({"data": [0] * 9}, {**FD_BUS, "default_fd": False}, "at most 8 bytes"), + ], +) +def test_fd_send_validation(action: dict, bus: dict, error: str | None) -> None: + """Bus defaults apply to sends that leave the option out.""" + action = {"remote_transmission_request": False, **action} + if error is None: + canbus.validate_send(action, bus) else: - with pytest.raises(cv.Invalid, match="at most 8"): - CANBUS_SEND_ACTION_SCHEMA(config) + with pytest.raises(cv.Invalid, match=error): + canbus.validate_send(action, bus) diff --git a/tests/components/canbus/fake_canbus.h b/tests/components/canbus/fake_canbus.h new file mode 100644 index 00000000000..f2c79661143 --- /dev/null +++ b/tests/components/canbus/fake_canbus.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include + +#include "esphome/components/canbus/canbus.h" + +namespace esphome::canbus::testing { + +inline std::vector payload_bytes(const CanFrame &frame) { return frame.payload(); } + +// Hands queued frames to the bus and records the frames it sends +class QueuedCanbus : public Canbus { + public: + void queue(uint32_t can_id, bool extended, std::initializer_list data) { + CanFrame frame{}; + frame.can_id = can_id; + frame.use_extended_id = extended; + frame.can_data_length_code = static_cast(data.size()); + std::copy(data.begin(), data.end(), frame.data); + this->rx_.push_back(frame); + } + void queue(const CanFrame &frame) { this->rx_.push_back(frame); } + + std::vector sent; + Error send_result{ERROR_OK}; + + protected: + bool setup_internal() override { return true; } + Error send_message(CanFrame *frame) override { + this->sent.push_back(*frame); + return this->send_result; + } + Error read_message(CanFrame *frame) override { + if (this->rx_.empty()) + return ERROR_NOMSG; + *frame = this->rx_.front(); + this->rx_.pop_front(); + return ERROR_OK; + } + + std::deque rx_; +}; + +} // namespace esphome::canbus::testing diff --git a/tests/components/canbus/fd_test.cpp b/tests/components/canbus/fd_test.cpp new file mode 100644 index 00000000000..89521a5c8fd --- /dev/null +++ b/tests/components/canbus/fd_test.cpp @@ -0,0 +1,254 @@ +// The rest of the test binary builds canbus without CAN FD, as every in-tree platform does. This file builds a +// second copy with USE_CANBUS_FD in its own namespace, so both frame sizes are tested in one binary. +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/optional.h" + +#define USE_CANBUS_FD +#define canbus canbus_fd // NOLINT(cppcoreguidelines-macro-usage) +#include "esphome/components/canbus/canbus.cpp" // NOLINT(bugprone-suspicious-include) +#include "fake_canbus.h" + +namespace esphome::canbus::testing { + +static_assert(sizeof(CanFrame::data) == CAN_FD_MAX_DATA_LENGTH); +static_assert(can_dlc_to_len(8) == 8); +static_assert(can_dlc_to_len(9) == 12); +static_assert(can_dlc_to_len(15) == 64); +static_assert(can_dlc_to_len(16) == CAN_INVALID_DLC); +static_assert(can_len_to_dlc(8, false) == 8); +static_assert(can_len_to_dlc(9, false) == CAN_INVALID_DLC); +static_assert(can_len_to_dlc(9, true) == 9); +static_assert(can_len_to_dlc(12, false) == 9); +static_assert(can_len_to_dlc(49, true) == 15); +static_assert(can_len_to_dlc(64, false) == 15); +static_assert(can_len_to_dlc(65, true) == CAN_INVALID_DLC); + +// A bus of a platform that supports CAN FD, as register_canbus() sets it up +class FdCanbus : public QueuedCanbus { + public: + FdCanbus() { this->set_supports_fd(true); } +}; + +TEST(CanbusFd, ClassicBraceInitializationStillWorks) { + const CanFrame frame{true, false, 0x1234567, 8, {0, 1, 2, 3, 4, 5, 6, 7}}; + EXPECT_EQ(frame.can_id, 0x1234567u); + EXPECT_FALSE(frame.fd); + EXPECT_FALSE(frame.bit_rate_switch); + EXPECT_EQ(payload_bytes(frame), (std::vector{0, 1, 2, 3, 4, 5, 6, 7})); +} + +TEST(CanbusFd, PayloadLengthFollowsTheFrameFormat) { + CanFrame frame{}; + frame.can_data_length_code = 15; + EXPECT_EQ(frame.payload_length(), 8); + frame.fd = true; + EXPECT_EQ(frame.payload_length(), 64); + frame.can_data_length_code = 16; + EXPECT_EQ(frame.payload_length(), 0); +} + +TEST(CanbusFd, FrameValidation) { + struct Case { + const char *name; + bool fd; + bool rtr; + bool brs; + uint8_t dlc; + Error expected; + }; + for (const auto &c : { + Case{"fd 64 bytes", true, false, true, 15, ERROR_OK}, + Case{"fd invalid dlc", true, false, false, 16, ERROR_FAILTX}, + Case{"fd remote request", true, true, false, 8, ERROR_FAILTX}, + Case{"brs without fd", false, false, true, 8, ERROR_FAILTX}, + Case{"classic", false, false, false, 8, ERROR_OK}, + }) { + FdCanbus bus; + CanFrame frame{}; + frame.can_id = 0x123; + frame.fd = c.fd; + frame.remote_transmission_request = c.rtr; + frame.bit_rate_switch = c.brs; + frame.can_data_length_code = c.dlc; + EXPECT_EQ(bus.send_data(frame), c.expected) << c.name; + EXPECT_EQ(bus.sent.size(), c.expected == ERROR_OK ? 1u : 0u) << c.name; + } +} + +TEST(CanbusFd, LengthBasedSend) { + struct Case { + bool fd; + bool pad; + size_t len; + uint8_t expected_dlc; // CAN_INVALID_DLC: not sent + }; + std::vector payload(80, 0xAB); + for (const auto &c : { + Case{true, false, 12, 9}, + Case{true, false, 13, CAN_INVALID_DLC}, + Case{true, true, 13, 10}, + Case{true, true, 64, 15}, + Case{true, true, 65, CAN_INVALID_DLC}, + Case{false, false, 13, 8}, // classic still cuts to 8 bytes + }) { + FdCanbus bus; + const Error result = bus.send_data(0x123, false, false, payload.data(), c.len, c.fd, false, c.pad); + if (c.expected_dlc == CAN_INVALID_DLC) { + EXPECT_EQ(result, ERROR_FAILTX) << c.len; + EXPECT_TRUE(bus.sent.empty()) << c.len; + continue; + } + ASSERT_EQ(bus.sent.size(), 1u) << c.len; + const CanFrame &sent = bus.sent[0]; + EXPECT_EQ(sent.fd, c.fd); + EXPECT_EQ(sent.can_data_length_code, c.expected_dlc) << c.len; + const size_t copied = std::min(c.len, sent.payload_length()); + std::vector expected(sent.payload_length(), 0); + std::fill_n(expected.begin(), copied, 0xAB); + EXPECT_EQ(payload_bytes(sent), expected) << c.len; + } +} + +TEST(CanbusFd, ClassicBusInAnFdBuildRefusesFdFrames) { + FdCanbus fd_bus; + QueuedCanbus classic_bus; + EXPECT_TRUE(fd_bus.supports_fd()); + EXPECT_FALSE(classic_bus.supports_fd()); + static constexpr uint8_t DATA[8] = {1, 2, 3, 4, 5, 6, 7, 8}; + for (QueuedCanbus *bus : {static_cast(&fd_bus), &classic_bus}) { + const Error expected = bus->supports_fd() ? ERROR_OK : ERROR_FAILTX; + EXPECT_EQ(bus->send_data(0x123, false, false, DATA, sizeof(DATA), true), expected); + EXPECT_EQ(bus->send_data(0x123, false, false, DATA, sizeof(DATA)), ERROR_OK); + ASSERT_EQ(bus->sent.size(), bus->supports_fd() ? 2u : 1u); + EXPECT_FALSE(bus->sent.back().fd); + } +} + +TEST(CanbusFd, ClassicDriverAfterFdFrameSeesClearedFlags) { + // Writes only the classic fields, as esp32_can and mcp2515 do + class MixedCanbus : public QueuedCanbus { + protected: + Error read_message(CanFrame *frame) override { + if (this->reads_ == 2) + return ERROR_NOMSG; + frame->can_id = 0x123; + frame->can_data_length_code = 15; + std::fill_n(frame->data, CAN_FRAME_DATA_LENGTH, 0xAB); + if (this->reads_++ == 0) { + frame->fd = true; + frame->bit_rate_switch = true; + frame->error_state_indicator = true; + } + return ERROR_OK; + } + uint8_t reads_{0}; + }; + MixedCanbus bus; + std::vector> received; + bus.add_frame_callback([&](const CanFrame &frame) { + EXPECT_EQ(frame.bit_rate_switch, frame.fd); + EXPECT_EQ(frame.error_state_indicator, frame.fd); + received.emplace_back(frame.fd, frame.payload().size()); + }); + bus.loop(); + EXPECT_EQ(received, (std::vector>{{true, 64}, {false, 8}})); +} + +static bool yes() { return true; } +static bool no() { return false; } + +static CanPayload sixty_four_bytes() { return std::vector(64, 0xAB); } +static CanPayload sixty_five_bytes() { return std::vector(65, 0xAB); } +// Built from a range rather than a vector, so it goes through another constructor +static CanPayload sixty_five_byte_range() { + static constexpr uint8_t DATA[65] = {}; + return CanPayload(std::begin(DATA), std::end(DATA)); +} + +TEST(CanbusFd, ActionRefusesAnFdLambdaPayloadThatDoesNotFit) { + struct Case { + CanPayload (*data)(); + bool expect_sent; + }; + for (const auto &c : + {Case{sixty_four_bytes, true}, Case{sixty_five_bytes, false}, Case{sixty_five_byte_range, false}}) { + FdCanbus bus; + bus.set_can_id(0x100); + bus.set_default_fd(true); + CanbusSendAction<> action; + action.set_parent(&bus); + action.set_data_template(c.data); + action.play(); + ASSERT_EQ(bus.sent.size(), c.expect_sent ? 1u : 0u); + if (c.expect_sent) + EXPECT_EQ(bus.sent[0].payload_length(), 64); + } +} + +TEST(CanbusFd, ActionDefaultsAndOverrides) { + using Flag = bool (*)(); + struct Case { + const char *name; + bool default_fd; + bool default_brs; + bool default_pad; + Flag fd; + Flag brs; + Flag pad; + size_t len; + bool expect_sent; + bool expect_fd; + bool expect_brs; + uint8_t expect_dlc; + }; + static constexpr uint8_t DATA[13] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}; + for (const auto &c : { + Case{"classic by default", false, true, false, nullptr, nullptr, nullptr, 8, true, false, false, 8}, + Case{"bus default fd and brs", true, true, false, nullptr, nullptr, nullptr, 12, true, true, true, 9}, + Case{"13 bytes need padding", true, false, false, nullptr, nullptr, nullptr, 13, false, false, false, 0}, + Case{"bus default padding", true, false, true, nullptr, nullptr, nullptr, 13, true, true, false, 10}, + Case{"action padding", true, false, false, nullptr, nullptr, yes, 13, true, true, false, 10}, + Case{"action turns fd off", true, true, false, no, nullptr, nullptr, 8, true, false, false, 8}, + Case{"brs without fd", false, false, false, nullptr, yes, nullptr, 8, false, false, false, 0}, + Case{"action fd without brs", false, true, false, yes, no, nullptr, 8, true, true, false, 8}, + }) { + FdCanbus bus; + bus.set_can_id(0x100); + bus.set_default_fd(c.default_fd); + bus.set_default_bit_rate_switch(c.default_brs); + bus.set_default_pad_data(c.default_pad); + CanbusSendAction<> action; + action.set_parent(&bus); + action.set_data_static(DATA, c.len); + if (c.fd != nullptr) + action.set_fd(c.fd); + if (c.brs != nullptr) + action.set_bit_rate_switch(c.brs); + if (c.pad != nullptr) + action.set_pad_data(c.pad); + action.play(); + ASSERT_EQ(bus.sent.size(), c.expect_sent ? 1u : 0u) << c.name; + if (!c.expect_sent) + continue; + EXPECT_EQ(bus.sent[0].fd, c.expect_fd) << c.name; + EXPECT_EQ(bus.sent[0].bit_rate_switch, c.expect_brs) << c.name; + EXPECT_EQ(bus.sent[0].can_data_length_code, c.expect_dlc) << c.name; + } +} + +} // namespace esphome::canbus::testing + +#undef canbus diff --git a/tests/components/canbus/frame_callback_test.cpp b/tests/components/canbus/frame_callback_test.cpp index 3efd426a5d5..1f4740cf418 100644 --- a/tests/components/canbus/frame_callback_test.cpp +++ b/tests/components/canbus/frame_callback_test.cpp @@ -3,12 +3,11 @@ #include #include #include -#include #include #include #include -#include "esphome/components/canbus/canbus.h" +#include "fake_canbus.h" // Needs AddressSanitizer's malloc hooks #ifndef __has_feature @@ -22,32 +21,6 @@ extern "C" int __sanitizer_install_malloc_and_free_hooks(void (*malloc_hook)(con namespace esphome::canbus::testing { -// Receives queued frames -class QueuedCanbus : public Canbus { - public: - void queue(uint32_t can_id, bool extended, std::initializer_list data) { - CanFrame frame{}; - frame.can_id = can_id; - frame.use_extended_id = extended; - frame.can_data_length_code = static_cast(data.size()); - std::copy(data.begin(), data.end(), frame.data); - this->rx_.push_back(frame); - } - - protected: - bool setup_internal() override { return true; } - Error send_message(CanFrame *frame) override { return ERROR_OK; } - Error read_message(CanFrame *frame) override { - if (this->rx_.empty()) - return ERROR_NOMSG; - *frame = this->rx_.front(); - this->rx_.pop_front(); - return ERROR_OK; - } - - std::deque rx_; -}; - TEST(CanbusFrameCallback, PassesEveryFrameAndItsPayload) { using Received = std::vector>>; QueuedCanbus bus; diff --git a/tests/components/canbus/send_test.cpp b/tests/components/canbus/send_test.cpp new file mode 100644 index 00000000000..ac1a8014c09 --- /dev/null +++ b/tests/components/canbus/send_test.cpp @@ -0,0 +1,136 @@ +#include + +#include +#include +#include + +#include "fake_canbus.h" + +namespace esphome::canbus::testing { + +// Frames stay as large as on dev, and keep their field order for brace initialization. clang-tidy builds with +// defines.h, which turns CAN FD on. +#ifndef USE_CANBUS_FD +static_assert(sizeof(CanFrame::data) == CAN_MAX_DATA_LENGTH); +static_assert(sizeof(CanFrame) == 24); +static_assert(can_dlc_to_len(9) == 8); +static_assert(can_dlc_to_len(15) == 8); +#endif +static_assert(can_dlc_to_len(0) == 0); +static_assert(can_dlc_to_len(8) == 8); + +TEST(CanbusSend, ClassicFrameBraceInitialization) { + const CanFrame frame{true, true, 0x1234567, 8, {0, 1, 2, 3, 4, 5, 6, 7}}; + EXPECT_TRUE(frame.use_extended_id); + EXPECT_TRUE(frame.remote_transmission_request); + EXPECT_EQ(frame.can_id, 0x1234567u); + EXPECT_EQ(payload_bytes(frame), (std::vector{0, 1, 2, 3, 4, 5, 6, 7})); +} + +TEST(CanbusSend, LengthIsClampedToOneFrame) { + const std::vector payload{1, 2, 3, 4, 5, 6, 7, 8, 9}; + for (size_t len : {size_t{0}, size_t{8}, size_t{9}, size_t{256}}) { + QueuedCanbus bus; + const size_t sent_len = std::min(len, CAN_MAX_DATA_LENGTH); + EXPECT_EQ(bus.send_data(0x123, false, false, len == 0 ? nullptr : payload.data(), len), ERROR_OK); + ASSERT_EQ(bus.sent.size(), 1u) << len; + EXPECT_EQ(bus.sent[0].can_data_length_code, sent_len); + EXPECT_EQ(payload_bytes(bus.sent[0]), std::vector(payload.begin(), payload.begin() + sent_len)); + } +} + +TEST(CanbusSend, RemoteRequestDoesNotReadData) { + QueuedCanbus bus; + EXPECT_EQ(bus.send_data(0x123, false, true, nullptr, 8), ERROR_OK); + ASSERT_EQ(bus.sent.size(), 1u); + EXPECT_TRUE(bus.sent[0].remote_transmission_request); + EXPECT_EQ(bus.sent[0].can_data_length_code, 8); + // Zeroed, not left from the stack, since drivers and the verbose log still read them + EXPECT_EQ(payload_bytes(bus.sent[0]), std::vector(8, 0)); +} + +TEST(CanbusSend, FrameIdentifierMustFitItsWidth) { + struct Case { + bool extended; + uint32_t can_id; + Error expected; + }; + for (const auto &c : {Case{false, CAN_STANDARD_ID_MAX, ERROR_OK}, Case{false, CAN_STANDARD_ID_MAX + 1, ERROR_FAILTX}, + Case{true, CAN_EXTENDED_ID_MAX, ERROR_OK}, Case{true, CAN_EXTENDED_ID_MAX + 1, ERROR_FAILTX}}) { + QueuedCanbus bus; + CanFrame frame{}; + frame.use_extended_id = c.extended; + frame.can_id = c.can_id; + EXPECT_EQ(bus.send_data(frame), c.expected) << c.can_id; + EXPECT_EQ(bus.sent.size(), c.expected == ERROR_OK ? 1u : 0u) << c.can_id; + } +} + +TEST(CanbusSend, DriverErrorIsReturned) { + QueuedCanbus bus; + bus.send_result = ERROR_ALLTXBUSY; + EXPECT_EQ(bus.send_data(0x123, false, std::vector{1}), ERROR_ALLTXBUSY); +} + +static CanPayload three_bytes() { return {1, 2, 3}; } +static CanPayload too_long() { return std::vector(20, 0xAB); } + +TEST(CanbusSend, EveryPayloadConstructorRecordsATruncation) { + EXPECT_FALSE(CanPayload({1, 2, 3, 4, 5, 6, 7, 8}).truncated()); + const CanPayload braces{1, 2, 3, 4, 5, 6, 7, 8, 9}; + EXPECT_TRUE(braces.truncated()); + EXPECT_EQ(braces.size(), 8u); + static constexpr uint8_t DATA[9] = {}; + EXPECT_TRUE(CanPayload(std::begin(DATA), std::end(DATA)).truncated()); + EXPECT_TRUE(CanPayload(std::vector(9, 0)).truncated()); + EXPECT_FALSE(CanPayload(std::vector(8, 0)).truncated()); +} + +TEST(CanbusSendAction, UsesTheBusOrItsOwnIdentifier) { + struct Case { + const char *name; + bool own_id; + bool extended; + bool rtr; + CanPayload (*data)(); + uint32_t expected_id; + uint8_t expected_dlc; + }; + for (const auto &c : { + Case{"bus id", false, false, false, three_bytes, 0x100, 3}, + Case{"own extended id", true, true, false, three_bytes, 0x1ABCDE, 3}, + Case{"remote request", true, false, true, three_bytes, 0x1ABCDE, 3}, + Case{"lambda over 8 bytes", false, false, false, too_long, 0x100, 8}, + }) { + QueuedCanbus bus; + bus.set_can_id(0x100); + CanbusSendAction<> action; + action.set_parent(&bus); + if (c.own_id) { + action.set_can_id(0x1ABCDE); + action.set_use_extended_id(c.extended); + } + action.set_remote_transmission_request(c.rtr); + action.set_data_template(c.data); + action.play(); + ASSERT_EQ(bus.sent.size(), 1u) << c.name; + EXPECT_EQ(bus.sent[0].can_id, c.expected_id) << c.name; + EXPECT_EQ(bus.sent[0].use_extended_id, c.extended) << c.name; + EXPECT_EQ(bus.sent[0].remote_transmission_request, c.rtr) << c.name; + EXPECT_EQ(bus.sent[0].can_data_length_code, c.expected_dlc) << c.name; + } +} + +TEST(CanbusSendAction, StaticPayload) { + static constexpr uint8_t DATA[] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}; + QueuedCanbus bus; + bus.set_can_id(0x100); + CanbusSendAction<> action; + action.set_parent(&bus); + action.set_data_static(DATA, sizeof(DATA)); + action.play(); + ASSERT_EQ(bus.sent.size(), 1u); + EXPECT_EQ(payload_bytes(bus.sent[0]), (std::vector{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88})); +} + +} // namespace esphome::canbus::testing From c390a46f1cc1e890e511c425626465b8a6e3b9cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 17:40:01 -0600 Subject: [PATCH 37/55] [core] Keep a trigger's only extra key in the language schema (#21801) --- script/build_language_schema.py | 8 ++------ tests/script/test_build_language_schema.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index d18052d4f52..2c475450bcf 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1009,13 +1009,9 @@ def convert(schema, config_var, path): config_var.update(convert_config(data[0], path + "/maybe")) # esphome/on_boot elif schema_type == "automation": - extra_schema = None config_var[S_TYPE] = "trigger" - if ejs.extended_schemas[repr(data)][0] == automation.AUTOMATION_SCHEMA: - extra_schema = ejs.extended_schemas[repr(data)][1] - if ( - extra_schema is not None and len(extra_schema) > 1 - ): # usually only trigger_id here + entry = ejs.extended_schemas[repr(data)] + if entry[0] == automation.AUTOMATION_SCHEMA and (extra_schema := entry[1]): config = convert_config(extra_schema, path + "/extra") if "schema" in config: automation_schema = config["schema"] diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 0769f5c11b9..670c96f4714 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -354,6 +354,17 @@ def test_uart_clock_source_preserves_variant_metadata( assert "ESP32C6" not in clock_source["values"]["APB"]["variants"] +def test_automation_with_one_extra_key_keeps_it(language_schema_dir: Path) -> None: + """A trigger whose only extra key is not trigger_id still lists that key.""" + modbus_schema = json.loads((language_schema_dir / "modbus_client.json").read_text()) + on_no_response = modbus_schema["modbus_client"]["schemas"]["_ACTION_BASE_SCHEMA"][ + "schema" + ]["config_vars"]["on_no_response"] + + assert on_no_response["type"] == "trigger" + assert set(on_no_response["schema"]["config_vars"]) == {"retry", "then"} + + # --------------------------------------------------------------------------- # Regression tests for the lvgl schema dump. # From 549b56197f6e88b86db1368e40dfc5154cdef5b3 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:38:46 -0600 Subject: [PATCH 38/55] Bump aioesphomeapi from 46.26.0 to 46.27.0 (#22279) Co-authored-by: J. Nick Koston --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6254446ca4a..39e89f77d5f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.6.0 click==8.5.0 -aioesphomeapi==46.26.0 +aioesphomeapi==46.27.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.153.0 puremagic==2.2.0 From 9b24d1afdaf5ff601bbb4631731d6e96c51613a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 18:55:07 -0600 Subject: [PATCH 39/55] [libretiny] Record abort() as a crash and reboot (#22288) --- esphome/components/libretiny/__init__.py | 1 + .../components/libretiny/crash_handler.cpp | 74 ++++++++++++++----- .../libretiny/test_crash_handler.py | 2 + 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index e8ab39954b8..d31212e1942 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -568,6 +568,7 @@ async def component_to_code(config): # Crash handler (crash_handler.cpp) cg.add_define("USE_LIBRETINY_CRASH_HANDLER") cg.add_define("USE_CRASH_HANDLER") + cg.add_build_flag("-Wl,--wrap=abort") if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238): for trap in BK_TRAP_HANDLERS: cg.add_build_flag(f"-Wl,--wrap={trap}") diff --git a/esphome/components/libretiny/crash_handler.cpp b/esphome/components/libretiny/crash_handler.cpp index 77b4a7e57ba..6616caaa8e2 100644 --- a/esphome/components/libretiny/crash_handler.cpp +++ b/esphome/components/libretiny/crash_handler.cpp @@ -10,6 +10,7 @@ #endif #include "core.h" +#include #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -96,8 +97,10 @@ static constexpr uint32_t CRASH_REASON_PREFETCH_ABORT = 2; static constexpr uint32_t CRASH_REASON_DATA_ABORT = 3; // Cortex-M families: the exception number (3 to 7) plus this offset. static constexpr uint32_t CRASH_REASON_CORTEX_M_OFFSET = 1; +// After the Cortex-M ones (4 to 8). +static constexpr uint32_t CRASH_REASON_ABORT = 9; PROGMEM_STRING_TABLE(CrashReasonStrings, "Unknown", "Undefined instruction", "Prefetch abort", "Data abort", - "Hard fault", "MemManage fault", "Bus fault", "Usage fault", "Secure fault"); + "Hard fault", "MemManage fault", "Bus fault", "Usage fault", "Secure fault", "Abort"); static constexpr size_t MAX_BACKTRACE = 8; // Words scanned above sp for return addresses. @@ -155,7 +158,10 @@ void crash_handler_log() { ESP_LOGE(TAG, " CFSR: 0x%08" PRIX32 " Address: 0x%08" PRIX32, s_record.fault_status, s_record.fault_address); } ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_record.pc); - ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_record.lr); + // 0 for abort(), and for a Cortex-M frame outside SRAM + if (s_record.lr != 0) { + ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_record.lr); + } ESP_LOGE(TAG, " SP: 0x%08" PRIX32, s_record.sp); uint8_t count = 0; for (; count < MAX_BACKTRACE && s_record.backtrace[count] != 0; count++) { @@ -163,8 +169,9 @@ void crash_handler_log() { } // addr2line hint with every address char hint[160]; - int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e raw_firmware.elf 0x%08" PRIX32 " 0x%08" PRIX32, - s_record.pc, s_record.lr); + int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e raw_firmware.elf 0x%08" PRIX32, s_record.pc); + if (s_record.lr != 0) + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_record.lr); for (uint8_t i = 0; i < count && pos < (int) sizeof(hint) - 12; i++) { pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_record.backtrace[i]); } @@ -190,6 +197,16 @@ static inline bool is_code_addr(uint32_t val) { #endif } +static void fill_record(uint32_t reason, uint32_t pc, uint32_t lr, uint32_t sp, uint32_t fault_status, + uint32_t fault_address) { + s_record.reason = reason; + s_record.pc = pc; + s_record.lr = lr; + s_record.sp = sp; + s_record.fault_status = fault_status; + s_record.fault_address = fault_address; +} + // Return addresses above sp give the backtrace; then the magic marks the record complete. static void finish_record(uint32_t sp, uint32_t sram_start, uint32_t sram_end) { uint32_t count = 0; @@ -264,12 +281,7 @@ static void record_crash(arm_registers *regs, uint32_t reason, uint32_t pc_offse uint32_t lr = 0; read_saved_sp_lr(mode, &sp, &lr); - s_record.reason = reason; - s_record.pc = regs->pc - pc_offset; - s_record.lr = lr; - s_record.sp = sp; - s_record.fault_status = 0; - s_record.fault_address = 0; + fill_record(reason, regs->pc - pc_offset, lr, sp, 0, 0); finish_record(sp, SRAM_START, SRAM_END); lt_wdt_enable(CRASH_REBOOT_WDT_MS); @@ -366,22 +378,17 @@ extern "C" __attribute__((used)) uint32_t esphome_record_fault(const uint32_t *f } else if (cfsr & CFSR_MMARVALID) { address = read_reg(SCB_MMFAR); } - s_record.reason = vector + CRASH_REASON_CORTEX_M_OFFSET; - s_record.fault_status = cfsr; - s_record.fault_address = address; - auto frame_addr = (uint32_t) frame; uint32_t frame_words = (exc_return & EXC_RETURN_STANDARD_FRAME) ? FRAME_WORDS : EXTENDED_FRAME_WORDS; + uint32_t pc = 0; + uint32_t lr = 0; uint32_t sp = 0; if (frame_addr >= SRAM_START && frame_addr <= SRAM_END - frame_words * 4 && (frame_addr & 3) == 0) { - s_record.pc = frame[FRAME_PC]; - s_record.lr = frame[FRAME_LR]; + pc = frame[FRAME_PC]; + lr = frame[FRAME_LR]; sp = frame_addr + frame_words * 4 + ((frame[FRAME_XPSR] & XPSR_STACK_ALIGNED) ? 4 : 0); - } else { - s_record.pc = 0; - s_record.lr = 0; } - s_record.sp = sp; + fill_record(vector + CRASH_REASON_CORTEX_M_OFFSET, pc, lr, sp, cfsr, address); finish_record(sp, SRAM_START, SRAM_END); return s_sdk_fault_vectors[vector - FIRST_FAULT_VECTOR]; } @@ -416,6 +423,33 @@ void crash_handler_install() { #endif // USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH +// newlib's abort() spins in _exit() with no record and, on the BK7238, no watchdog reset. Interrupts go off first +// because every task's delay() feeds the watchdog. +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" [[noreturn]] void __wrap_abort() { + portDISABLE_INTERRUPTS(); + lt_wdt_enable(CRASH_REBOOT_WDT_MS); + uint32_t sp; + __asm volatile("mov %0, sp" : "=r"(sp)); + // Inside the bl that called abort(): a noreturn call can be the caller's last instruction, so the return + // address may already be the next function. Bit 0 is the Thumb bit. + fill_record(CRASH_REASON_ABORT, ((uint32_t) __builtin_return_address(0) & ~1u) - 2, 0, sp, 0, 0); + // Valid without a backtrace, so a fault in the stack scan still leaves this record; every entry is cleared, so + // a scan cut short leaves none from an earlier crash + for (auto &addr : s_record.backtrace) + addr = 0; + s_record.magic = CRASH_MAGIC_V2; + // Keeps those writes ahead of the scan's reads + __asm volatile("" ::: "memory"); +#ifdef USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH + // A fault in the scan then spins for the watchdog instead of replacing this record or running the SDK dump + s_fault_recorded = true; +#endif + finish_record(sp, SRAM_START, SRAM_END); + while (true) { + } +} + } // namespace esphome::libretiny #endif // USE_LIBRETINY_CRASH_HANDLER diff --git a/tests/component_tests/libretiny/test_crash_handler.py b/tests/component_tests/libretiny/test_crash_handler.py index 050f80f80d1..0837d75bbd1 100644 --- a/tests/component_tests/libretiny/test_crash_handler.py +++ b/tests/component_tests/libretiny/test_crash_handler.py @@ -33,6 +33,8 @@ def test_crash_handler_family_gate( defines = {define.name for define in CORE.defines} assert ("USE_LIBRETINY_CRASH_HANDLER" in defines) is enabled assert ("USE_CRASH_HANDLER" in defines) is enabled + # __wrap_abort is only built with the crash handler; a leaked flag fails the link + assert ("-Wl,--wrap=abort" in CORE.build_flags) is enabled for trap in BK_TRAP_HANDLERS: assert (f"-Wl,--wrap={trap}" in CORE.build_flags) is bk_hooks assert ( From 765ca4a0855373fe1a3eae18c110cbb6186b0c95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 19:19:26 -0600 Subject: [PATCH 40/55] [schema] Keep sibling extends when shrink inlines one schema (#22287) --- esphome/components/gree/switch/__init__.py | 4 + script/build_language_schema.py | 149 ++++++++--- tests/script/test_build_language_schema.py | 278 ++++++++++++++++++++- 3 files changed, 391 insertions(+), 40 deletions(-) diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py index 4f83f31092d..2f4e716cb21 100644 --- a/esphome/components/gree/switch/__init__.py +++ b/esphome/components/gree/switch/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from .. import gree_ns @@ -82,7 +83,10 @@ def _feature_switch_schema( entity_category=ENTITY_CATEGORY_CONFIG, ) + @schema_extractor("schema") def validator(value: ConfigType) -> ConfigType: + if value is SCHEMA_EXTRACT: + return schema explicit = isinstance(value, dict) and any( key in value for key in _RESTORE_KEYS ) diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 2c475450bcf..46dba7e305d 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import argparse +import copy import inspect import json import os @@ -561,34 +562,77 @@ def get_arr_path_schema(path): return s -def merge(source, destination): - """ - run me with nosetests --with-doctest file.py - - >>> a = { 'first' : { 'all_rows' : { 'pass' : 'dog', 'number' : '1' } } } - >>> b = { 'first' : { 'all_rows' : { 'fail' : 'cat', 'number' : '5' } } } - >>> merge(b, a) == { 'first' : { 'all_rows' : { 'pass' : 'dog', 'fail' : 'cat', 'number' : '5' } } } - True - """ - for key, value in source.items(): - if isinstance(value, dict): - # get node or create one - node = destination.setdefault(key, {}) - merge(value, node) - elif key == "constraints" and isinstance(value, list): - destination[key] = value + destination.get(key, []) - else: - destination[key] = value - - return destination - - def inline_schema(node: dict, base: dict) -> None: - """Copy *base* into *node*, keeping the key constraints of both.""" + """Copy *base* into *node*, keeping the key constraints and config vars of both.""" local = node.get("constraints", []) + local_vars = node.get(S_CONFIG_VARS) node |= base if local: node["constraints"] = base.get("constraints", []) + local + if local_vars: + node[S_CONFIG_VARS] = copy.deepcopy( + overlay_config_vars(local_vars, base.get(S_CONFIG_VARS, {})) + ) + + +def overlay_field(local: dict, base: dict) -> dict: + """Overlay the field descriptor *local* on *base*, the one it inherits. + + Only the schema structure recurses (a nested ``schema`` and its ``config_vars``, + whose key constraints are kept from both); any other value *local* sets, such + as a narrowed ``values`` map or its own ``id_type``, replaces the base's. + """ + merged = base | local + local_schema, base_schema = local.get(S_SCHEMA), base.get(S_SCHEMA) + if isinstance(local_schema, dict) and isinstance(base_schema, dict): + schema = base_schema | local_schema + if S_CONFIG_VARS in local_schema and S_CONFIG_VARS in base_schema: + schema[S_CONFIG_VARS] = overlay_config_vars( + local_schema[S_CONFIG_VARS], base_schema[S_CONFIG_VARS] + ) + if "constraints" in local_schema and "constraints" in base_schema: + schema["constraints"] = ( + base_schema["constraints"] + local_schema["constraints"] + ) + merged[S_SCHEMA] = schema + return merged + + +def overlay_config_vars(local: dict, base: dict) -> dict: + """Overlay *local* field descriptors on *base*'s, one field at a time.""" + return base | { + name: overlay_field(field, base[name]) + if isinstance(base.get(name), dict) + else field + for name, field in local.items() + } + + +def splice_extends(node: dict, extends: list, name: str) -> None: + """Set *node*'s extends to *extends*, with *name* replaced by the extends *node* inherited from it. + + >>> node = {"extends": ["d", "a"]} + >>> splice_extends(node, ["a", "b", "c"], "b") + >>> node + {'extends': ['a', 'd', 'c']} + """ + inherited = node.pop(S_EXTENDS, []) + idx = extends.index(name) + added = [e for e in inherited if e not in extends] + if spliced := extends[:idx] + added + extends[idx + 1 :]: + node[S_EXTENDS] = spliced + + +SIMPLE_TYPES = ("enum", "registry", "integer", "string") + + +def drop_extend(node: dict, name: str) -> None: + """Remove *name* from *node*'s extends, dropping the key when it empties.""" + # Rebuilt, not mutated: inlined nodes can share one extends list + if extends := [e for e in node[S_EXTENDS] if e != name]: + node[S_EXTENDS] = extends + else: + node.pop(S_EXTENDS) def is_platform_schema(schema_name): @@ -631,19 +675,30 @@ def shrink(): # key_s.pop(S_EXTENDS) pass_again = True if S_SCHEMA in arr_s: - if S_EXTENDS in arr_s[S_SCHEMA]: - arr_s[S_SCHEMA].pop(S_EXTENDS) - else: + extends = arr_s[S_SCHEMA].pop(S_EXTENDS, None) + if extends is None: print("expected extends here!" + x) - arr_s = merge(key_s, arr_s) + merged = overlay_field(arr_s, key_s) + # The inlined schema decides the type (an enum or typed collapse) + merged[S_TYPE] = key_s.get(S_TYPE, merged.get(S_TYPE)) + arr_s.clear() + arr_s.update(copy.deepcopy(merged)) + if extends: + splice_extends(arr_s[S_SCHEMA], extends, x) # Only key constraints survive the collapse - if arr_s[S_TYPE] in ["enum", "typed"] and ( - constraints := arr_s.pop(S_SCHEMA).get("constraints") - ): - arr_s[S_SCHEMA] = {"constraints": constraints} + if arr_s[S_TYPE] in ["enum", "typed"]: + collapsed = arr_s.pop(S_SCHEMA) + if siblings := collapsed.get(S_EXTENDS): + print( + f"dropped {siblings} beside sibling extends at " + + ".".join(paths[0]) + ) + if constraints := collapsed.get("constraints"): + arr_s[S_SCHEMA] = {"constraints": constraints} else: - arr_s.pop(S_EXTENDS) + extends = arr_s.pop(S_EXTENDS) inline_schema(arr_s, key_s[S_SCHEMA]) + splice_extends(arr_s, extends, x) # simple types should be spread on each component, # for enums so far these are logger.is_log_level, cover.validate_cover_state and pulse_counter.sensor.COUNT_MODE_SCHEMA @@ -651,7 +706,7 @@ def shrink(): # then are all simple types, integer and strings for x, paths in referenced_schemas.items(): key_s = get_str_path_schema(x) - if key_s and key_s.get(S_TYPE) in ["enum", "registry", "integer", "string"]: + if key_s and key_s.get(S_TYPE) in SIMPLE_TYPES: if key_s[S_TYPE] == "registry": print("Spreading registry: " + x) for target in paths: @@ -659,7 +714,23 @@ def shrink(): if S_SCHEMA not in target_s: print("skipping simple spread for " + ".".join(target)) continue - assert target_s[S_SCHEMA][S_EXTENDS] == [x] + extends = target_s[S_SCHEMA][S_EXTENDS] + assert x in extends + if len(extends) > 1: + # Spliced beside sibling extends (cv.Any(A, B)): keep the siblings + drop_extend(target_s[S_SCHEMA], x) + if any( + get_str_path_schema(e).get(S_TYPE) in SIMPLE_TYPES + for e in target_s[S_SCHEMA].get(S_EXTENDS, []) + ): + continue + if key_s[S_TYPE] == "enum" and "values" not in target_s: + target_s["values"] = key_s["values"] + else: + print( + f"dropped {x} beside sibling extends at " + ".".join(target) + ) + continue target_s.pop(S_SCHEMA) target_s |= key_s if key_s[S_TYPE] in ["integer", "string"]: @@ -671,9 +742,15 @@ def shrink(): target_s = get_arr_path_schema(target) if S_SCHEMA not in target_s: # an empty schema like speaker.SPEAKER_SCHEMA - target_s[S_EXTENDS].remove(x) + drop_extend(target_s, x) + continue + target_schema = target_s[S_SCHEMA] + extends = target_schema[S_EXTENDS] + assert x in extends + if target_schema.get(S_CONFIG_VARS) or len(extends) > 1: + # A bare validator beside real keys (cv.All(SCHEMA, check)) adds nothing + drop_extend(target_schema, x) continue - assert x in target_s[S_SCHEMA][S_EXTENDS] target_s.pop(S_SCHEMA) target_s.pop(S_TYPE) # undefined target_s["data_type"] = x.split(".")[1] diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index 670c96f4714..6c45a358318 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -188,18 +188,21 @@ def test_convert_emits_key_constraints() -> None: ] -def test_merge_keeps_base_and_local_constraints() -> None: +def test_overlay_field_keeps_base_and_local_constraints() -> None: base = {"schema": {"constraints": [{"type": "key_requires", "keys": ["a"]}]}} local = {"schema": {"constraints": [{"type": "only_when", "keys": ["b"]}]}} - assert _bls.merge(base, local)["schema"]["constraints"] == [ + assert _bls.overlay_field(local, base)["schema"]["constraints"] == [ {"type": "key_requires", "keys": ["a"]}, {"type": "only_when", "keys": ["b"]}, ] -def test_merge_enum_option_named_constraints() -> None: +def test_overlay_field_enum_option_named_constraints() -> None: base = {"values": {"constraints": None}} - assert _bls.merge(base, {"values": {}}) == {"values": {"constraints": None}} + assert _bls.overlay_field({"key": "Optional"}, base) == { + "key": "Optional", + "values": {"constraints": None}, + } def test_inline_schema_keeps_base_and_local_constraints() -> None: @@ -472,3 +475,270 @@ def test_convert_typed_schema_with_an_empty_type( assert config_var["typed_key"] == "mode" assert "byte" in config_var["types"]["with_keys"]["config_vars"] assert config_var["types"]["no_keys"] == {"config_vars": {}} + + +def _schema(config_vars: dict | None = None, extends: list | None = None) -> dict: + schema: dict = {} + if config_vars is not None: + schema["config_vars"] = config_vars + if extends is not None: + schema["extends"] = extends + return {"type": "schema", "schema": schema} + + +def test_splice_extends_drops_the_key_when_nothing_remains() -> None: + node: dict = {"extends": []} + _bls.splice_extends(node, ["b"], "b") + assert node == {} + + +def _shrink(monkeypatch: pytest.MonkeyPatch, output: dict) -> None: + monkeypatch.setattr(_bls, "output", output) + monkeypatch.setattr(_bls, "components", {}) + _bls.shrink() + + +def test_shrink_inlining_one_extend_keeps_the_others( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Inlining a once-referenced schema keeps the target's other extends.""" + output = { + "base": {"schemas": {"SHARED": _schema({"name": {"key": "Optional"}})}}, + "mod": { + "schemas": { + "ONCE": _schema({"extra": {"key": "Optional"}}), + "CONFIG_SCHEMA": _schema({}, ["base.SHARED", "mod.ONCE"]), + } + }, + "other": {"schemas": {"CONFIG_SCHEMA": _schema({}, ["base.SHARED"])}}, + } + _shrink(monkeypatch, output) + + schema = output["mod"]["schemas"]["CONFIG_SCHEMA"]["schema"] + assert schema["extends"] == ["base.SHARED"] + assert "extra" in schema["config_vars"] + + +def test_shrink_drops_a_bare_validator_beside_config_vars( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An empty extend next to real keys (cv.All(SCHEMA, check)) is dropped, not collapsed.""" + output = { + "mod": { + "schemas": { + "check": {}, + "CONFIG_SCHEMA": _schema( + {"address": {"key": "Required"}}, ["mod.check"] + ), + } + }, + "other": {"schemas": {"CONFIG_SCHEMA": _schema(None, ["mod.check"])}}, + } + _shrink(monkeypatch, output) + + assert output["mod"]["schemas"]["CONFIG_SCHEMA"] == _schema( + {"address": {"key": "Required"}} + ) + assert output["other"]["schemas"]["CONFIG_SCHEMA"] == {"data_type": "check"} + + +def test_shrink_inlined_schema_brings_its_own_extends( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The inlined schema's extends take its place beside the target's siblings.""" + output = { + "base": { + "schemas": { + "SHARED": _schema({"name": {"key": "Optional"}}), + "INNER": _schema({"inner": {"key": "Optional"}}), + } + }, + "mod": { + "schemas": { + "ONCE": _schema({"extra": {"key": "Optional"}}, ["base.INNER"]), + "CONFIG_SCHEMA": _schema( + {"local": {"key": "Optional"}}, ["base.SHARED", "mod.ONCE"] + ), + } + }, + "other": { + "schemas": {"CONFIG_SCHEMA": _schema({}, ["base.SHARED", "base.INNER"])} + }, + } + _shrink(monkeypatch, output) + + schema = output["mod"]["schemas"]["CONFIG_SCHEMA"]["schema"] + assert schema["extends"] == ["base.SHARED", "base.INNER"] + assert set(schema["config_vars"]) == {"local", "extra"} + + +def test_shrink_inlines_into_a_typed_variant(monkeypatch: pytest.MonkeyPatch) -> None: + """A typed variant's top-level extends keep the sibling, the inherited base and local keys.""" + output = { + "base": { + "schemas": { + "SHARED": _schema({"name": {"key": "Optional"}}), + "INNER": _schema({"inner": {"key": "Optional"}}), + } + }, + "mod": { + "schemas": { + "ONCE": _schema({"extra": {"key": "Optional"}}, ["base.INNER"]), + "CONFIG_SCHEMA": { + "type": "typed", + "types": { + "v1": { + "config_vars": {"local": {"key": "Optional"}}, + "extends": ["base.SHARED", "mod.ONCE"], + } + }, + }, + } + }, + "other": { + "schemas": {"CONFIG_SCHEMA": _schema({}, ["base.SHARED", "base.INNER"])} + }, + } + _shrink(monkeypatch, output) + + variant = output["mod"]["schemas"]["CONFIG_SCHEMA"]["types"]["v1"] + assert variant["extends"] == ["base.SHARED", "base.INNER"] + assert set(variant["config_vars"]) == {"local", "extra"} + + +def test_shrink_spread_keeps_enum_values_beside_a_schema( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An enum spliced beside a plain schema keeps its values; alone it is folded in.""" + enum = {"type": "enum", "values": {"a": None}} + output = { + "base": { + "schemas": {"E": enum, "B": _schema({"b": {"key": "Optional"}})}, + }, + "mod": { + "schemas": { + "CONFIG_SCHEMA": _schema({"mode": _schema(None, ["base.E", "base.B"])}) + } + }, + "other": { + "schemas": { + "CONFIG_SCHEMA": _schema( + { + "mode": _schema(None, ["base.E"]), + "b": _schema(None, ["base.B"]), + } + ) + } + }, + } + _shrink(monkeypatch, output) + + mod_vars = output["mod"]["schemas"]["CONFIG_SCHEMA"]["schema"]["config_vars"] + other_vars = output["other"]["schemas"]["CONFIG_SCHEMA"]["schema"]["config_vars"] + assert mod_vars["mode"] == {**_schema(None, ["base.B"]), "values": {"a": None}} + assert other_vars["mode"] == enum + + +def test_gree_feature_switches_extend_the_switch_schema( + language_schema_dir: Path, +) -> None: + """Gree's closure-built feature switches export the switch schema.""" + gree = json.loads((language_schema_dir / "gree.json").read_text()) + config_vars = gree["gree.switch"]["schemas"]["CONFIG_SCHEMA"]["schema"][ + "config_vars" + ] + for key in ("health", "light", "turbo", "xfan"): + assert config_vars[key]["schema"]["extends"] == ["switch._SWITCH_SCHEMA"] + + +def test_inline_schema_merges_a_same_key_override_per_field() -> None: + """A local override keeps the base field's type and values, and the base is not mutated.""" + base = { + "config_vars": { + "mode": { + "key": "Optional", + "type": "enum", + "values": {"a": None, "b": None}, + } + } + } + node = {"config_vars": {"mode": {"default": "b"}}, "extends": ["x"]} + _bls.inline_schema(node, base) + + assert node["config_vars"]["mode"] == { + "key": "Optional", + "type": "enum", + "values": {"a": None, "b": None}, + "default": "b", + } + assert "default" not in base["config_vars"]["mode"] + + +def test_inline_schema_lets_a_narrowed_enum_replace_the_base_values() -> None: + """An explicitly overridden values map replaces the base's instead of merging with it.""" + base = { + "config_vars": { + "mode": { + "key": "Optional", + "type": "enum", + "values": {"a": None, "b": None}, + } + } + } + node = {"config_vars": {"mode": {"values": {"b": None}}}} + _bls.inline_schema(node, base) + + assert node["config_vars"]["mode"] == { + "key": "Optional", + "type": "enum", + "values": {"b": None}, + } + + +def test_inline_schema_overlays_nested_schema_fields() -> None: + """A nested schema's config vars overlay per field too.""" + base = { + "config_vars": { + "outer": { + "type": "schema", + "schema": { + "config_vars": {"inner": {"key": "Optional", "type": "boolean"}} + }, + } + } + } + node = { + "config_vars": { + "outer": {"schema": {"config_vars": {"inner": {"default": "True"}}}} + } + } + _bls.inline_schema(node, base) + + assert node["config_vars"]["outer"]["schema"]["config_vars"]["inner"] == { + "key": "Optional", + "type": "boolean", + "default": "True", + } + + +def test_shrink_enum_collapse_reports_dropped_siblings( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Collapsing into an enum prints the sibling extends it cannot keep.""" + output = { + "base": {"schemas": {"B": _schema({"b": {"key": "Optional"}})}}, + "mod": { + "schemas": { + "E": {"type": "enum", "values": {"a": None}}, + "CONFIG_SCHEMA": _schema({"mode": _schema(None, ["mod.E", "base.B"])}), + } + }, + "other": {"schemas": {"CONFIG_SCHEMA": _schema({}, ["base.B"])}}, + } + _shrink(monkeypatch, output) + + mode = output["mod"]["schemas"]["CONFIG_SCHEMA"]["schema"]["config_vars"]["mode"] + assert mode == {"type": "enum", "values": {"a": None}} + assert "dropped ['base.B'] beside sibling extends at mod.schemas.CONFIG_SCHEMA" in ( + capsys.readouterr().out + ) From da5aba76c7326202b3550c51d71050f69c6d035d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 19:19:37 -0600 Subject: [PATCH 41/55] [wifi] Log why a join failed on RTL87xx (#21751) --- .../components/wifi/wifi_component_libretiny.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 31f86248d90..3a0db423c4b 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -49,12 +49,20 @@ extern "C" int __wrap_wifi_sta_connect(wifi_sta_connect_t *connect, wifi_scan_cf #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/core/util.h" namespace esphome::wifi { ESPHOME_LOG_TAG(TAG, "wifi_lt"); +#ifdef USE_RTL87XX +// rtw_connect_error_flag_t, in order, as wifi_get_last_error() returns it +PROGMEM_STRING_TABLE(JoinErrorStrings, "none", "network not found", "connect failed", + "handshake failed (wrong password?)", "4-way handshake timeout", "DHCP failed", "auth failed", + "assoc failed", "deauthenticated", "unknown"); +#endif + // Thread-safe event handling for LibreTiny WiFi // // LibreTiny's WiFi.onEvent() callback runs in the WiFi driver's thread context, @@ -274,7 +282,12 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { ap.has_bssid() ? ap.get_bssid().data() : NULL); } if (status != WL_CONNECTED) { +#ifdef USE_RTL87XX + ESP_LOGW(TAG, "WiFi.begin failed: %d (%s)", status, + LOG_STR_ARG(JoinErrorStrings::get_log_str(wifi_get_last_error(), JoinErrorStrings::LAST_INDEX))); +#else ESP_LOGW(TAG, "WiFi.begin failed: %d", status); +#endif // Without this reset the state machine stays at CONNECTING and each retry // stalls for the full connect timeout (46 s). this->sta_state_ = static_cast(LTWiFiSTAState::ERROR_FAILED); From 51b88cee9b5d63e8b333413634b5b58dde6f0160 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 19:20:34 -0600 Subject: [PATCH 42/55] [wifi] Ignore the scan inside a join on RTL87xx (#21749) --- esphome/components/wifi/wifi_component_libretiny.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 3a0db423c4b..e3dab2f7645 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -504,6 +504,12 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { case ESPHOME_EVENT_ID_WIFI_SCAN_DONE: { auto &it = event->data.scan_done; ESP_LOGV(TAG, "Scan done: status=%" PRIu32 " number=%u scan_id=%u", it.status, it.number, it.scan_id); +#ifdef USE_RTL87XX + // The driver also reports the scan inside each join; taking that one would clear the results the + // retry phases are working from, so only a scan this component started counts + if (this->state_ != WIFI_COMPONENT_STATE_STA_SCANNING && !this->is_roaming_scan_active()) + break; +#endif this->wifi_scan_done_callback_(); break; } From 5b077a0a9ac95dde358bd231935f07834fcf5f6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 19:35:13 -0600 Subject: [PATCH 43/55] [wifi] Treat open networks as open in RTL87xx scan results (#21750) --- esphome/components/wifi/wifi_component.cpp | 10 +++++----- esphome/components/wifi/wifi_component.h | 7 +++++-- .../wifi/wifi_component_libretiny.cpp | 19 +++++++++++++++++-- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f555d992a3a..63954d394a7 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -521,10 +521,10 @@ bool WiFiComponent::needs_full_scan_results_() const { return false; } -bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t *bssid) const { +const WiFiAP *WiFiComponent::find_configured_network_(const char *ssid, const uint8_t *bssid) const { // Hidden networks in scan results have empty SSIDs - skip them if (ssid[0] == '\0') { - return false; + return nullptr; } for (const auto &sta : this->sta_) { // Skip hidden network configs (they don't appear in normal scans) @@ -534,16 +534,16 @@ bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t // For BSSID-only configs (empty SSID), match by BSSID if (sta.ssid_.empty()) { if (sta.has_bssid() && std::memcmp(sta.get_bssid().data(), bssid, 6) == 0) { - return true; + return &sta; } continue; } // Match by SSID if (sta.ssid_ == ssid) { - return true; + return &sta; } } - return false; + return nullptr; } void __attribute__((flatten)) WiFiComponent::set_sta_priority(bssid_t bssid, int8_t priority) { diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 1201c02954b..d375ae05354 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -798,9 +798,12 @@ class WiFiComponent final : public Component { bool ssid_was_seen_in_scan_(const CompactString &ssid) const; /// Check if full scan results are needed (captive portal active, improv, listeners) bool needs_full_scan_results_() const; - /// Check if network matches any configured network (for scan result filtering) + /// The configured network a scan result belongs to, or nullptr (for scan result filtering) /// Matches by SSID when configured, or by BSSID for BSSID-only configs - bool matches_configured_network_(const char *ssid, const uint8_t *bssid) const; + const WiFiAP *find_configured_network_(const char *ssid, const uint8_t *bssid) const; + bool matches_configured_network_(const char *ssid, const uint8_t *bssid) const { + return this->find_configured_network_(ssid, bssid) != nullptr; + } /// Log a discarded scan result at VERBOSE level (skipped during roaming scans to avoid log overflow) void log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel); /// Find next SSID that wasn't in scan results (might be hidden) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index e3dab2f7645..47ba431880b 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -779,10 +779,25 @@ void WiFiComponent::wifi_scan_done_callback_() { for (int i = 0; i < num; i++) { const char *ssid_cstr = scan->ap[i].ssid; auto &ap = scan->ap[i]; - if (needs_full || this->matches_configured_network_(ssid_cstr, ap.bssid.addr)) { + const WiFiAP *config = this->find_configured_network_(ssid_cstr, ap.bssid.addr); + if (needs_full || config != nullptr) { +#ifdef USE_RTL87XX + // LibreTiny maps the low byte of the driver's security type as an encryption type, so WEP comes out OPEN + // and open, WPA/WPA2 mixed and PMF networks all come out INVALID. For INVALID, follow the configured network. + bool with_auth = true; + if (ap.auth == WIFI_AUTH_INVALID && config != nullptr) { +#ifdef USE_WIFI_WPA2_EAP + with_auth = !config->password_.empty() || config->get_eap().has_value(); +#else + with_auth = !config->password_.empty(); +#endif + } +#else + const bool with_auth = ap.auth != WIFI_AUTH_OPEN; +#endif this->scan_result_.emplace_back(bssid_t{ap.bssid.addr[0], ap.bssid.addr[1], ap.bssid.addr[2], ap.bssid.addr[3], ap.bssid.addr[4], ap.bssid.addr[5]}, - ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, ap.auth != WIFI_AUTH_OPEN, + ssid_cstr, strlen(ssid_cstr), ap.channel, ap.rssi, with_auth, ssid_cstr[0] == '\0'); } else { this->log_discarded_scan_result_(ssid_cstr, ap.bssid.addr, ap.rssi, ap.channel); From f06cba700fb2e782406372072bc566d7dc5aa32f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 19:36:18 -0600 Subject: [PATCH 44/55] [web_server_base] Apply the web server's allowed origins check to every handler (#21972) --- THREAT_MODEL.md | 17 +-- esphome/components/web_server/__init__.py | 19 ++- esphome/components/web_server/web_server.cpp | 73 ++--------- esphome/components/web_server/web_server.h | 26 ---- .../components/web_server_base/__init__.py | 3 + esphome/components/web_server_base/origin.cpp | 36 ++++++ esphome/components/web_server_base/origin.h | 24 ++++ .../web_server_base/web_server_base.cpp | 119 +++++++++++++++++- .../web_server_base/web_server_base.h | 77 ++++++------ esphome/components/web_server_idf/utils.cpp | 9 ++ esphome/components/web_server_idf/utils.h | 4 + .../web_server_idf/web_server_idf.cpp | 10 ++ .../web_server_idf/web_server_idf.h | 3 + esphome/core/defines.h | 1 + .../web_server/config/allowed_origins.yaml | 2 +- .../config/allowed_origins_wildcard.yaml | 15 +++ .../web_server/test_allowed_origins.py | 30 ++++- .../web_server/test_private_network_access.py | 10 ++ .../web_server/test-ota.esp32-idf.yaml | 4 + .../web_server_base/test_origin.cpp | 74 +++++++++++ 20 files changed, 409 insertions(+), 147 deletions(-) create mode 100644 esphome/components/web_server_base/origin.cpp create mode 100644 esphome/components/web_server_base/origin.h create mode 100644 tests/component_tests/web_server/config/allowed_origins_wildcard.yaml create mode 100644 tests/components/web_server_base/test_origin.cpp diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 11656ff0b72..0ffd791bae2 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -94,23 +94,24 @@ without a password leaves OTA open. The API is documented and is meant to be called by other devices, scripts, and pages. As defense-in-depth, the web server checks the `Origin` header on browser requests -to its entity control and state endpoints: a request whose `Origin` does not match -the address the device is served on is rejected, and the `allowed_origins` option +to every endpoint served through it, including the web OTA `/update` endpoint +(the captive portal pages are open by design): a request whose `Origin` does not match +the `Host` header of the same request is rejected, and the `allowed_origins` option widens that list. This blocks the common "confused deputy" (CSRF) case where a page the operator visits drives the device through their browser. It is **not** an authentication boundary: it only constrains browsers. Any client that omits the `Origin` header — `curl`, scripts, or other non-browser callers on the same -network — reaches every endpoint exactly as before. The check also does not cover -the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer` +network — reaches every endpoint exactly as before. The device performs no CSRF-token or `Referer` validation. The following are therefore **not** vulnerabilities in this repository: - Requests without an `Origin` header (for example `curl`) reaching the control endpoints, whether or not `web_server` `auth:` is set. - Requests from an origin the operator added to `allowed_origins`. -- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when - web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not - covered by the `Origin` check; this is the same exposure as running OTA without a - password. +- DNS rebinding: a page whose host name resolves to the device sends a matching + `Origin` and `Host`, so the check lets it through. +- Firmware upload through the web OTA endpoint (`/update`) by a client that sends no + `Origin` header when web OTA is enabled without `web_server` `auth:`; this is the + same exposure as running OTA without a password. The supported defenses are `web_server` `auth:`, protecting OTA (a web password or a native OTA password), and keeping devices on a trusted, segmented network. See diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 0d550daa153..d11b68e59a8 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -10,7 +10,10 @@ import esphome.codegen as cg from esphome.components import web_server_base from esphome.components.json import enable_arena from esphome.components.logger import request_log_listener -from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID +from esphome.components.web_server_base import ( + CONF_WEB_SERVER_BASE_ID, + MAX_ORIGIN_LENGTH, +) import esphome.config_validation as cv # Re-exported so entity components and external components keep importing @@ -163,6 +166,11 @@ def validate_origin(value: Any) -> str: f"'{value}' is not a valid origin. An origin must be 'scheme://host[:port]' with no " f"path or trailing slash (e.g. 'https://example.com'), or '*' to allow any origin." ) + if len(value) > MAX_ORIGIN_LENGTH: + raise cv.Invalid( + f"'{value}' is too long; an allowed origin can have at most " + f"{MAX_ORIGIN_LENGTH} characters." + ) # Browsers send the scheme and host lowercased in the Origin header, so normalize to match. return value.lower() @@ -541,11 +549,16 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_expose_log(False)) if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") - if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: + allowed_origins = config.get(CONF_ALLOWED_ORIGINS, []) + if "*" in allowed_origins: + # Any origin is allowed, so the check compiles to nothing and no table is stored. + cg.add_define("USE_WEBSERVER_ALLOW_ANY_ORIGIN") + elif allowed_origins: cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") # Shared flash table ended by nullptr, so the server stores only a pointer. + # Set on web_server_base so every handler added through add_handler() uses it. cg.add( - var.set_allowed_origins( + paren.set_allowed_origins( cg.shared_progmem_array( "web_server_allowed_origins", cg.const_char_ptr, diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index dcf8a448fa4..84723878fa2 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -529,62 +529,23 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #endif -// Read a request header value portably across the Arduino and ESP-IDF web servers. -// Returns an empty string when the header is absent (only allocates when a value is present). -static std::string get_request_header(AsyncWebServerRequest *request, const char *name) { -#ifdef USE_ESP32 - // ESP32 (Arduino and ESP-IDF) uses the web_server_idf backend. - optional value = request->get_header(name); - return value.has_value() ? std::move(*value) : std::string(); -#else - // ESP8266, RP2040 and LibreTiny use the Arduino ESPAsyncWebServer backend. - const AsyncWebHeader *header = request->getHeader(name); - return header != nullptr ? std::string(header->value().c_str()) : std::string(); -#endif -} - -bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin) { - // No Origin header: not a browser cross-origin request (e.g. curl, native API client). Allow. - if (origin.empty()) - return true; - - // Same-origin: the Origin authority (scheme stripped) matches the Host the request was sent to. - // This covers the device's own IP, mDNS name, or DNS name without knowing any at compile time. - const size_t scheme_sep = origin.find("://"); - if (scheme_sep != std::string::npos) { - const std::string host = get_request_header(request, "Host"); - // Compare by hand: compare(pos, ...) carries an out_of_range throw path that can never fire here - const size_t authority = scheme_sep + 3; - if (!host.empty() && origin.size() - authority == host.size() && - memcmp(origin.data() + authority, host.data(), host.size()) == 0) - return true; - } - -#ifdef USE_WEBSERVER_ALLOWED_ORIGINS - // Otherwise the origin must be explicitly allowed via configuration. - for (const char *const *it = this->allowed_origins_; *it != nullptr; it++) { - const char *allowed_origin = *it; - // A single "*" entry allows any origin. - if (allowed_origin[0] == '*' && allowed_origin[1] == '\0') - return true; - if (origin == allowed_origin) - return true; - } -#endif - return false; -} - #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { - const std::string origin = get_request_header(request, "Origin"); - if (!this->is_request_origin_allowed_(request, origin)) { - request->send(403); - return; - } - + // WebServerBase's middleware already rejected an origin that is not allowed. +#ifdef USE_ESP32 + char origin_buf[web_server_base::MAX_ORIGIN_LENGTH + 1]; + const size_t origin_len = request->get_header_to("Origin", origin_buf); + // Only an allow-any-origin build lets through an Origin too long for the buffer; it gets "*" instead + const bool has_origin = origin_len != 0 && origin_len != SIZE_MAX; + const char *origin = origin_buf; +#else + const AsyncWebHeader *origin_header = request->getHeader("Origin"); + const bool has_origin = origin_header != nullptr && origin_header->value().length() != 0; + const char *origin = has_origin ? origin_header->value().c_str() : nullptr; +#endif AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("")); // Echo the specific origin back so the response is valid even when auth (credentials) is enabled. - response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str()); + response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), has_origin ? origin : "*"); response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true")); response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str()); char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -2430,20 +2391,12 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS - // Private Network Access preflight carries a cross-origin Origin by design; its handler does the - // origin check itself, so let it run before the general enforcement below. if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { this->handle_pna_cors_request(request); return; } #endif - // Reject cross-origin browser requests unless the origin is explicitly allowed. - if (!this->is_request_origin_allowed_(request, get_request_header(request, "Origin"))) { - request->send(403); - return; - } - #if !defined(USE_ESP32) && defined(USE_ARDUINO) if (url == ESPHOME_F("/events")) { this->events_.add_new_client(this, request); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 13695e033ec..881641fe3ff 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -250,22 +250,6 @@ class WebServer final : public Component, public AsyncWebHandler { */ void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; } -#ifdef USE_WEBSERVER_ALLOWED_ORIGINS - /** Set the origins that browsers are allowed to make cross-origin requests from. - * - * Requests without an `Origin` header (e.g. non-browser clients like curl or the native API) - * are always allowed. Requests whose `Origin` matches the address the device is served on - * (same-origin) are always allowed. Any other browser origin must appear in this list, or the - * request is rejected. A single "*" entry allows any origin. Each other entry must exactly match - * the requesting page's `Origin` header (e.g. "https://example.com"). - * - * This list is also used to authorize Private Network Access requests when that feature is enabled. - * - * @param origins nullptr-terminated table of allowed origins; must outlive the server. - */ - void set_allowed_origins(const char *const *origins) { this->allowed_origins_ = origins; } -#endif - // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup the internal web server and register handlers. @@ -643,16 +627,6 @@ class WebServer final : public Component, public AsyncWebHandler { #ifdef USE_WEBSERVER_JS_INCLUDE const char *js_include_{nullptr}; #endif -#ifdef USE_WEBSERVER_ALLOWED_ORIGINS - // Extra origins allowed to make cross-origin browser requests ("*" means any origin). - // Only compiled when allowed_origins is configured; same-origin is always allowed regardless. - const char *const *allowed_origins_{nullptr}; -#endif - - /// Check whether the given request Origin is permitted. Same-origin (matching the Host the - /// request was sent to) and requests without an Origin header are always allowed; any other - /// origin must be listed in allowed_origins. The caller passes the already-read Origin header. - bool is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin); private: #ifdef USE_SENSOR diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index f290873c2f6..cb61b841f1b 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -28,6 +28,9 @@ WebServerBase = web_server_base_ns.class_("WebServerBase") CONF_WEB_SERVER_BASE_ID = "web_server_base_id" +# Longest Origin the ESP32 server can read; keep in sync with MAX_ORIGIN_LENGTH in origin.h. +MAX_ORIGIN_LENGTH = 127 + @dataclass class WebServerBaseData: diff --git a/esphome/components/web_server_base/origin.cpp b/esphome/components/web_server_base/origin.cpp new file mode 100644 index 00000000000..e7391a5f407 --- /dev/null +++ b/esphome/components/web_server_base/origin.cpp @@ -0,0 +1,36 @@ +#include "origin.h" + +#include + +#include "esphome/core/string_ref.h" + +namespace esphome::web_server_base { + +bool is_origin_allowed(const char *origin, size_t origin_len, const char *host, size_t host_len, + const char *const *allowed) { + if (origin_len == 0) + return true; + + if (host_len != 0) { + // Same-origin: covers the device's IP, mDNS name or DNS name without knowing any at compile time. + // The scheme has no ':', so the first one starts "://". + const char *colon = static_cast(memchr(origin, ':', origin_len)); + if (colon != nullptr) { + const size_t authority = colon - origin + 3; + if (authority <= origin_len && colon[1] == '/' && colon[2] == '/' && + StringRef(origin + authority, origin_len - authority) == StringRef(host, host_len)) + return true; + } + } + + if (allowed == nullptr) + return false; + const StringRef origin_ref(origin, origin_len); + for (; *allowed != nullptr; allowed++) { + if (origin_ref == *allowed) + return true; + } + return false; +} + +} // namespace esphome::web_server_base diff --git a/esphome/components/web_server_base/origin.h b/esphome/components/web_server_base/origin.h new file mode 100644 index 00000000000..39ecfe00cb4 --- /dev/null +++ b/esphome/components/web_server_base/origin.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace esphome::web_server_base { + +/// Longest Origin or Host value the ESP32 server reads into its stack buffers; keep in sync with +/// MAX_ORIGIN_LENGTH in web_server_base/__init__.py. +static constexpr size_t MAX_ORIGIN_LENGTH = 127; + +/** Decide whether a browser request from the given Origin may reach a handler. + * + * A request without an Origin (origin_len == 0) is not a cross-origin browser request and is allowed. + * A same-origin request, whose Origin authority (the part after "scheme://") equals the Host header, + * is allowed. Any other origin must exactly equal an entry of the nullptr-terminated allowed table + * (nullptr when none is configured). A configured "*" never reaches this table: codegen turns it into + * USE_WEBSERVER_ALLOW_ANY_ORIGIN, which skips the check entirely. + * + * @param host Host header value; host_len == 0 when absent or unreadable. + */ +bool is_origin_allowed(const char *origin, size_t origin_len, const char *host, size_t host_len, + const char *const *allowed); + +} // namespace esphome::web_server_base diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 50144b6df73..ac170f8348d 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -5,13 +5,120 @@ namespace esphome::web_server_base { WebServerBase *global_web_server_base = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -void WebServerBase::add_handler(AsyncWebHandler *handler) { -#ifdef USE_WEBSERVER_AUTH - if (credentials_.is_set()) { - handler = new internal::AuthMiddlewareHandler(handler, &credentials_); - } +#if defined(USE_WEBSERVER_ALLOWED_ORIGINS) || !defined(USE_WEBSERVER_ALLOW_ANY_ORIGIN) +// Not inlined, so its header copies leave the stack before the wrapped handler runs +static bool __attribute__((noinline)) is_request_origin_allowed(AsyncWebServerRequest *request, + const char *const *allowed) { +#ifdef USE_ESP32 + // Stack copies: the IDF server only hands out header values by copy + char origin[MAX_ORIGIN_LENGTH + 1]; + const size_t origin_len = request->get_header_to("Origin", origin); + if (origin_len == 0) + return true; + // Longer than codegen lets a configured entry be + if (origin_len == SIZE_MAX) + return false; + char host[MAX_ORIGIN_LENGTH + 1]; + size_t host_len = request->get_header_to("Host", host); + // A Host that does not fit means an Origin longer than the buffer, already handled above + if (host_len == SIZE_MAX) + host_len = 0; + return is_origin_allowed(origin, origin_len, host, host_len, allowed); +#else + const AsyncWebHeader *origin = request->getHeader("Origin"); + if (origin == nullptr) + return true; + const AsyncWebHeader *host = request->getHeader("Host"); + return is_origin_allowed(origin->value().c_str(), origin->value().length(), + host != nullptr ? host->value().c_str() : nullptr, + host != nullptr ? host->value().length() : 0, allowed); #endif - this->add_handler_without_auth(handler); +} +#endif + +namespace internal { + +bool GuardMiddlewareHandler::check_auth_(AsyncWebServerRequest *request) { +#ifdef USE_WEBSERVER_AUTH + const Credentials &credentials = global_web_server_base->credentials_; + if (!credentials.is_set()) + return true; + // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is + // compiled out. On ESP32 our own server picks the scheme internally. +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + bool success = request->authenticate(credentials.username, credentials.password); +#else + bool success = request->authenticate(credentials.basic_auth_hash); +#endif + if (!success) { +#if USE_ESP32 + request->requestAuthentication(); +#elif defined(USE_WEBSERVER_AUTH_DIGEST) + request->requestAuthentication(nullptr, true); +#else + request->requestAuthentication(nullptr, false); +#endif + } + return success; +#else + return true; +#endif +} + +bool GuardMiddlewareHandler::check_origin_(AsyncWebServerRequest *request) { + // Codegen never sets both defines +#if defined(USE_WEBSERVER_ALLOWED_ORIGINS) + return is_request_origin_allowed(request, global_web_server_base->allowed_origins_); +#elif defined(USE_WEBSERVER_ALLOW_ANY_ORIGIN) + return true; +#else + return is_request_origin_allowed(request, nullptr); +#endif +} + +bool GuardMiddlewareHandler::check_chunk_(AsyncWebServerRequest *request, size_t index) { + // Every chunk is gated, but only the first of a request pays for the header copies and digest. + // A request always starts at index 0, so a new request reusing a freed request's address is + // checked again; an interleaved request has another address; a rejected one never sets passed_. + if (index != 0 && request == this->passed_) + return true; + if (!this->check_origin_(request) || !this->check_auth_(request)) { + this->passed_ = nullptr; + return false; + } + this->passed_ = request; + return true; +} + +void GuardMiddlewareHandler::handleRequest(AsyncWebServerRequest *request) { + this->passed_ = nullptr; + if (!this->check_origin_(request)) { + request->send(403); + return; + } + if (this->check_auth_(request)) + MiddlewareHandler::handleRequest(request); +} + +void GuardMiddlewareHandler::handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, + uint8_t *data, size_t len, bool final) { + // A handler that buffers chunks never sees one from a rejected request + if (!this->check_chunk_(request, index)) + return; + MiddlewareHandler::handleUpload(request, filename, index, data, len, final); +} + +void GuardMiddlewareHandler::handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, + size_t total) { + if (!this->check_chunk_(request, index)) + return; + MiddlewareHandler::handleBody(request, data, len, index, total); +} + +} // namespace internal + +void WebServerBase::add_handler(AsyncWebHandler *handler) { + this->add_handler_without_auth(new internal::GuardMiddlewareHandler(handler)); } void WebServerBase::add_handler_without_auth(AsyncWebHandler *handler) { diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 38098971f4f..d695f113afd 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -4,6 +4,7 @@ #include #include "esphome/core/progmem.h" +#include "origin.h" #if USE_ESP32 #include "esphome/core/hal.h" @@ -60,53 +61,30 @@ struct Credentials { bool is_set() const { return basic_auth_hash != nullptr; } #endif }; +#endif -class AuthMiddlewareHandler : public MiddlewareHandler { +// Wraps every handler added through WebServerBase::add_handler(): rejects cross-origin browser +// requests the origin policy does not allow, then checks credentials when auth is configured. +// It holds only the next handler; the policy lives in global_web_server_base. +class GuardMiddlewareHandler : public MiddlewareHandler { public: - AuthMiddlewareHandler(AsyncWebHandler *next, Credentials *credentials) - : MiddlewareHandler(next), credentials_(credentials) {} + using MiddlewareHandler::MiddlewareHandler; - bool check_auth(AsyncWebServerRequest *request) { - // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is - // compiled out. On ESP32 our own server picks the scheme internally. -#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) - bool success = request->authenticate(credentials_->username, credentials_->password); -#else - bool success = request->authenticate(credentials_->basic_auth_hash); -#endif - if (!success) { -#if USE_ESP32 - request->requestAuthentication(); -#elif defined(USE_WEBSERVER_AUTH_DIGEST) - request->requestAuthentication(nullptr, true); -#else - request->requestAuthentication(nullptr, false); -#endif - } - return success; - } - - void handleRequest(AsyncWebServerRequest *request) override { - if (!check_auth(request)) - return; - MiddlewareHandler::handleRequest(request); - } + void handleRequest(AsyncWebServerRequest *request) override; void handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, uint8_t *data, - size_t len, bool final) override { - if (!check_auth(request)) - return; - MiddlewareHandler::handleUpload(request, filename, index, data, len, final); - } - void handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) override { - if (!check_auth(request)) - return; - MiddlewareHandler::handleBody(request, data, len, index, total); - } + size_t len, bool final) override; + void handleBody(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) override; protected: - Credentials *credentials_; + // Upload and body chunks arrive before handleRequest(), which sends the 403 for a rejected origin. + bool check_chunk_(AsyncWebServerRequest *request, size_t index); + bool check_origin_(AsyncWebServerRequest *request); + // Always true when auth is not compiled in. + bool check_auth_(AsyncWebServerRequest *request); + + // Request whose first chunk passed the checks; see check_chunk_(). + const AsyncWebServerRequest *passed_{nullptr}; }; -#endif } // namespace internal @@ -153,15 +131,27 @@ class WebServerBase final { #else void set_auth_basic_hash(const char *hash) { credentials_.basic_auth_hash = hash; } #endif +#endif +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + /** Set the extra origins browsers may make cross-origin requests from, for every handler added + * through add_handler(). Requests without an Origin header and same-origin requests are always + * allowed. Each entry must exactly match the browser's Origin header ("scheme://host[:port]"); + * "*" is not an entry here, codegen turns it into USE_WEBSERVER_ALLOW_ANY_ORIGIN instead. + * + * @param origins nullptr-terminated table of allowed origins; must outlive the server. + */ + void set_allowed_origins(const char *const *origins) { this->allowed_origins_ = origins; } #endif void add_handler(AsyncWebHandler *handler); /** - * WARNING: Registers a handler that bypasses the USE_WEBSERVER_AUTH middleware. + * WARNING: Registers a handler that bypasses the auth and origin middleware. * * This should only be used for endpoints that are intentionally unauthenticated * (for example, captive portal or very limited-status endpoints). For normal * endpoints that should respect web server authentication, use add_handler(). + * The origin check is skipped too, so a captive portal-only build carries none of it; + * the portal's own pages are same-origin and the OS probes send no Origin. */ void add_handler_without_auth(AsyncWebHandler *handler); @@ -176,6 +166,11 @@ class WebServerBase final { #ifdef USE_WEBSERVER_AUTH internal::Credentials credentials_; #endif +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + const char *const *allowed_origins_{nullptr}; +#endif + + friend class internal::GuardMiddlewareHandler; }; } // namespace esphome::web_server_base diff --git a/esphome/components/web_server_idf/utils.cpp b/esphome/components/web_server_idf/utils.cpp index c58ca2a24ff..c12314ded26 100644 --- a/esphome/components/web_server_idf/utils.cpp +++ b/esphome/components/web_server_idf/utils.cpp @@ -50,6 +50,15 @@ optional request_get_header(httpd_req_t *req, const char *name) { return {str}; } +size_t request_get_header_to(httpd_req_t *req, const char *name, std::span buf) { + const size_t len = httpd_req_get_hdr_value_len(req, name); + if (len == 0) + return 0; + if (len >= buf.size() || httpd_req_get_hdr_value_str(req, name, buf.data(), buf.size()) != ESP_OK) + return SIZE_MAX; + return len; +} + optional query_key_value(const char *query_url, size_t query_len, const char *key) { if (query_url == nullptr || query_len == 0) { return {}; diff --git a/esphome/components/web_server_idf/utils.h b/esphome/components/web_server_idf/utils.h index 027a2f7b6cb..d0a41ec70bf 100644 --- a/esphome/components/web_server_idf/utils.h +++ b/esphome/components/web_server_idf/utils.h @@ -2,6 +2,7 @@ #ifdef USE_ESP32 #include +#include #include #include "esphome/core/helpers.h" @@ -13,6 +14,9 @@ size_t url_decode(char *str); bool request_has_header(httpd_req_t *req, const char *name); optional request_get_header(httpd_req_t *req, const char *name); +/// Copy a header value into buf, nul-terminated, without heap allocation. +/// Returns its length, 0 when absent, or SIZE_MAX when it does not fit in buf. +size_t request_get_header_to(httpd_req_t *req, const char *name, std::span buf); optional query_key_value(const char *query_url, size_t query_len, const char *key); bool query_has_key(const char *query_url, size_t query_len, const char *key); diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index e82749ebbb5..87d5ddae6ea 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -45,6 +45,9 @@ namespace esphome::web_server_idf { #ifndef HTTPD_401 #define HTTPD_401 "401 Unauthorized" #endif +#ifndef HTTPD_403 +#define HTTPD_403 "403 Forbidden" +#endif #ifndef HTTPD_409 #define HTTPD_409 "409 Conflict" #endif @@ -334,6 +337,10 @@ optional AsyncWebServerRequest::get_header(const char *name) const return request_get_header(*this, name); } +size_t AsyncWebServerRequest::get_header_to(const char *name, std::span buf) const { + return request_get_header_to(*this, name, buf); +} + StringRef AsyncWebServerRequest::url_to(std::span buffer) const { const char *uri = this->req_->uri; const char *query_start = strchr(uri, '?'); @@ -369,6 +376,9 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code case 401: status = HTTPD_401; break; + case 403: + status = HTTPD_403; + break; case 404: status = HTTPD_404; break; diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index b017e6b36c4..6c801258f38 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -193,6 +193,9 @@ class AsyncWebServerRequest { operator httpd_req_t *() const { return this->req_; } optional get_header(const char *name) const; + /// Copy a header value into buf without heap allocation. + /// Returns its length, 0 when absent, or SIZE_MAX when it does not fit in buf. + size_t get_header_to(const char *name, std::span buf) const; // NOLINTNEXTLINE(readability-identifier-naming) bool hasHeader(const char *name) const; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1fae710743e..816c956aa1b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -590,6 +590,7 @@ #define USE_WEBSERVER_GZIP #define USE_WEBSERVER_SORTING #define USE_WEBSERVER_ALLOWED_ORIGINS +#define USE_WEBSERVER_ALLOW_ANY_ORIGIN #define USE_WEBSERVER_CSS_INCLUDE #define USE_WEBSERVER_JS_INCLUDE #define USE_WEBSERVER_PRIVATE_NETWORK_ACCESS diff --git a/tests/component_tests/web_server/config/allowed_origins.yaml b/tests/component_tests/web_server/config/allowed_origins.yaml index 2ced49d6d8c..87354d42e65 100644 --- a/tests/component_tests/web_server/config/allowed_origins.yaml +++ b/tests/component_tests/web_server/config/allowed_origins.yaml @@ -12,4 +12,4 @@ wifi: web_server: allowed_origins: - https://app.esphome.io - - "*" + - http://other.local:8080 diff --git a/tests/component_tests/web_server/config/allowed_origins_wildcard.yaml b/tests/component_tests/web_server/config/allowed_origins_wildcard.yaml new file mode 100644 index 00000000000..2ced49d6d8c --- /dev/null +++ b/tests/component_tests/web_server/config/allowed_origins_wildcard.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +web_server: + allowed_origins: + - https://app.esphome.io + - "*" diff --git a/tests/component_tests/web_server/test_allowed_origins.py b/tests/component_tests/web_server/test_allowed_origins.py index 7a6c602c129..f95d1f260f1 100644 --- a/tests/component_tests/web_server/test_allowed_origins.py +++ b/tests/component_tests/web_server/test_allowed_origins.py @@ -2,6 +2,13 @@ from collections.abc import Callable from pathlib import Path +import re + +from esphome.core import CORE + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} def test_allowed_origins_are_a_progmem_table( @@ -13,6 +20,25 @@ def test_allowed_origins_are_a_progmem_table( assert ( "static constexpr const char * web_server_allowed_origins[] PROGMEM = " - '{"https://app.esphome.io", "*", nullptr};' in main_cpp + '{"https://app.esphome.io", "http://other.local:8080", nullptr};' in main_cpp ) - assert "->set_allowed_origins(web_server_allowed_origins);" in main_cpp + assert "USE_WEBSERVER_ALLOWED_ORIGINS" in _defines() + assert "USE_WEBSERVER_ALLOW_ANY_ORIGIN" not in _defines() + # Set on web_server_base, whose middleware checks every handler added with add_handler() + assert re.search( + r"\bweb_server_base_\w+->set_allowed_origins\(web_server_allowed_origins\);", + main_cpp, + ) + + +def test_wildcard_origin_stores_no_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A "*" entry allows any origin at build time, so no table or list check is generated.""" + main_cpp = generate_main(component_config_path("allowed_origins_wildcard.yaml")) + + assert "USE_WEBSERVER_ALLOW_ANY_ORIGIN" in _defines() + assert "USE_WEBSERVER_ALLOWED_ORIGINS" not in _defines() + assert "web_server_allowed_origins" not in main_cpp + assert "set_allowed_origins" not in main_cpp diff --git a/tests/component_tests/web_server/test_private_network_access.py b/tests/component_tests/web_server/test_private_network_access.py index 87911c5f9b7..8a1174f3ecc 100644 --- a/tests/component_tests/web_server/test_private_network_access.py +++ b/tests/component_tests/web_server/test_private_network_access.py @@ -8,6 +8,7 @@ from esphome.components.web_server import ( validate_origin, validate_private_network_access, ) +from esphome.components.web_server_base import MAX_ORIGIN_LENGTH from esphome.const import CONF_ENABLE_PRIVATE_NETWORK_ACCESS from esphome.types import ConfigType @@ -84,3 +85,12 @@ def test_validate_origin_invalid(value: str) -> None: """Malformed origins are rejected at config time instead of silently 403ing.""" with pytest.raises(cv.Invalid, match="not a valid origin"): validate_origin(value) + + +def test_validate_origin_length_limit() -> None: + """An origin longer than the ESP32 header buffer could never match, so it is rejected.""" + prefix = "https://" + longest = prefix + "a" * (MAX_ORIGIN_LENGTH - len(prefix)) + assert validate_origin(longest) == longest + with pytest.raises(cv.Invalid, match="too long"): + validate_origin(longest + "a") diff --git a/tests/components/web_server/test-ota.esp32-idf.yaml b/tests/components/web_server/test-ota.esp32-idf.yaml index b3496e01ebf..cece9cc9c51 100644 --- a/tests/components/web_server/test-ota.esp32-idf.yaml +++ b/tests/components/web_server/test-ota.esp32-idf.yaml @@ -14,6 +14,10 @@ web_server: port: 8080 version: 2 include_internal: true + # "*" compiles the origin check out; PNA still echoes the request origin + enable_private_network_access: true + allowed_origins: + - "*" # Enable debug logging for OTA logger: diff --git a/tests/components/web_server_base/test_origin.cpp b/tests/components/web_server_base/test_origin.cpp new file mode 100644 index 00000000000..9ebce5c1477 --- /dev/null +++ b/tests/components/web_server_base/test_origin.cpp @@ -0,0 +1,74 @@ +#include + +#include + +#include "esphome/components/web_server_base/origin.h" + +namespace esphome::web_server_base::testing { + +static bool allowed(const char *origin, const char *host, const char *const *list) { + return is_origin_allowed(origin, origin == nullptr ? 0 : strlen(origin), host, host == nullptr ? 0 : strlen(host), + list); +} + +static constexpr const char *LIST[] = {"https://app.esphome.io", "http://other.local:8080", nullptr}; +static constexpr const char *STAR[] = {"*", nullptr}; + +TEST(WebServerBaseOrigin, NoOriginIsAllowed) { + EXPECT_TRUE(allowed(nullptr, nullptr, nullptr)); + EXPECT_TRUE(allowed("", "device.local", nullptr)); +} + +TEST(WebServerBaseOrigin, SameOriginIsAllowedWithoutList) { + EXPECT_TRUE(allowed("http://device.local", "device.local", nullptr)); + EXPECT_TRUE(allowed("http://192.168.1.5:8080", "192.168.1.5:8080", nullptr)); + EXPECT_TRUE(allowed("https://device.example.com", "device.example.com", nullptr)); +} + +TEST(WebServerBaseOrigin, CrossOriginIsRejectedWithoutList) { + EXPECT_FALSE(allowed("http://evil.example", "device.local", nullptr)); + // Port or prefix differences are a different origin + EXPECT_FALSE(allowed("http://device.local:81", "device.local", nullptr)); + EXPECT_FALSE(allowed("http://device.local", "device.local:81", nullptr)); + EXPECT_FALSE(allowed("http://device.localx", "device.local", nullptr)); + EXPECT_FALSE(allowed("http://device.local", nullptr, nullptr)); + // Opaque origins such as sandboxed frames and file:// pages + EXPECT_FALSE(allowed("null", "null", nullptr)); +} + +TEST(WebServerBaseOrigin, ListedOriginIsAllowed) { + EXPECT_TRUE(allowed("https://app.esphome.io", "device.local", LIST)); + EXPECT_TRUE(allowed("http://other.local:8080", "device.local", LIST)); + EXPECT_TRUE(allowed("http://device.local", "device.local", LIST)); +} + +TEST(WebServerBaseOrigin, UnlistedOriginIsRejected) { + EXPECT_FALSE(allowed("https://app.esphome.io.evil", "device.local", LIST)); + EXPECT_FALSE(allowed("https://app.esphome.i", "device.local", LIST)); + EXPECT_FALSE(allowed("http://other.local", "device.local", LIST)); + EXPECT_FALSE(allowed("null", "device.local", LIST)); +} + +TEST(WebServerBaseOrigin, StarEntryIsNotAWildcard) { + // Codegen resolves "*" to USE_WEBSERVER_ALLOW_ANY_ORIGIN; the table itself never matches any origin + EXPECT_FALSE(allowed("http://evil.example", "device.local", STAR)); + EXPECT_FALSE(allowed("null", "device.local", STAR)); +} + +TEST(WebServerBaseOrigin, SchemeSeparatorMustFollowFirstColon) { + EXPECT_FALSE(allowed("http:", "device.local", nullptr)); + EXPECT_FALSE(allowed("http:/", "device.local", nullptr)); + EXPECT_FALSE(allowed("http:/device.local", "device.local", nullptr)); + EXPECT_FALSE(allowed("http:device.local", "device.local", nullptr)); +} + +TEST(WebServerBaseOrigin, LengthsAreNotNulTerminated) { + // Arduino and ESP-IDF header values are passed with their length, not relied on for a terminator + const char origin[] = "https://app.esphome.ioXXXX"; + EXPECT_TRUE(is_origin_allowed(origin, strlen("https://app.esphome.io"), "h", 1, LIST)); + const char host[] = "device.localXXXX"; + EXPECT_TRUE( + is_origin_allowed("http://device.local", strlen("http://device.local"), host, strlen("device.local"), nullptr)); +} + +} // namespace esphome::web_server_base::testing From 469a459b9a090aaad813d17984dcdf1a7eb67f79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 19:36:43 -0600 Subject: [PATCH 45/55] [wifi] Retry at once when a scan leaves no hidden network to try (#21747) --- esphome/components/wifi/wifi_component.cpp | 5 +++-- tests/components/wifi/retry_phase_test.cpp | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 63954d394a7..cd3ea03ba1d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1744,10 +1744,11 @@ void WiFiComponent::check_scanning_finished() { if (!found_match) { ESP_LOGW(TAG, "No matching network found"); // No scan results matched our configured networks - transition directly to hidden mode - // Don't call retry_connect() since we never attempted a connection (no BSSID to penalize) this->transition_to_phase_(WiFiRetryPhase::RETRY_HIDDEN); - // If no hidden networks to try, skip connection attempt (will be handled on next loop) + // No hidden network left: retry now instead of waiting out the scan timeout. With + // selected_sta_index_ at -1, retry_connect() penalizes no BSSID. if (this->selected_sta_index_ == -1) { + this->retry_connect(); return; } // Now start connection attempt in hidden mode diff --git a/tests/components/wifi/retry_phase_test.cpp b/tests/components/wifi/retry_phase_test.cpp index b95cbf73a92..50a43e6ea41 100644 --- a/tests/components/wifi/retry_phase_test.cpp +++ b/tests/components/wifi/retry_phase_test.cpp @@ -66,5 +66,18 @@ TEST_F(WiFiRetryPhaseTest, NetworkMissingFromScanIsTriedAsHidden) { EXPECT_FALSE(host_hooks.last_connect.has_bssid()); } +TEST_F(WiFiRetryPhaseTest, PinnedBssidMissingFromScanRestartsAtOnce) { + WiFiAP ap = make_ap("home"); + ap.set_bssid(BSSID_A); + this->add_network_(ap); + + // The SSID is visible, but on another access point, so no hidden network is left to try + this->scan_({make_result("home", BSSID_B)}); + + // The adapter restarts now instead of after the scan timeout + EXPECT_EQ(host_hooks.station_offs, 1); + EXPECT_EQ(host_hooks.connects, 0); +} + } // namespace } // namespace esphome::wifi::testing From 59d507197df32b4d742431579393e94baa1ee8d1 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:38:40 +0000 Subject: [PATCH 46/55] Bump aioesphomeapi from 46.27.0 to 46.28.0 (#22290) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 39e89f77d5f..4bc123e9699 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.6.0 click==8.5.0 -aioesphomeapi==46.27.0 +aioesphomeapi==46.28.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.153.0 puremagic==2.2.0 From c248a7d3c43b20067f15a2c9ab51c6e3fe63a6ce Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:58:45 +1300 Subject: [PATCH 47/55] [epaper_spi] Add Good Display GDEY0583T81 5.83" model (#22143) --- .../components/epaper_spi/models/uc8179.py | 13 +++++++ tests/component_tests/epaper_spi/test_init.py | 26 +++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 37 +++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py index 404a14ab102..9bb2ee37b10 100644 --- a/esphome/components/epaper_spi/models/uc8179.py +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -4,6 +4,8 @@ Supported models: - waveshare-7.5in-v2: 7.5" mono display, 800x480 pixels (EPD_7in5_V2) - seeed-reterminal-e1001: Seeed reTerminal E1001, which uses the same 7.5" 800x480 panel on an integrated ESP32-S3 board +- goodisplay-gdey0583t81-5.83: Good Display GDEY0583T81 5.83" mono display, + 648x480 pixels, also sold by Seeed as 0583BN-T81-D2 Panel configuration and power-on (0x04) are both sent during the INITIALISE state; the state machine's built-in busy wait then covers the power-on delay @@ -75,6 +77,17 @@ waveshare_7_5_v2 = uc8179.extend( height=480, ) +# Good Display GDEY0583T81, 5.83" mono 648x480, UC8179; also sold by Seeed as 0583BN-T81-D2. +# Replaces the waveshare_epaper model gdey0583t81. Busy is low while busy, so set inverted: true. +goodisplay_gdey0583t81 = uc8179.extend( + "goodisplay-gdey0583t81-5.83", + width=648, + height=480, +) + +# Name printed on the panel's label as sold by Seeed +goodisplay_gdey0583t81.extend("0583bn-t81-d2") + # Seeed reTerminal E1001 — 7.5" mono e-paper (800x480), same panel as the # Waveshare 7.5" V2, driven by an integrated ESP32-S3 board waveshare_7_5_v2.extend( diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index c6fd1293f5e..dcebfa632fa 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -628,6 +628,32 @@ def test_uc8179_e1001_code_generation( assert "97, 4, 3, 32, 1, 224" in main_cpp +def test_gdey0583t81_defaults_and_driver_class( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The GDEY0583T81 models default to 648x480 and use the UC8179 driver class.""" + 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}) + + for name in ("goodisplay-gdey0583t81-5.83", "0583BN-T81-D2"): + model = MODELS[name.upper()] + assert model.class_name == "EPaperUC8179" + config = run_schema_validation( + { + "id": "test_display", + "model": name, + "dc_pin": 21, + "full_update_every": 5, + } + ) + assert model.get_dimensions(config) == (648, 480) + + def test_uc8279_width_must_be_a_multiple_of_8( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index d60201b6a4f..248660aecab 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -515,6 +515,43 @@ display: 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); + # Good Display GDEY0583T81 5.83" mono e-paper (648x480, UC8179) + - platform: epaper_spi + spi_id: spi_bus + model: goodisplay-gdey0583t81-5.83 + full_update_every: 5 + 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 + + # Seeed 0583BN-T81-D2, the GDEY0583T81 panel under its label name + - platform: epaper_spi + spi_id: spi_bus + model: 0583BN-T81-D2 + 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 + # Seeed reTerminal E1001 - 7.5" mono e-paper (800x480, UC8179) # Pins overridden to avoid conflicts with the E1002 defaults above - platform: epaper_spi From 3de1503ec0ad5e4a7bf48bf1af57dfe0c88a8a8b Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 4 Nov 2026 20:04:01 -0600 Subject: [PATCH 48/55] [api] Let clients fetch the crash recorded on the previous boot (#21596) --- esphome/components/api/api.proto | 38 +++++++++ esphome/components/api/api_connection.cpp | 35 +++++++- esphome/components/api/api_connection.h | 3 + esphome/components/api/api_pb2.cpp | 67 ++++++++++++++++ esphome/components/api/api_pb2.h | 69 +++++++++++++++- esphome/components/api/api_pb2_dump.cpp | 38 +++++++++ esphome/components/api/api_pb2_includes.h | 4 + esphome/components/api/api_pb2_service.cpp | 9 +++ esphome/components/api/api_pb2_service.h | 3 + esphome/components/esp32/__init__.py | 1 + esphome/components/esp32/crash_handler.cpp | 79 +++++++++++++++++++ esphome/components/esp8266/__init__.py | 1 + esphome/components/esp8266/crash_handler.cpp | 64 ++++++++++++--- esphome/components/libretiny/__init__.py | 1 + .../components/libretiny/crash_handler.cpp | 46 +++++++++++ esphome/components/rp2/__init__.py | 1 + esphome/components/rp2/crash_handler.cpp | 26 ++++++ esphome/core/crash_report.h | 70 ++++++++++++++++ esphome/core/defines.h | 1 + .../components/api/test_api_proto.py | 48 +++++++++++ 20 files changed, 590 insertions(+), 14 deletions(-) create mode 100644 esphome/core/crash_report.h diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index ffe8897d353..f4daa0154ca 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -22,6 +22,7 @@ service APIConnection { rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {} rpc device_wizard (DeviceWizardRequest) returns (DeviceWizardResponse) {} rpc wizard_input_set (WizardInputSetRequest) returns (void) {} + rpc crash_report (CrashReportRequest) returns (CrashReportResponse) {} rpc list_entities (ListEntitiesRequest) returns (void) {} rpc subscribe_states (SubscribeStatesRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} @@ -420,6 +421,12 @@ message SendspinCapabilities { string client_id = 2 [(max_data_length) = 43]; } +message CrashReportCapabilities { + // Bitmask of CrashReportFlag. ESP32 checks BUILD_MATCHES against the build time stored with the record; + // ESP8266, RP2 and LibreTiny store none and assume it. + uint32 flags = 1; +} + message DeviceCapabilitiesResponse { option (id) = 150; option (source) = SOURCE_SERVER; @@ -432,6 +439,7 @@ message DeviceCapabilitiesResponse { WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"]; SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"]; StoreYamlCapabilities store_yaml = 7 [(field_ifdef) = "USE_STORE_YAML"]; + CrashReportCapabilities crash_report = 8 [(field_ifdef) = "USE_CRASH_REPORT"]; } // ==================== DEVICE WIZARD ==================== @@ -545,6 +553,36 @@ message SubscribeStatesRequest { // Empty } +// ==================== CRASH REPORT ==================== + +// Send only when crash_report.flags has PRESENT. The report is dropped once sent. +message CrashReportRequest { + option (id) = 163; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_CRASH_REPORT"; + // Empty +} + +message CrashReportCore { + uint32 core = 1; + repeated uint32 backtrace = 2 [(fixed_array_with_length_define) = "CRASH_REPORT_MAX_BACKTRACE"]; +} + +message CrashReportResponse { + option (id) = 164; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_CRASH_REPORT"; + + uint32 flags = 1; // Bitmask of CrashReportFlag + uint32 reason = 2; // CrashReason + uint32 pc = 3; + uint32 cause = 4; + uint32 fault_address = 5; + uint32 build_time = 6; + uint32 crashed_core = 7; + repeated CrashReportCore cores = 8 [(fixed_array_with_length_define) = "CRASH_REPORT_MAX_CORES"]; +} + // ==================== COMMON ===================== enum EntityCategory { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index baf6352c4db..fbcc1cfb814 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1969,7 +1969,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 19; + resp.api_version_minor = 20; // 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.name = StringRef(App.get_name()); @@ -2163,6 +2163,11 @@ bool APIConnection::send_device_capabilities_response_() { #endif #ifdef USE_STORE_YAML resp.store_yaml.supported = true; +#endif +#ifdef USE_CRASH_REPORT + CrashReport report; + if (crash_report_get(report)) + resp.crash_report.flags = report.flags; #endif return this->send_message(resp); } @@ -2192,6 +2197,34 @@ void APIConnection::on_device_capabilities_request() { this->on_fatal_error(); } } +#ifdef USE_CRASH_REPORT +void APIConnection::on_crash_report_request() { + CrashReport report; + CrashReportResponse resp; + if (crash_report_get(report)) { + resp.flags = report.flags; + resp.reason = static_cast(report.reason); + resp.pc = report.pc; + resp.cause = report.cause; + resp.fault_address = report.fault_address; + resp.build_time = report.build_time; + resp.crashed_core = report.crashed_core; + for (uint8_t i = 0; i < report.core_count; i++) { + auto &core = resp.cores[i]; + core.core = report.cores[i].core; + core.backtrace = report.cores[i].addresses; + core.backtrace_len = report.cores[i].count; + } + resp.cores_len = report.core_count; + } + if (!this->send_message(resp)) { + this->on_fatal_error(); + return; + } + if (resp.flags & CRASH_REPORT_FLAG_PRESENT) + crash_report_clear(); +} +#endif #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 5a8af7e98c1..795c7e2e45e 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -285,6 +285,9 @@ class APIConnection final : public APIServerConnectionBase { #ifdef USE_API_WIZARD void on_device_wizard_request(); #endif +#ifdef USE_CRASH_REPORT + void on_crash_report_request(); +#endif #ifdef USE_API_WIZARD_INPUTS void on_wizard_input_set_request(const WizardInputSetRequest &msg); #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 560da29e27e..eb38d838fe8 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -340,6 +340,19 @@ uint32_t SendspinCapabilities::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_CRASH_REPORT +uint8_t *CrashReportCapabilities::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.flags); + return pos; +} +uint32_t CrashReportCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.flags); + return size; +} +#endif uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); #ifdef USE_BLUETOOTH_PROXY @@ -364,6 +377,9 @@ uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__res #endif #ifdef USE_STORE_YAML pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.store_yaml); +#endif +#ifdef USE_CRASH_REPORT + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.crash_report); #endif return pos; } @@ -392,6 +408,9 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { #endif #ifdef USE_STORE_YAML size += ProtoSize::calc_message(1, msg.store_yaml.calculate_size()); +#endif +#ifdef USE_CRASH_REPORT + size += ProtoSize::calc_message(1, msg.crash_report.calculate_size()); #endif return size; } @@ -424,6 +443,54 @@ void WizardInputSetRequest::decode_field(void *self, uint32_t tag, const uint8_t } } #endif +#ifdef USE_CRASH_REPORT +uint8_t *CrashReportCore::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.core); + for (uint16_t i = 0; i < msg.backtrace_len; i++) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.backtrace[i]); + } + return pos; +} +uint32_t CrashReportCore::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.core); + for (uint16_t i = 0; i < msg.backtrace_len; i++) { + size += ProtoSize::calc_uint32_force(1, msg.backtrace[i]); + } + return size; +} +uint8_t *CrashReportResponse::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.flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.reason); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.pc); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.cause); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.fault_address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.build_time); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.crashed_core); + for (uint16_t i = 0; i < msg.cores_len; i++) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.cores[i]); + } + return pos; +} +uint32_t CrashReportResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.flags); + size += ProtoSize::calc_uint32(1, msg.reason); + size += ProtoSize::calc_uint32(1, msg.pc); + size += ProtoSize::calc_uint32(1, msg.cause); + size += ProtoSize::calc_uint32(1, msg.fault_address); + size += ProtoSize::calc_uint32(1, msg.build_time); + size += ProtoSize::calc_uint32(1, msg.crashed_core); + for (uint16_t i = 0; i < msg.cores_len; i++) { + size += ProtoSize::calc_message_force(1, msg.cores[i].calculate_size()); + } + return size; +} +#endif #ifdef USE_BINARY_SENSOR uint8_t *ListEntitiesBinarySensorResponse::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 5c08455212d..3700f897292 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -788,10 +788,27 @@ class SendspinCapabilities final : public ProtoMessage { protected: }; #endif +#ifdef USE_CRASH_REPORT +class CrashReportCapabilities final : public ProtoMessage { + public: + uint32_t flags{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); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif class DeviceCapabilitiesResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 150; - static constexpr uint8_t ESTIMATED_SIZE = 153; + static constexpr uint8_t ESTIMATED_SIZE = 170; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } #endif @@ -815,6 +832,9 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #endif #ifdef USE_STORE_YAML StoreYamlCapabilities store_yaml{}; +#endif +#ifdef USE_CRASH_REPORT + CrashReportCapabilities crash_report{}; #endif 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 { @@ -885,6 +905,53 @@ class ListEntitiesDoneResponse final : public ProtoMessage { protected: }; +#ifdef USE_CRASH_REPORT +class CrashReportCore final : public ProtoMessage { + public: + uint32_t core{0}; + std::array backtrace{}; + uint16_t backtrace_len{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); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +class CrashReportResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 164; + static constexpr uint8_t ESTIMATED_SIZE = 164; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("crash_report_response"); } +#endif + uint32_t flags{0}; + uint32_t reason{0}; + uint32_t pc{0}; + uint32_t cause{0}; + uint32_t fault_address{0}; + uint32_t build_time{0}; + uint32_t crashed_core{0}; + std::array cores{}; + uint16_t cores_len{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); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif #ifdef USE_BINARY_SENSOR class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ab1af92fabc..084ec62e5a5 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1153,6 +1153,13 @@ const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_CRASH_REPORT +const char *CrashReportCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("CrashReportCapabilities")); + dump_field(out, ESPHOME_PSTR("flags"), this->flags); + return out.c_str(); +} +#endif const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); #ifdef USE_BLUETOOTH_PROXY @@ -1191,6 +1198,11 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { out.append(2, ' ').append_p(ESPHOME_PSTR("store_yaml")).append(": "); this->store_yaml.dump_to(out); out.append("\n"); +#endif +#ifdef USE_CRASH_REPORT + out.append(2, ' ').append_p(ESPHOME_PSTR("crash_report")).append(": "); + this->crash_report.dump_to(out); + out.append("\n"); #endif return out.c_str(); } @@ -1213,6 +1225,32 @@ const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}")); return out.c_str(); } +#ifdef USE_CRASH_REPORT +const char *CrashReportCore::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("CrashReportCore")); + dump_field(out, ESPHOME_PSTR("core"), this->core); + for (uint16_t i = 0; i < this->backtrace_len; i++) { + dump_field(out, ESPHOME_PSTR("backtrace"), this->backtrace[i], 4); + } + return out.c_str(); +} +const char *CrashReportResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("CrashReportResponse")); + dump_field(out, ESPHOME_PSTR("flags"), this->flags); + dump_field(out, ESPHOME_PSTR("reason"), this->reason); + dump_field(out, ESPHOME_PSTR("pc"), this->pc); + dump_field(out, ESPHOME_PSTR("cause"), this->cause); + dump_field(out, ESPHOME_PSTR("fault_address"), this->fault_address); + dump_field(out, ESPHOME_PSTR("build_time"), this->build_time); + dump_field(out, ESPHOME_PSTR("crashed_core"), this->crashed_core); + for (uint16_t i = 0; i < this->cores_len; i++) { + out.append(4, ' ').append_p(ESPHOME_PSTR("cores")).append(": "); + this->cores[i].dump_to(out); + out.append("\n"); + } + return out.c_str(); +} +#endif #ifdef USE_BINARY_SENSOR const char *ListEntitiesBinarySensorResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesBinarySensorResponse")); diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index e076f98fe3d..239b919c4e9 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -27,6 +27,10 @@ #include "esphome/components/select/select_traits.h" #endif +#ifdef USE_CRASH_REPORT +#include "esphome/core/crash_report.h" +#endif + // Standard library includes that might be needed #include #include diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index dd9736bae10..0f21858fc8c 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -769,6 +769,15 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_get_yaml_request(); break; } +#endif +#ifdef USE_CRASH_REPORT + case 163 /* CrashReportRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_crash_report_request")); +#endif + this->on_crash_report_request(); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index f246d8d21fc..3e7f7254f46 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -39,6 +39,9 @@ class APIServerConnectionBase { void on_list_entities_request() {}; void on_subscribe_states_request() {}; +#ifdef USE_CRASH_REPORT + void on_crash_report_request() {}; +#endif #ifdef USE_COVER void on_cover_command_request(const CoverCommandRequest &value) {}; diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0a17832e85c..50be9c56be8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3003,6 +3003,7 @@ async def _finalize_arduino_aware_flags(): cg.add_build_flag("-Wl,--wrap=esp_panic_handler") cg.add_define("USE_ESP32_CRASH_HANDLER") cg.add_define("USE_CRASH_HANDLER") + cg.add_define("USE_CRASH_REPORT") elif not CORE.using_arduino: cg.add_build_flag("-DUSE_ARDUINO") diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 99380139240..b0c22535913 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -5,6 +5,7 @@ #include "crash_handler.h" #include "esphome/core/build_info_data.h" +#include "esphome/core/crash_report.h" #include "esphome/core/log.h" #include @@ -500,6 +501,84 @@ void crash_handler_log() { } // namespace esphome::esp32 +namespace esphome { + +static_assert(MAX_BACKTRACE <= CRASH_REPORT_MAX_BACKTRACE, "Crash report backtrace too small for the record"); + +// The record itself stays valid for log replay this boot +static bool s_report_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Filtered as in log_backtrace() +static uint8_t copy_backtrace(const uint32_t *addrs, uint8_t count, [[maybe_unused]] uint8_t reg_frame_count, + [[maybe_unused]] bool filter, uint32_t *out) { + uint8_t out_count = 0; + for (uint8_t i = 0; i < count; i++) { +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (filter && i >= reg_frame_count && !is_return_addr(addrs[i])) + continue; +#endif + out[out_count++] = addrs[i]; + } + return out_count; +} + +static CrashReason get_crash_reason() { + switch (s_raw_crash_data.exception) { + case PANIC_EXCEPTION_ABORT: + return CrashReason::CRASH_REASON_ABORT; + case PANIC_EXCEPTION_TWDT: + return CrashReason::CRASH_REASON_TASK_WATCHDOG; + case PANIC_EXCEPTION_IWDT: + return CrashReason::CRASH_REASON_INTERRUPT_WATCHDOG; + default: + return CrashReason::CRASH_REASON_EXCEPTION; + } +} + +bool crash_report_get(CrashReport &out) { + if (s_report_read || !esp32::crash_handler_has_data()) + return false; + out = {}; + out.flags = CRASH_REPORT_FLAG_PRESENT; + const bool build_matches = s_raw_crash_data.build_time == esp32::s_current_build_time; + if (build_matches) + out.flags |= CRASH_REPORT_FLAG_BUILD_MATCHES; + out.reason = get_crash_reason(); + out.crashed_core = s_raw_crash_data.crashed_core; + out.pc = s_raw_crash_data.pc; + out.build_time = s_raw_crash_data.build_time; + // A pseudo cause is an IDF panic reason + if (s_raw_crash_data.exception != PANIC_EXCEPTION_ABORT && s_raw_crash_data.exception != PANIC_EXCEPTION_TWDT && + !s_raw_crash_data.pseudo_excause && esp32::cause_slot_was_written()) { + out.flags |= CRASH_REPORT_FLAG_CAUSE; + out.cause = s_raw_crash_data.cause; + } + if (esp32::has_fault_addr()) { + out.flags |= CRASH_REPORT_FLAG_FAULT_ADDRESS; + out.fault_address = s_raw_crash_data.fault_addr; + } + auto &crashed = out.cores[out.core_count++]; + crashed.core = s_raw_crash_data.crashed_core; + crashed.count = copy_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count, build_matches, crashed.addresses.data()); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + auto &other = out.cores[out.core_count++]; + other.core = 1 - s_raw_crash_data.crashed_core; + other.count = copy_backtrace(s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, + s_raw_crash_data.other_reg_frame_count, build_matches, other.addresses.data()); + } +#endif + return true; +} + +void crash_report_clear() { + s_report_read = true; + esp32::crash_handler_clear(); +} + +} // namespace esphome + // --- Panic handler wrapper --- // Intercepts esp_panic_handler() via --wrap linker flag to capture crash data // into NOINIT memory before the normal panic handler runs. diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index c980b2d8919..ac245c957f4 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -424,6 +424,7 @@ async def to_code(config: ConfigType) -> None: cg.add_define(ThreadModel.SINGLE) cg.add_define("USE_ESP8266_CRASH_HANDLER") cg.add_define("USE_CRASH_HANDLER") + cg.add_define("USE_CRASH_REPORT") enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index 8513d213155..ceffa53847e 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -5,6 +5,7 @@ #include "crash_handler.h" #include "rtc_layout.h" +#include "esphome/core/crash_report.h" #include "esphome/core/log.h" #include @@ -123,6 +124,21 @@ static const LogString *get_reset_reason(uint32_t reason) { return LOG_STR("Unknown"); } +// GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific +// ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match +// the Arduino core's postmortem handler behavior. +static uint32_t get_exccause() { + static constexpr uint32_t EXCCAUSE_ILLEGAL_INSTRUCTION = 0; + static constexpr uint32_t EXCCAUSE_INTEGER_DIVIDE_BY_ZERO = 6; + static constexpr uint32_t ROM_DIV_ZERO_ADDR_1 = 0x4000dce5; + static constexpr uint32_t ROM_DIV_ZERO_ADDR_2 = 0x4000dd3d; + if (resetInfo.exccause == EXCCAUSE_ILLEGAL_INSTRUCTION && + (resetInfo.epc1 == ROM_DIV_ZERO_ADDR_1 || resetInfo.epc1 == ROM_DIV_ZERO_ADDR_2)) { + return EXCCAUSE_INTEGER_DIVIDE_BY_ZERO; + } + return resetInfo.exccause; +} + // Read backtrace from RTC user memory into caller-provided buffer. // Returns the number of valid backtrace entries (0 if no data found). static uint8_t read_rtc_backtrace(uint32_t *backtrace, size_t max_entries) { @@ -172,18 +188,7 @@ void crash_handler_log() { // called multiple times (logger init + API subscribe) with the same result. uint32_t backtrace[MAX_BACKTRACE]; uint8_t bt_count = read_rtc_backtrace(backtrace, MAX_BACKTRACE); - // GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific - // ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match - // the Arduino core's postmortem handler behavior. - static constexpr uint32_t EXCCAUSE_ILLEGAL_INSTRUCTION = 0; - static constexpr uint32_t EXCCAUSE_INTEGER_DIVIDE_BY_ZERO = 6; - static constexpr uint32_t ROM_DIV_ZERO_ADDR_1 = 0x4000dce5; - static constexpr uint32_t ROM_DIV_ZERO_ADDR_2 = 0x4000dd3d; - uint32_t exccause = resetInfo.exccause; - if (exccause == EXCCAUSE_ILLEGAL_INSTRUCTION && - (resetInfo.epc1 == ROM_DIV_ZERO_ADDR_1 || resetInfo.epc1 == ROM_DIV_ZERO_ADDR_2)) { - exccause = EXCCAUSE_INTEGER_DIVIDE_BY_ZERO; - } + uint32_t exccause = get_exccause(); const LogString *cause = get_exception_cause(exccause); if (cause != nullptr) { ESP_LOGE(TAG, " Reason: %s - %s (exccause=%" PRIu32 ")", LOG_STR_ARG(get_reset_reason(resetInfo.reason)), @@ -202,6 +207,41 @@ void crash_handler_log() { } // namespace esphome::esp8266 +namespace esphome { + +static_assert(MAX_BACKTRACE <= CRASH_REPORT_MAX_BACKTRACE, "Crash report backtrace too small for the record"); + +static bool s_report_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_report_get(CrashReport &out) { + if (s_report_read || !esp8266::crash_handler_has_data()) + return false; + out = {}; + // Never survives into another build + out.flags = CRASH_REPORT_FLAG_PRESENT | CRASH_REPORT_FLAG_BUILD_MATCHES; + if (resetInfo.reason == REASON_WDT_RST) { + // Nothing else is recorded for a hardware WDT reset + out.reason = CrashReason::CRASH_REASON_HARDWARE_WATCHDOG; + return true; + } + out.pc = resetInfo.epc1; + if (resetInfo.reason == REASON_EXCEPTION_RST) { + out.reason = CrashReason::CRASH_REASON_EXCEPTION; + out.flags |= CRASH_REPORT_FLAG_CAUSE | CRASH_REPORT_FLAG_FAULT_ADDRESS; + out.cause = esp8266::get_exccause(); + out.fault_address = resetInfo.excvaddr; + } else { + out.reason = CrashReason::CRASH_REASON_SOFT_WATCHDOG; + } + out.core_count = 1; + out.cores[0].count = esp8266::read_rtc_backtrace(out.cores[0].addresses.data(), MAX_BACKTRACE); + return true; +} + +void crash_report_clear() { s_report_read = true; } + +} // namespace esphome + // --- Custom crash callback --- // Overrides the weak custom_crash_callback() from Arduino core's // core_esp8266_postmortem.cpp. Called during exception handling before diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index d31212e1942..c905a1671da 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -568,6 +568,7 @@ async def component_to_code(config): # Crash handler (crash_handler.cpp) cg.add_define("USE_LIBRETINY_CRASH_HANDLER") cg.add_define("USE_CRASH_HANDLER") + cg.add_define("USE_CRASH_REPORT") cg.add_build_flag("-Wl,--wrap=abort") if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238): for trap in BK_TRAP_HANDLERS: diff --git a/esphome/components/libretiny/crash_handler.cpp b/esphome/components/libretiny/crash_handler.cpp index 6616caaa8e2..24e8ab41438 100644 --- a/esphome/components/libretiny/crash_handler.cpp +++ b/esphome/components/libretiny/crash_handler.cpp @@ -11,6 +11,7 @@ #include "core.h" #include +#include "esphome/core/crash_report.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -452,5 +453,50 @@ extern "C" [[noreturn]] void __wrap_abort() { } // namespace esphome::libretiny +namespace esphome { + +static_assert(libretiny::MAX_BACKTRACE + 1 <= CRASH_REPORT_MAX_BACKTRACE, + "Crash report backtrace too small for the record"); + +static bool s_report_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_report_get(CrashReport &out) { + if (s_report_read || !libretiny::crash_handler_has_data()) + return false; + const auto &record = libretiny::s_record; + out = {}; + // The record has no build stamp; like ESP8266 and RP2, assume the running firmware wrote it + out.flags = CRASH_REPORT_FLAG_PRESENT | CRASH_REPORT_FLAG_BUILD_MATCHES; + if (record.reason == libretiny::CRASH_REASON_ABORT) { + out.reason = CrashReason::CRASH_REASON_ABORT; + } else { + out.reason = record.reason != 0 ? CrashReason::CRASH_REASON_EXCEPTION : CrashReason::CRASH_REASON_UNKNOWN; + } + out.pc = record.pc; +#ifdef USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH + // Cortex-M CFSR, and the BFAR or MMFAR its valid bits mark (0 is a real address for a null dereference); + // BK72xx records neither + if (record.fault_status != 0) { + out.flags |= CRASH_REPORT_FLAG_CAUSE; + out.cause = record.fault_status; + } + if (record.fault_status & (libretiny::CFSR_BFARVALID | libretiny::CFSR_MMARVALID)) { + out.flags |= CRASH_REPORT_FLAG_FAULT_ADDRESS; + out.fault_address = record.fault_address; + } +#endif + auto &core = out.cores[out.core_count++]; + // 0 for abort(), and for a Cortex-M frame outside SRAM + if (record.lr != 0) + core.addresses[core.count++] = record.lr; + for (size_t i = 0; i < libretiny::MAX_BACKTRACE && record.backtrace[i] != 0; i++) + core.addresses[core.count++] = record.backtrace[i]; + return true; +} + +void crash_report_clear() { s_report_read = true; } + +} // namespace esphome + #endif // USE_LIBRETINY_CRASH_HANDLER #endif // USE_LIBRETINY diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index e28b4913fee..0baeb9833c7 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -394,6 +394,7 @@ async def to_code(config: ConfigType) -> None: ) # back-compat alias cg.add_define("USE_RP2_CRASH_HANDLER") cg.add_define("USE_CRASH_HANDLER") + cg.add_define("USE_CRASH_REPORT") cg.add_define("USE_RP2040_CRASH_HANDLER") # back-compat alias _configure_lwip() diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index 0741cc24e39..c94d3c1826b 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -4,6 +4,7 @@ #ifdef USE_RP2_CRASH_HANDLER #include "crash_handler.h" +#include "esphome/core/crash_report.h" #include "esphome/core/log.h" #include @@ -131,6 +132,31 @@ void crash_handler_log() { } // namespace esphome::rp2 +namespace esphome { + +static_assert(MAX_BACKTRACE + 1 <= CRASH_REPORT_MAX_BACKTRACE, "Crash report backtrace too small for the record"); + +static bool s_report_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +bool crash_report_get(CrashReport &out) { + if (s_report_read || !rp2::crash_handler_has_data()) + return false; + out = {}; + // Never survives into another build + out.flags = CRASH_REPORT_FLAG_PRESENT | CRASH_REPORT_FLAG_BUILD_MATCHES; + out.reason = CrashReason::CRASH_REASON_EXCEPTION; + out.pc = rp2::s_crash_data.pc; + auto &core = out.cores[out.core_count++]; + core.addresses[core.count++] = rp2::s_crash_data.lr; + for (uint8_t i = 0; i < rp2::s_crash_data.backtrace_count; i++) + core.addresses[core.count++] = rp2::s_crash_data.backtrace[i]; + return true; +} + +void crash_report_clear() { s_report_read = true; } + +} // namespace esphome + // --- HardFault handler --- // Overrides the weak isr_hardfault from arduino-pico's crt0.S. // On Cortex-M0+, the CPU pushes {R0,R1,R2,R3,R12,LR,PC,xPSR} onto the diff --git a/esphome/core/crash_report.h b/esphome/core/crash_report.h new file mode 100644 index 00000000000..dac7faa7ca0 --- /dev/null +++ b/esphome/core/crash_report.h @@ -0,0 +1,70 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_CRASH_REPORT + +#include +#include + +#ifdef USE_ESP32 +#include +#endif + +namespace esphome { + +/// Per core +static constexpr uint8_t CRASH_REPORT_MAX_BACKTRACE = 16; +#ifdef USE_ESP32 +static constexpr uint8_t CRASH_REPORT_MAX_CORES = SOC_CPU_CORES_NUM; +#else +static constexpr uint8_t CRASH_REPORT_MAX_CORES = 1; +#endif + +/// Sent as is over the native API +enum CrashReportFlag : uint8_t { + CRASH_REPORT_FLAG_NONE = 0, + CRASH_REPORT_FLAG_PRESENT = 1 << 0, // Stored and not yet read + CRASH_REPORT_FLAG_BUILD_MATCHES = 1 << 1, // Recorded by the running firmware; assumed on ESP8266, RP2 and LibreTiny + CRASH_REPORT_FLAG_CAUSE = 1 << 2, // cause is valid + CRASH_REPORT_FLAG_FAULT_ADDRESS = 1 << 3, // fault_address is valid +}; + +/// Sent as is over the native API +enum class CrashReason : uint8_t { + CRASH_REASON_UNKNOWN = 0, + CRASH_REASON_EXCEPTION = 1, + CRASH_REASON_ABORT = 2, + CRASH_REASON_TASK_WATCHDOG = 3, + CRASH_REASON_INTERRUPT_WATCHDOG = 4, + CRASH_REASON_SOFT_WATCHDOG = 5, + CRASH_REASON_HARDWARE_WATCHDOG = 6, // Only the reason is recorded +}; + +struct CrashCoreBacktrace { + uint8_t core{0}; + uint8_t count{0}; + std::array addresses{}; +}; + +struct CrashReport { + uint8_t flags{CRASH_REPORT_FLAG_NONE}; + CrashReason reason{CrashReason::CRASH_REASON_UNKNOWN}; + uint8_t crashed_core{0}; + uint8_t core_count{0}; + uint32_t pc{0}; + uint32_t cause{0}; + uint32_t fault_address{0}; + uint32_t build_time{0}; + std::array cores{}; +}; + +/// The previous boot's crash; false when there is none or it was read. Implemented per platform. +bool crash_report_get(CrashReport &out); + +/// Mark the report read; it also no longer survives a reboot. +void crash_report_clear(); + +} // namespace esphome + +#endif // USE_CRASH_REPORT diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 816c956aa1b..0c19a8aaae3 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -72,6 +72,7 @@ #define USE_CONTROLLER_REGISTRY #define USE_COVER #define USE_CRASH_HANDLER +#define USE_CRASH_REPORT #define USE_DATETIME #define USE_DATETIME_DATE #define USE_DATETIME_DATETIME diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index 68e3cab2a21..99551e45e68 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -69,9 +69,11 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { "serial_proxies": 4, "wizard": 5, "sendspin": 6, + "crash_report": 8, }, "WizardCapabilities": {"configured": 1}, "SendspinCapabilities": {"feature_flags": 1, "client_id": 2}, + "CrashReportCapabilities": {"flags": 1}, "BluetoothProxyCapabilities": { "feature_flags": 1, "mac_address": 2, @@ -484,6 +486,52 @@ def test_sendspin_pairing_token_rpc_requires_authentication() -> None: # ==================== Group C: advertised API version ==================== +def test_crash_report_messages_keep_their_wire_ids() -> None: + """Message ids are part of the wire protocol and must not change.""" + for message, expected in ( + ("CrashReportRequest", 163), + ("CrashReportResponse", 164), + ): + body = _extract_proto_message(PROTO_TEXT, message) + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, f"{message} is missing `option (id)`" + assert int(match.group(1)) == expected, ( + f"{message} has id {match.group(1)}, expected {expected}" + ) + + +def test_crash_report_response_fields_keep_their_wire_numbers() -> None: + """Clients decode the response purely by field number.""" + for message, fields in ( + ( + "CrashReportResponse", + ( + ("flags", 1), + ("reason", 2), + ("pc", 3), + ("cause", 4), + ("fault_address", 5), + ("build_time", 6), + ("crashed_core", 7), + ("cores", 8), + ), + ), + ("CrashReportCore", (("core", 1), ("backtrace", 2))), + ): + body = _extract_proto_message(PROTO_TEXT, message) + for field_name, number in fields: + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{number}\b", line), ( + f"{field_name} in {message} is no longer field number {number}" + ) + + +def test_crash_report_rpc_requires_authentication() -> None: + """The rpc inherits the default of needs_authentication = true.""" + body = _extract_rpc_body(PROTO_TEXT, "crash_report") + assert "needs_authentication" not in body + + def test_api_version_minor_is_at_least_15() -> None: """Clients gate sending DeviceCapabilitiesRequest on seeing api_version >= 1.15 in HelloResponse. Regressing api_version_minor below From 1fa3fe26bba60a8f07f348445e5167e1886a3858 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 20:05:52 -0600 Subject: [PATCH 49/55] [ezo] Replace the heap command queue with a fixed ring buffer (#21840) --- esphome/components/ezo/ezo.cpp | 361 +++++++++++++-------- esphome/components/ezo/ezo.h | 81 +++-- tests/components/ezo/test_ezo_commands.cpp | 25 ++ tests/components/ezo/test_ezo_queue.cpp | 248 ++++++++++++++ tests/components/ezo/test_ezo_reply.cpp | 61 ++++ 5 files changed, 614 insertions(+), 162 deletions(-) create mode 100644 tests/components/ezo/test_ezo_queue.cpp create mode 100644 tests/components/ezo/test_ezo_reply.cpp diff --git a/esphome/components/ezo/ezo.cpp b/esphome/components/ezo/ezo.cpp index d974a5c47fe..4e45f2d91d2 100644 --- a/esphome/components/ezo/ezo.cpp +++ b/esphome/components/ezo/ezo.cpp @@ -1,4 +1,5 @@ #include "ezo.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" #include "esphome/core/progmem.h" @@ -8,7 +9,57 @@ namespace esphome::ezo { PROGMEM_STRING_TABLE(EzoCommandTypeStrings, "EZO_READ", "EZO_LED", "EZO_DEVICE_INFORMATION", "EZO_SLOPE", "EZO_CALIBRATION", "EZO_SLEEP", "EZO_I2C", "EZO_T", "EZO_CUSTOM"); -PROGMEM_STRING_TABLE(EzoCalibrationTypeStrings, "LOW", "MID", "HIGH"); +// Indexed by EzoCommandId, up to I2C; ids from CAL_LOW to I2C get their value appended +PROGMEM_STRING_TABLE(EzoCommandTextStrings, "i", "Sleep", "Slope,?", "T,?", "Cal,?", "Cal,clear", "L,?", "L,0", "L,1", + "R", "Cal,LOW,", "Cal,MID,", "Cal,HIGH,", "Cal,", "T,", "I2C,"); +static_assert(EzoCommandTextStrings::COUNT == static_cast(EzoCommandId::EZO_COMMAND_ID_CUSTOM)); +static_assert(EzoCommandTextStrings::MAX_LENGTH <= EZO_MAX_COMMAND_LENGTH); + +// Indexed by EzoCommandId +static constexpr uint8_t REPLY_TYPES[] PROGMEM = { + EZO_DEVICE_INFORMATION, + EZO_SLEEP, + EZO_SLOPE, + EZO_T, + EZO_CALIBRATION, + EZO_CALIBRATION, + EZO_LED, + EZO_LED, + EZO_LED, + EZO_READ, + EZO_CALIBRATION, + EZO_CALIBRATION, + EZO_CALIBRATION, + EZO_CALIBRATION, + EZO_T, + EZO_I2C, + EZO_CUSTOM, +}; +static_assert(sizeof(REPLY_TYPES) == static_cast(EzoCommandId::EZO_COMMAND_ID_CUSTOM) + 1); + +static EzoCommandType reply_type(EzoCommandId id) { + return static_cast(progmem_read_byte(&REPLY_TYPES[static_cast(id)])); +} + +static constexpr uint32_t LONG_DELAY_MS = 900; // reads and calibration points +static constexpr uint32_t COMMAND_DELAY_MS = 300; +static constexpr size_t MAX_REPLY_LENGTH = 52; + +static uint32_t reply_delay_ms(EzoCommandId id) { + return id >= EzoCommandId::EZO_COMMAND_ID_READ && id <= EzoCommandId::EZO_COMMAND_ID_CAL_GENERIC ? LONG_DELAY_MS + : COMMAND_DELAY_MS; +} + +// Writes a built-in command's text and returns its length. Values get two decimals and the I2C address none; +// the other commands ignore the value. +static size_t render_command(EzoCommandId id, float value, char *text) { + const int length = ESPHOME_snprintf_P( + text, EZO_MAX_COMMAND_LENGTH + 1, + id >= EzoCommandId::EZO_COMMAND_ID_CAL_LOW ? ESPHOME_PSTR("%s%.*f") : ESPHOME_PSTR("%s"), + LOG_STR_ARG(EzoCommandTextStrings::get_log_str(static_cast(id), EzoCommandTextStrings::LAST_INDEX)), + id == EzoCommandId::EZO_COMMAND_ID_I2C ? 0 : 2, value); + return length < 0 ? 0 : std::min(static_cast(length), EZO_MAX_COMMAND_LENGTH); +} void EZOSensor::dump_config() { LOG_SENSOR("", "EZO", this); @@ -17,73 +68,89 @@ void EZOSensor::dump_config() { } void EZOSensor::update() { - // Check if a read is in there already and if not insert on in the second position - - if (!this->commands_.empty() && this->commands_.front()->command_type != EzoCommandType::EZO_READ && - this->commands_.size() > 1) { - bool found = false; - - for (auto &i : this->commands_) { - if (i->command_type == EzoCommandType::EZO_READ) { - found = true; - break; - } - } - - if (!found) { - auto ezo_command = make_unique(); - ezo_command->command = "R"; - ezo_command->command_type = EzoCommandType::EZO_READ; - ezo_command->delay_ms = 900; - - auto it = this->commands_.begin(); - ++it; - this->commands_.insert(it, std::move(ezo_command)); - } + this->read_pending_ = true; + this->enable_loop(); +} +void EZOSensor::send_next_() { + const uint32_t now = App.get_loop_component_start_time(); + const bool after_read = this->state_ == EzoState::EZO_STATE_IDLE_AFTER_READ; + // A read goes right after the command in flight, but never twice in a row while others wait. + if (this->read_pending_ && (!after_read || this->queue_.empty())) { + ESP_LOGVV(TAG, "Sending command \"R\""); + static constexpr uint8_t READ_COMMAND = 'R'; + this->write(&READ_COMMAND, 1); + this->read_pending_ = false; + this->state_ = EzoState::EZO_STATE_SENT_READ; + this->reply_due_ = now + LONG_DELAY_MS; + return; + } + if (this->queue_.empty()) { + this->disable_loop(); return; } - this->get_state(); + const EzoCommand &command = this->queue_.front(); + const EzoCommandId id = command.id; + char text[EZO_MAX_COMMAND_LENGTH + 1]; + size_t length; + if (id == EzoCommandId::EZO_COMMAND_ID_CUSTOM) { + length = command.text_length; + const size_t first = std::min(length, EZO_CUSTOM_TEXT_SIZE - this->custom_text_start_); + memcpy(text, this->custom_text_ + this->custom_text_start_, first); + memcpy(text + first, this->custom_text_, length - first); + text[length] = '\0'; + } else { + length = render_command(id, command.value, text); + } + ESP_LOGVV(TAG, "Sending command \"%s\"", text); + this->write(reinterpret_cast(text), length); + + if (id == EzoCommandId::EZO_COMMAND_ID_SLEEP || id == EzoCommandId::EZO_COMMAND_ID_I2C) { + // No reply; each queued address change applies its own target + if (id == EzoCommandId::EZO_COMMAND_ID_I2C) + this->address_ = static_cast(command.value); + this->queue_.pop(); + this->state_ = EzoState::EZO_STATE_IDLE; + return; + } + + this->state_ = EzoState::EZO_STATE_SENT_QUEUED; + this->reply_due_ = now + reply_delay_ms(id); +} + +void EZOSensor::finish_command_() { + if (this->state_ == EzoState::EZO_STATE_SENT_READ) { + this->state_ = EzoState::EZO_STATE_IDLE_AFTER_READ; + return; + } + const EzoCommand &command = this->queue_.front(); + if (command.id == EzoCommandId::EZO_COMMAND_ID_CUSTOM) { + this->custom_text_start_ = (this->custom_text_start_ + command.text_length) % EZO_CUSTOM_TEXT_SIZE; + this->custom_text_used_ -= command.text_length; + } + this->queue_.pop(); + this->state_ = EzoState::EZO_STATE_IDLE; } void EZOSensor::loop() { - if (this->commands_.empty()) { + if (this->state_ == EzoState::EZO_STATE_IDLE || this->state_ == EzoState::EZO_STATE_IDLE_AFTER_READ) { + this->send_next_(); return; } - EzoCommand *to_run = this->commands_.front().get(); - - if (!to_run->command_sent) { - const uint8_t *data = reinterpret_cast(to_run->command.c_str()); - ESP_LOGVV(TAG, "Sending command \"%s\"", data); - - this->write(data, to_run->command.length()); - - if (to_run->command_type == EzoCommandType::EZO_SLEEP || - to_run->command_type == EzoCommandType::EZO_I2C) { // Commands with no return data - bool update_address = to_run->command_type == EzoCommandType::EZO_I2C; - this->commands_.pop_front(); - if (update_address) - this->address_ = this->new_address_; - return; - } - - this->start_time_ = millis(); - to_run->command_sent = true; - return; - } - - if (millis() - this->start_time_ < to_run->delay_ms) + if (static_cast(App.get_loop_component_start_time() - this->reply_due_) < 0) return; - uint8_t buf[32]; - + // Response code, then up to 52 characters (the largest documented reply, EZO-RGB), then a terminator + // that is always set, since a reply that fills the read has none. + uint8_t buf[MAX_REPLY_LENGTH + 2]; buf[0] = 0; + buf[MAX_REPLY_LENGTH + 1] = 0; - if (!this->read_bytes_raw(buf, 32)) { + if (!this->read_bytes_raw(buf, MAX_REPLY_LENGTH + 1)) { ESP_LOGE(TAG, "read error"); - this->commands_.pop_front(); + this->finish_command_(); return; } @@ -103,138 +170,148 @@ void EZOSensor::loop() { break; } + const EzoCommandType command_type = + this->state_ == EzoState::EZO_STATE_SENT_READ ? EzoCommandType::EZO_READ : reply_type(this->queue_.front().id); + // Free the slot first so the callbacks below can queue a follow-up + this->finish_command_(); ESP_LOGV(TAG, "Received buffer \"%s\" for command type %s", &buf[1], - LOG_STR_ARG(EzoCommandTypeStrings::get_log_str(to_run->command_type, EzoCommandTypeStrings::LAST_INDEX))); + LOG_STR_ARG(EzoCommandTypeStrings::get_log_str(command_type, EzoCommandTypeStrings::LAST_INDEX))); - if (buf[0] == 1) { - std::string payload = reinterpret_cast(&buf[1]); - if (!payload.empty()) { - auto start_location = payload.find(','); - switch (to_run->command_type) { - case EzoCommandType::EZO_READ: { - // some sensors return multiple comma-separated values, terminate string after first one - if (start_location != std::string::npos) { - payload.erase(start_location); - } - auto val = parse_number(payload); - if (!val.has_value()) { - ESP_LOGW(TAG, "Can't convert '%s' to number!", payload.c_str()); - } else { - this->publish_state(*val); - } - break; - } - case EzoCommandType::EZO_LED: - this->led_callback_.call(payload.back() == '1'); - break; - case EzoCommandType::EZO_DEVICE_INFORMATION: - if (start_location != std::string::npos) { - this->device_infomation_callback_.call(payload.substr(start_location + 1)); - } - break; - case EzoCommandType::EZO_SLOPE: - if (start_location != std::string::npos) { - this->slope_callback_.call(payload.substr(start_location + 1)); - } - break; - case EzoCommandType::EZO_CALIBRATION: - if (start_location != std::string::npos) { - this->calibration_callback_.call(payload.substr(start_location + 1)); - } - break; - case EzoCommandType::EZO_T: - if (start_location != std::string::npos) { - this->t_callback_.call(payload.substr(start_location + 1)); - } - break; - case EzoCommandType::EZO_CUSTOM: - this->custom_callback_.call(payload); - break; - default: - break; + char *payload = reinterpret_cast(&buf[1]); + if (buf[0] != 1 || payload[0] == '\0') + return; + char *comma = strchr(payload, ','); + const char *text = comma != nullptr ? comma + 1 : nullptr; + CallbackManager *callback = nullptr; + switch (command_type) { + case EzoCommandType::EZO_READ: { + // some sensors return multiple comma-separated values, terminate string after first one + if (comma != nullptr) + *comma = '\0'; + auto val = parse_number(payload); + if (!val.has_value()) { + ESP_LOGW(TAG, "Can't convert '%s' to number!", payload); + } else { + this->publish_state(*val); } + return; } + case EzoCommandType::EZO_LED: + this->led_callback_.call(payload[strlen(payload) - 1] == '1'); + return; + case EzoCommandType::EZO_DEVICE_INFORMATION: + callback = &this->device_infomation_callback_; + break; + case EzoCommandType::EZO_SLOPE: + callback = &this->slope_callback_; + break; + case EzoCommandType::EZO_CALIBRATION: + callback = &this->calibration_callback_; + break; + case EzoCommandType::EZO_T: + callback = &this->t_callback_; + break; + case EzoCommandType::EZO_CUSTOM: + callback = &this->custom_callback_; + text = payload; + break; + default: + return; } - this->commands_.pop_front(); + if (text != nullptr) + callback->call(std::string(text)); } -void EZOSensor::add_command_(const char *command, EzoCommandType command_type, uint16_t delay_ms) { - auto ezo_command = make_unique(); - ezo_command->command = command; - ezo_command->command_type = command_type; - ezo_command->delay_ms = delay_ms; - this->commands_.push_back(std::move(ezo_command)); -} - -void EZOSensor::set_calibration_point_(EzoCalibrationType type, float value) { - // max 21: "Cal,"(4) + type(4) + ","(1) + float(11) + null; use 24 for safety - char payload[24]; - ESPHOME_snprintf_P(payload, sizeof(payload), ESPHOME_PSTR("Cal,%s,%0.2f"), - LOG_STR_ARG(EzoCalibrationTypeStrings::get_log_str(type, EzoCalibrationTypeStrings::LAST_INDEX)), - value); - this->add_command_(payload, EzoCommandType::EZO_CALIBRATION, 900); +void EZOSensor::add_command_(EzoCommandId id, float value) { + if (!this->queue_.push(EzoCommand{id, {value}})) { + ESP_LOGW( + TAG, "Command queue full, dropped \"%s\"", + LOG_STR_ARG(EzoCommandTextStrings::get_log_str(static_cast(id), EzoCommandTextStrings::LAST_INDEX))); + return; + } + this->enable_loop(); } void EZOSensor::set_address(uint8_t address) { if (address > 0 && address < 128) { - // max 8: "I2C,"(4) + uint8(3) + null - char payload[8]; - ESPHOME_snprintf_P(payload, sizeof(payload), ESPHOME_PSTR("I2C,%u"), address); - this->new_address_ = address; - this->add_command_(payload, EzoCommandType::EZO_I2C); + this->add_command_(EzoCommandId::EZO_COMMAND_ID_I2C, address); } else { ESP_LOGE(TAG, "Invalid I2C address"); } } -void EZOSensor::get_device_information() { this->add_command_("i", EzoCommandType::EZO_DEVICE_INFORMATION); } +void EZOSensor::get_device_information() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_DEVICE_INFORMATION); } -void EZOSensor::set_sleep() { this->add_command_("Sleep", EzoCommandType::EZO_SLEEP); } +void EZOSensor::set_sleep() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_SLEEP); } -void EZOSensor::get_state() { this->add_command_("R", EzoCommandType::EZO_READ, 900); } +void EZOSensor::get_state() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_READ); } -void EZOSensor::get_slope() { this->add_command_("Slope,?", EzoCommandType::EZO_SLOPE); } +void EZOSensor::get_slope() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_SLOPE); } -void EZOSensor::get_t() { this->add_command_("T,?", EzoCommandType::EZO_T); } +void EZOSensor::get_t() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_T_GET); } void EZOSensor::set_t(float value) { - // max 14 bytes: "T,"(2) + float with "%0.2f" (up to 11 chars) + null(1); use 16 for alignment - char payload[16]; - ESPHOME_snprintf_P(payload, sizeof(payload), ESPHOME_PSTR("T,%0.2f"), value); - this->add_command_(payload, EzoCommandType::EZO_T); + // A newer value replaces one that is still waiting to be sent + bool in_flight = this->state_ == EzoState::EZO_STATE_SENT_QUEUED; + for (EzoCommand &command : this->queue_) { + if (!in_flight && command.id == EzoCommandId::EZO_COMMAND_ID_T_SET) { + command.value = value; + return; + } + in_flight = false; + } + this->add_command_(EzoCommandId::EZO_COMMAND_ID_T_SET, value); } void EZOSensor::set_tempcomp_value(float temp) { this->set_t(temp); } -void EZOSensor::get_calibration() { this->add_command_("Cal,?", EzoCommandType::EZO_CALIBRATION); } +void EZOSensor::get_calibration() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_GET); } void EZOSensor::set_calibration_point_low(float value) { - this->set_calibration_point_(EzoCalibrationType::EZO_CAL_LOW, value); + this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_LOW, value); } void EZOSensor::set_calibration_point_mid(float value) { - this->set_calibration_point_(EzoCalibrationType::EZO_CAL_MID, value); + this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_MID, value); } void EZOSensor::set_calibration_point_high(float value) { - this->set_calibration_point_(EzoCalibrationType::EZO_CAL_HIGH, value); + this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_HIGH, value); } void EZOSensor::set_calibration_generic(float value) { - // exact 16 bytes: "Cal," (4) + float with "%0.2f" (up to 11 chars, e.g. "-9999999.99") + null (1) = 16 - char payload[16]; - ESPHOME_snprintf_P(payload, sizeof(payload), ESPHOME_PSTR("Cal,%0.2f"), value); - this->add_command_(payload, EzoCommandType::EZO_CALIBRATION, 900); + this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_GENERIC, value); } -void EZOSensor::clear_calibration() { this->add_command_("Cal,clear", EzoCommandType::EZO_CALIBRATION); } +void EZOSensor::clear_calibration() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_CAL_CLEAR); } -void EZOSensor::get_led_state() { this->add_command_("L,?", EzoCommandType::EZO_LED); } +void EZOSensor::get_led_state() { this->add_command_(EzoCommandId::EZO_COMMAND_ID_LED_GET); } -void EZOSensor::set_led_state(bool on) { this->add_command_(on ? "L,1" : "L,0", EzoCommandType::EZO_LED); } +void EZOSensor::set_led_state(bool on) { + this->add_command_(on ? EzoCommandId::EZO_COMMAND_ID_LED_ON : EzoCommandId::EZO_COMMAND_ID_LED_OFF); +} void EZOSensor::send_custom(const std::string &to_send) { - this->add_command_(to_send.c_str(), EzoCommandType::EZO_CUSTOM); + const size_t length = to_send.size(); + if (length > EZO_MAX_COMMAND_LENGTH) { + ESP_LOGW(TAG, "Command too long (%u > %u), dropped", static_cast(length), + static_cast(EZO_MAX_COMMAND_LENGTH)); + return; + } + if (this->queue_.size() >= EZO_COMMAND_QUEUE_SIZE || this->custom_text_used_ + length > EZO_CUSTOM_TEXT_SIZE) { + ESP_LOGW(TAG, "Command queue full, dropped \"%s\"", to_send.c_str()); + return; + } + // Append after the queued texts, wrapping around the end of the ring + const size_t end = (this->custom_text_start_ + this->custom_text_used_) % EZO_CUSTOM_TEXT_SIZE; + const size_t first = std::min(length, EZO_CUSTOM_TEXT_SIZE - end); + memcpy(this->custom_text_ + end, to_send.data(), first); + memcpy(this->custom_text_, to_send.data() + first, length - first); + this->custom_text_used_ += length; + EzoCommand command{EzoCommandId::EZO_COMMAND_ID_CUSTOM, {}}; + command.text_length = length; + this->queue_.push(command); + this->enable_loop(); } } // namespace esphome::ezo diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index 7a3e84f56ca..37746a2828a 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -3,8 +3,9 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include namespace esphome::ezo { @@ -22,14 +23,49 @@ enum EzoCommandType : uint8_t { EZO_CUSTOM }; -enum EzoCalibrationType : uint8_t { EZO_CAL_LOW = 0, EZO_CAL_MID = 1, EZO_CAL_HIGH = 2 }; +/// Longest command text, without the terminator. The longest documented command is the datasheet's +/// "Import, 59 6F 75 20 61 72" (25). Longer custom commands are dropped. +static constexpr size_t EZO_MAX_COMMAND_LENGTH = 25; +/// Total command slots, including the one in flight. The periodic read does not take a slot. +static constexpr uint8_t EZO_COMMAND_QUEUE_SIZE = 8; +/// Bytes shared by the texts of all queued custom commands, stored back to back without terminators. +static constexpr uint8_t EZO_CUSTOM_TEXT_SIZE = 64; -class EzoCommand { - public: - std::string command; - uint16_t delay_ms = 0; - bool command_sent = false; - EzoCommandType command_type; +/// Each built-in command; the text is rendered only when it is sent. The order matches the tables in ezo.cpp. +enum class EzoCommandId : uint8_t { + EZO_COMMAND_ID_DEVICE_INFORMATION, + EZO_COMMAND_ID_SLEEP, + EZO_COMMAND_ID_SLOPE, + EZO_COMMAND_ID_T_GET, + EZO_COMMAND_ID_CAL_GET, + EZO_COMMAND_ID_CAL_CLEAR, + EZO_COMMAND_ID_LED_GET, + EZO_COMMAND_ID_LED_OFF, + EZO_COMMAND_ID_LED_ON, + EZO_COMMAND_ID_READ, // READ to CAL_GENERIC wait 900 ms for the reply + EZO_COMMAND_ID_CAL_LOW, // CAL_LOW to I2C send their value + EZO_COMMAND_ID_CAL_MID, + EZO_COMMAND_ID_CAL_HIGH, + EZO_COMMAND_ID_CAL_GENERIC, + EZO_COMMAND_ID_T_SET, + EZO_COMMAND_ID_I2C, + EZO_COMMAND_ID_CUSTOM, // the text is in EZOSensor::custom_text_ +}; + +struct __attribute__((packed)) EzoCommand { + EzoCommandId id; + union __attribute__((packed)) { + float value; // CAL_* and T_SET value, or the I2C address + uint8_t text_length; // CUSTOM: length of its text in EZOSensor::custom_text_ + }; +}; +static_assert(sizeof(EzoCommand) == 5, "a queue slot holds the command id and one float"); + +enum class EzoState : uint8_t { + EZO_STATE_IDLE, + EZO_STATE_IDLE_AFTER_READ, // a pending read waits for one queued command first + EZO_STATE_SENT_QUEUED, // the queue's front command is waiting for its reply + EZO_STATE_SENT_READ, }; /// This class implements support for the EZO circuits in i2c mode @@ -85,21 +121,26 @@ class EZOSensor final : public sensor::Sensor, public PollingComponent, public i template void add_custom_callback(F &&callback) { this->custom_callback_.add(std::forward(callback)); } protected: - std::deque> commands_; - int new_address_; + void add_command_(EzoCommandId id, float value = 0.0f); + void send_next_(); + void finish_command_(); - void add_command_(const char *command, EzoCommandType command_type, uint16_t delay_ms = 300); - - void set_calibration_point_(EzoCalibrationType type, float value); - - CallbackManager device_infomation_callback_{}; - CallbackManager calibration_callback_{}; - CallbackManager slope_callback_{}; - CallbackManager t_callback_{}; - CallbackManager custom_callback_{}; + CallbackManager device_infomation_callback_{}; + CallbackManager calibration_callback_{}; + CallbackManager slope_callback_{}; + CallbackManager t_callback_{}; + CallbackManager custom_callback_{}; CallbackManager led_callback_{}; - uint32_t start_time_ = 0; + /// Queued commands; the front one stays in place while it is in flight. The periodic read is not queued. + StaticRingBuffer queue_; + uint32_t reply_due_{0}; // when the reply to the command in flight can be read + EzoState state_{EzoState::EZO_STATE_IDLE}; + bool read_pending_{false}; // a periodic read was requested and is not yet sent + /// Ring of queued custom texts in queue order; the oldest starts at custom_text_start_. + uint8_t custom_text_start_{0}; + uint8_t custom_text_used_{0}; + char custom_text_[EZO_CUSTOM_TEXT_SIZE]; }; } // namespace esphome::ezo diff --git a/tests/components/ezo/test_ezo_commands.cpp b/tests/components/ezo/test_ezo_commands.cpp index 18c32a802d8..98914fbe611 100644 --- a/tests/components/ezo/test_ezo_commands.cpp +++ b/tests/components/ezo/test_ezo_commands.cpp @@ -96,6 +96,31 @@ TEST_F(EzoCommandTest, WireTextOfEachCommand) { "Name,?", "R"})); } +TEST_F(EzoCommandTest, RepliesReachTheirCallbacks) { + std::vector got; + this->ezo_.add_device_infomation_callback([&got](const std::string &x) { got.push_back("info " + x); }); + this->ezo_.add_slope_callback([&got](const std::string &x) { got.push_back("slope " + x); }); + this->ezo_.add_calibration_callback([&got](const std::string &x) { got.push_back("cal " + x); }); + this->ezo_.add_t_callback([&got](const std::string &x) { got.push_back("t " + x); }); + this->ezo_.add_led_state_callback([&got](bool on) { got.emplace_back(on ? "led on" : "led off"); }); + this->ezo_.add_custom_callback([&got](const std::string &x) { got.push_back("custom " + x); }); + this->bus_.replies = { + {"i", "?I,pH,2.16"}, {"Slope,?", "?Slope,99.7,100.3,-0.89"}, {"Cal,?", "?Cal,2"}, {"T,?", "?T,19.5"}, + {"L,?", "?L,1"}, {"Status", "?Status,P,5.038"}, {"R", "7.25,1.5"}}; + this->ezo_.get_device_information(); + this->ezo_.get_slope(); + this->ezo_.get_calibration(); + this->ezo_.get_t(); + this->ezo_.get_led_state(); + this->ezo_.send_custom("Status"); + this->ezo_.get_state(); + this->run_until_written_(7); + EXPECT_EQ(got, (std::vector{"info pH,2.16", "slope 99.7,100.3,-0.89", "cal 2", "t 19.5", "led on", + "custom ?Status,P,5.038"})); + // A read publishes the first of several values. + EXPECT_FLOAT_EQ(this->ezo_.state, 7.25f); +} + TEST_F(EzoCommandTest, AddressChangeAppliesToLaterCommands) { this->ezo_.set_address(100); this->ezo_.get_led_state(); diff --git a/tests/components/ezo/test_ezo_queue.cpp b/tests/components/ezo/test_ezo_queue.cpp new file mode 100644 index 00000000000..806a3cf70b7 --- /dev/null +++ b/tests/components/ezo/test_ezo_queue.cpp @@ -0,0 +1,248 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/components/ezo/ezo.h" +#include "esphome/core/application.h" + +namespace esphome::ezo::testing { + +static constexpr uint8_t ADDRESS = 99; + +// Records each command written and answers every read with a success code and `reply`. +class EzoBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + this->addresses.push_back(address); + if (size != 0) + this->writes.emplace_back(reinterpret_cast(data), size); + if (out_size != 0) { + memset(out, 0, out_size); + out[0] = 1; + memcpy(out + 1, this->reply.c_str(), std::min(this->reply.size(), out_size - 1)); + } + return i2c::ERROR_OK; + } + + std::vector writes; + std::vector addresses; + std::string reply{"7.00"}; +}; + +class EzoQueueTest : public ::testing::Test { + protected: + void SetUp() override { + this->ezo_.set_i2c_bus(&this->bus_); + this->ezo_.set_i2c_address(ADDRESS); + } + + // One loop pass, one second after the previous one, so every reply delay has passed. + void step_() { + this->now_ += 1000; + LoopBlockingGuard guard{nullptr, nullptr, this->now_}; + this->ezo_.loop(); + } + + void run_all_() { + for (int i = 0; i < 64; i++) + this->step_(); + } + + EzoBus bus_; + EZOSensor ezo_; + uint32_t now_{0}; +}; + +TEST_F(EzoQueueTest, ReadRunsNextAndOnlyOnce) { + this->ezo_.get_led_state(); + this->ezo_.get_slope(); + this->step_(); // "L,?" is in flight + this->ezo_.update(); + this->ezo_.update(); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"L,?", "R", "Slope,?"})); + EXPECT_FLOAT_EQ(this->ezo_.state, 7.0f); +} + +TEST_F(EzoQueueTest, ReadsDoNotStarveOtherCommands) { + this->ezo_.get_led_state(); + this->ezo_.get_slope(); + this->ezo_.update(); + this->step_(); // the read goes first since nothing was in flight + this->ezo_.update(); // asked again while the first read is in flight + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"R", "L,?", "R", "Slope,?"})); +} + +TEST_F(EzoQueueTest, WireTextOfEachCommand) { + this->ezo_.get_device_information(); + this->ezo_.set_sleep(); + this->ezo_.get_slope(); + this->ezo_.get_t(); + this->ezo_.set_t(21.5f); + this->ezo_.get_calibration(); + this->ezo_.clear_calibration(); + this->ezo_.set_calibration_point_low(4.0f); + this->run_all_(); + this->ezo_.set_tempcomp_value(-5.256f); + this->ezo_.set_calibration_point_mid(7.0f); + this->ezo_.set_calibration_point_high(10.0f); + this->ezo_.set_calibration_generic(1413.0f); + this->ezo_.get_led_state(); + this->ezo_.set_led_state(false); + this->ezo_.set_led_state(true); + this->ezo_.send_custom("Name,?"); + this->run_all_(); + this->ezo_.get_state(); + this->ezo_.set_address(100); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, + (std::vector{"i", "Sleep", "Slope,?", "T,?", "T,21.50", "Cal,?", "Cal,clear", "Cal,LOW,4.00", + "T,-5.26", "Cal,MID,7.00", "Cal,HIGH,10.00", "Cal,1413.00", "L,?", "L,0", "L,1", + "Name,?", "R", "I2C,100"})); +} + +// An explicit read waits its turn, so it sees the compensation queued before it. +TEST_F(EzoQueueTest, ExplicitReadRunsInOrder) { + this->ezo_.set_t(25.0f); + this->ezo_.get_state(); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"T,25.00", "R"})); + EXPECT_FLOAT_EQ(this->ezo_.state, 7.0f); +} + +// The finished command frees its slot before its callbacks run. +TEST_F(EzoQueueTest, CallbackCanQueueIntoFullQueue) { + bool queued = false; + this->ezo_.add_led_state_callback([this, &queued](bool) { + if (!queued) { + queued = true; + this->ezo_.get_slope(); + } + }); + this->bus_.reply = "?L,1"; + for (uint8_t i = 0; i < EZO_COMMAND_QUEUE_SIZE; i++) + this->ezo_.get_led_state(); + this->run_all_(); + ASSERT_EQ(this->bus_.writes.size(), EZO_COMMAND_QUEUE_SIZE + 1u); + EXPECT_EQ(this->bus_.writes.back(), "Slope,?"); +} + +TEST_F(EzoQueueTest, CustomCallbackCanSendCustom) { + bool sent = false; + this->ezo_.add_custom_callback([this, &sent](const std::string &) { + if (!sent) { + sent = true; + this->ezo_.send_custom("Find"); + } + }); + this->ezo_.send_custom("Status"); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"Status", "Find"})); +} + +TEST_F(EzoQueueTest, LongestDocumentedCommandFits) { + this->ezo_.send_custom("Import, 59 6F 75 20 61 72"); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"Import, 59 6F 75 20 61 72"})); +} + +TEST_F(EzoQueueTest, FullQueueDropsNewCommands) { + for (uint8_t i = 0; i < EZO_COMMAND_QUEUE_SIZE; i++) + this->ezo_.set_calibration_generic(static_cast(i)); + this->ezo_.get_led_state(); // one more than fits + this->ezo_.update(); // reads do not take a slot + this->run_all_(); + std::vector expected{"R"}; + for (uint8_t i = 0; i < EZO_COMMAND_QUEUE_SIZE; i++) { + char text[16]; + snprintf(text, sizeof(text), "Cal,%u.00", i); + expected.emplace_back(text); + } + EXPECT_EQ(this->bus_.writes, expected); +} + +// Custom commands queued back to back all go out, in order. +TEST_F(EzoQueueTest, CustomCommandsRunInOrder) { + this->ezo_.send_custom("O,EC,1"); + this->ezo_.send_custom("O,TDS,0"); + this->ezo_.get_led_state(); + this->ezo_.send_custom("O,S,0"); + this->ezo_.send_custom("O,SG,1"); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"O,EC,1", "O,TDS,0", "L,?", "O,S,0", "O,SG,1"})); +} + +// The custom texts share EZO_CUSTOM_TEXT_SIZE bytes; one that does not fit is dropped, and the space comes back +// as the commands finish, including texts that wrap around the end of the buffer. +TEST_F(EzoQueueTest, CustomTextSpaceIsShared) { + const std::string a(EZO_MAX_COMMAND_LENGTH, 'a'); + const std::string b(EZO_MAX_COMMAND_LENGTH, 'b'); + const std::string c(EZO_CUSTOM_TEXT_SIZE - 2 * EZO_MAX_COMMAND_LENGTH + 1, 'c'); + this->ezo_.send_custom(a); + this->ezo_.send_custom(b); + this->ezo_.send_custom(c); // one byte too many + this->ezo_.send_custom("x"); + this->run_all_(); + this->ezo_.send_custom(c); // wraps around the end + this->ezo_.send_custom(a); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{a, b, "x", c, a})); +} + +TEST_F(EzoQueueTest, TemperatureUpdatesMergeUntilSent) { + this->ezo_.set_t(20.0f); + this->ezo_.get_led_state(); + this->ezo_.set_tempcomp_value(25.0f); // replaces the 20 still waiting + this->step_(); // "T,25.00" is in flight + this->ezo_.set_t(30.0f); // queued after it + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"T,25.00", "L,?", "T,30.00"})); +} + +TEST_F(EzoQueueTest, CustomCommandDroppedByFullQueueFreesTheText) { + for (uint8_t i = 0; i < EZO_COMMAND_QUEUE_SIZE; i++) + this->ezo_.get_led_state(); + this->ezo_.send_custom("Name,?"); + this->run_all_(); + this->ezo_.send_custom("Status"); + this->run_all_(); + ASSERT_EQ(this->bus_.writes.size(), EZO_COMMAND_QUEUE_SIZE + 1u); + EXPECT_EQ(this->bus_.writes.back(), "Status"); +} + +TEST_F(EzoQueueTest, OversizeCustomCommandIsRejected) { + std::vector replies; + this->ezo_.add_custom_callback([&replies](const std::string &x) { replies.push_back(x); }); + this->bus_.reply = "ok"; + this->ezo_.send_custom(std::string(EZO_MAX_COMMAND_LENGTH + 1, 'A')); + this->ezo_.send_custom(std::string(EZO_MAX_COMMAND_LENGTH, 'B')); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{std::string(EZO_MAX_COMMAND_LENGTH, 'B')})); + EXPECT_EQ(replies, (std::vector{"ok"})); +} + +TEST_F(EzoQueueTest, ReadKeepsOnlyTheFirstValue) { + this->bus_.reply = "7.25,1.5"; + this->ezo_.update(); + this->run_all_(); + EXPECT_FLOAT_EQ(this->ezo_.state, 7.25f); +} + +TEST_F(EzoQueueTest, AddressChangesApplyInOrder) { + this->ezo_.set_address(100); + this->ezo_.get_led_state(); + this->ezo_.set_address(101); + this->ezo_.get_led_state(); + this->run_all_(); + EXPECT_EQ(this->bus_.writes, (std::vector{"I2C,100", "L,?", "I2C,101", "L,?"})); + // Each change needs no reply and goes to the address set before it; later commands go to the new one. + EXPECT_EQ(this->bus_.addresses, (std::vector{ADDRESS, 100, 100, 100, 101, 101})); +} + +} // namespace esphome::ezo::testing diff --git a/tests/components/ezo/test_ezo_reply.cpp b/tests/components/ezo/test_ezo_reply.cpp new file mode 100644 index 00000000000..61b3ade3cf9 --- /dev/null +++ b/tests/components/ezo/test_ezo_reply.cpp @@ -0,0 +1,61 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/ezo/ezo.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::ezo::testing { + +// Answers every read with a success code and `reply`, with no terminator when the reply fills the read. +class EzoReplyBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + if (out_size != 0) { + memset(out, 0, out_size); + out[0] = 1; + memcpy(out + 1, this->reply.data(), std::min(this->reply.size(), out_size - 1)); + this->read_sizes.push_back(out_size); + } + return i2c::ERROR_OK; + } + + std::string reply; + std::vector read_sizes; +}; + +static std::vector run_custom(const std::string &reply) { + EzoReplyBus bus; + bus.reply = reply; + EZOSensor ezo; + ezo.set_i2c_bus(&bus); + ezo.set_i2c_address(99); + std::vector replies; + ezo.add_custom_callback([&replies](const std::string &x) { replies.push_back(x); }); + ezo.send_custom("Status"); + // The reply is read once its delay has passed on the real clock. + const uint32_t start = millis(); + while (replies.empty() && millis() - start < 2000) { + LoopBlockingGuard guard{nullptr, nullptr, millis()}; + ezo.loop(); + delay(10); + } + return replies; +} + +// EZO-RGB's largest reply is 52 characters; it arrives whole even with no terminator in the read. +TEST(EzoReplyTest, LargestReplyIsDeliveredWhole) { + const std::string reply(52, 'x'); + EXPECT_EQ(run_custom(reply), (std::vector{reply})); +} + +TEST(EzoReplyTest, ShortReplyIsUnchanged) { + EXPECT_EQ(run_custom("?Status,P,5.038"), (std::vector{"?Status,P,5.038"})); +} + +} // namespace esphome::ezo::testing From e4011bd96a873dbb7efbdea92669c9ee3fc84dba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 20:11:22 -0600 Subject: [PATCH 50/55] [components] Publish sy6970, tca8418 and tm1638 children through cg.HubFunction (#22104) --- esphome/components/sy6970/__init__.py | 27 +++++- .../sy6970/binary_sensor/__init__.py | 33 +++---- .../binary_sensor/sy6970_binary_sensor.h | 43 --------- esphome/components/sy6970/sensor/__init__.py | 75 +++++++++++---- .../components/sy6970/sensor/sy6970_sensor.h | 46 --------- esphome/components/sy6970/sy6970.cpp | 8 +- esphome/components/sy6970/sy6970.h | 24 +++-- .../components/sy6970/text_sensor/__init__.py | 52 +++++----- .../sy6970/text_sensor/sy6970_text_sensor.h | 96 ------------------- esphome/components/tca8418/__init__.py | 19 ++-- .../tca8418/binary_sensor/__init__.py | 18 ++-- .../binary_sensor/tca8418_binary_sensor.h | 26 ----- esphome/components/tca8418/tca8418.cpp | 11 +-- esphome/components/tca8418/tca8418.h | 19 ++-- .../tm1638/binary_sensor/__init__.py | 13 +-- .../tm1638/binary_sensor/tm1638_key.cpp | 11 --- .../tm1638/binary_sensor/tm1638_key.h | 17 ---- esphome/components/tm1638/display.py | 9 ++ esphome/components/tm1638/tm1638.cpp | 10 +- esphome/components/tm1638/tm1638.h | 18 ++-- esphome/core/defines.h | 4 +- tests/component_tests/sy6970/__init__.py | 0 .../sy6970/config/hub_function.yaml | 59 ++++++++++++ .../sy6970/test_hub_function.py | 63 ++++++++++++ .../tca8418/config/hub_function.yaml | 28 ++++++ .../tca8418/test_hub_function.py | 22 +++++ tests/component_tests/tm1638/__init__.py | 0 .../tm1638/config/hub_function.yaml | 27 ++++++ .../tm1638/test_hub_function.py | 21 ++++ 29 files changed, 426 insertions(+), 373 deletions(-) delete mode 100644 esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h delete mode 100644 esphome/components/sy6970/sensor/sy6970_sensor.h delete mode 100644 esphome/components/sy6970/text_sensor/sy6970_text_sensor.h delete mode 100644 esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h delete mode 100644 esphome/components/tm1638/binary_sensor/tm1638_key.cpp delete mode 100644 esphome/components/tm1638/binary_sensor/tm1638_key.h create mode 100644 tests/component_tests/sy6970/__init__.py create mode 100644 tests/component_tests/sy6970/config/hub_function.yaml create mode 100644 tests/component_tests/sy6970/test_hub_function.py create mode 100644 tests/component_tests/tca8418/config/hub_function.yaml create mode 100644 tests/component_tests/tca8418/test_hub_function.py create mode 100644 tests/component_tests/tm1638/__init__.py create mode 100644 tests/component_tests/tm1638/config/hub_function.yaml create mode 100644 tests/component_tests/tm1638/test_hub_function.py diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 4179f7cab12..a3e5ef01034 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable + +from esphome.automation import parent_ref import esphome.codegen as cg from esphome.components import i2c from esphome.components.const import ( @@ -24,7 +27,7 @@ sy6970_ns = cg.esphome_ns.namespace("sy6970") SY6970Component = sy6970_ns.class_( "SY6970Component", cg.PollingComponent, i2c.I2CDevice ) -SY6970Listener = sy6970_ns.class_("SY6970Listener") +SY6970Data = sy6970_ns.struct("SY6970Data") I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout") # REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on @@ -32,6 +35,19 @@ I2C_WATCHDOG_TIMEOUTS = I2CWatchdogTimeout.options( "DISABLED", "40S", "80S", "160S", prefix="I2C_WATCHDOG_" ) +# Publishes the child entities from the registers read on each update +DATA_PUBLISHER = cg.HubFunction( + "data", + cg.void, + [(SY6970Data.const_ref(), "data")], + define="USE_SY6970_DATA_PUBLISHER", +) +DATA = DATA_PUBLISHER.param("data") +REGISTERS = DATA.registers +STATUS_REGISTER = REGISTERS[sy6970_ns.SY6970_REG_STATUS] +BUS_STATUS = (STATUS_REGISTER >> 5) & 0x07 +CHARGE_STATUS = (STATUS_REGISTER >> 3) & 0x03 + CONFIG_SCHEMA = ( cv.Schema( { @@ -73,3 +89,12 @@ async def to_code(config: ConfigType) -> None: config[CONF_ENABLE_ADC], config[CONF_I2C_WATCHDOG_TIMEOUT], ) + + +async def publish_children( + hub: cg.MockObj, sub_entities: Callable, states: dict[str, cg.MockObj] +) -> None: + """Create each configured child and publish its state from the hub's data function.""" + for key, state in states.items(): + if (var := await sub_entities(key, None)) is not None: + DATA_PUBLISHER.add(hub, parent_ref(var).publish_state(state)) diff --git a/esphome/components/sy6970/binary_sensor/__init__.py b/esphome/components/sy6970/binary_sensor/__init__.py index a46d7344b68..661e6d2afbe 100644 --- a/esphome/components/sy6970/binary_sensor/__init__.py +++ b/esphome/components/sy6970/binary_sensor/__init__.py @@ -4,7 +4,14 @@ import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_POWER from esphome.types import ConfigType -from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns +from .. import ( + BUS_STATUS, + CHARGE_STATUS, + CONF_SY6970_ID, + SY6970Component, + publish_children, + sy6970_ns, +) DEPENDENCIES = ["sy6970"] @@ -12,39 +19,29 @@ CONF_VBUS_CONNECTED = "vbus_connected" CONF_CHARGING = "charging" CONF_CHARGE_DONE = "charge_done" -SY6970VbusConnectedBinarySensor = sy6970_ns.class_( - "SY6970VbusConnectedBinarySensor", binary_sensor.BinarySensor -) -SY6970ChargingBinarySensor = sy6970_ns.class_( - "SY6970ChargingBinarySensor", binary_sensor.BinarySensor -) -SY6970ChargeDoneBinarySensor = sy6970_ns.class_( - "SY6970ChargeDoneBinarySensor", binary_sensor.BinarySensor -) - CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_SY6970_ID): cv.use_id(SY6970Component), cv.Optional(CONF_VBUS_CONNECTED): binary_sensor.binary_sensor_schema( - SY6970VbusConnectedBinarySensor, device_class=DEVICE_CLASS_CONNECTIVITY, ), cv.Optional(CONF_CHARGING): binary_sensor.binary_sensor_schema( - SY6970ChargingBinarySensor, device_class=DEVICE_CLASS_POWER, ), cv.Optional(CONF_CHARGE_DONE): binary_sensor.binary_sensor_schema( - SY6970ChargeDoneBinarySensor, device_class=DEVICE_CLASS_POWER, ), } ) +STATES = { + CONF_VBUS_CONNECTED: BUS_STATUS != sy6970_ns.BUS_STATUS_NO_INPUT, + CONF_CHARGING: sy6970_ns.is_charging(CHARGE_STATUS), + CONF_CHARGE_DONE: CHARGE_STATUS == sy6970_ns.CHARGE_STATUS_CHARGE_DONE, +} + async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SY6970_ID]) - binary_sensors = binary_sensor.sub_binary_sensors(config) - await binary_sensors(CONF_VBUS_CONNECTED, hub.add_listener) - await binary_sensors(CONF_CHARGING, hub.add_listener) - await binary_sensors(CONF_CHARGE_DONE, hub.add_listener) + await publish_children(hub, binary_sensor.sub_binary_sensors(config), STATES) diff --git a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h b/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h deleted file mode 100644 index b94c89d123e..00000000000 --- a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h +++ /dev/null @@ -1,43 +0,0 @@ -#pragma once - -#include "../sy6970.h" -#include "esphome/components/binary_sensor/binary_sensor.h" - -namespace esphome::sy6970 { - -template -class StatusBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t value = (data.registers[REG] >> SHIFT) & MASK; - this->publish_state(value == TRUE_VALUE); - } -}; - -template -class InverseStatusBinarySensor : public SY6970Listener, public binary_sensor::BinarySensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t value = (data.registers[REG] >> SHIFT) & MASK; - this->publish_state(value != FALSE_VALUE); - } -}; - -// Custom binary sensor for charging (true when pre-charge or fast charge) -class SY6970ChargingBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t chrg_stat = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; - bool charging = chrg_stat != CHARGE_STATUS_NOT_CHARGING && chrg_stat != CHARGE_STATUS_CHARGE_DONE; - this->publish_state(charging); - } -}; - -// Specialized sensor types using templates -// VBUS connected: BUS_STATUS != NO_INPUT -using SY6970VbusConnectedBinarySensor = InverseStatusBinarySensor; - -// Charge done: CHARGE_STATUS == CHARGE_DONE -using SY6970ChargeDoneBinarySensor = StatusBinarySensor; - -} // namespace esphome::sy6970 diff --git a/esphome/components/sy6970/sensor/__init__.py b/esphome/components/sy6970/sensor/__init__.py index e2f12501ce1..46573552d56 100644 --- a/esphome/components/sy6970/sensor/__init__.py +++ b/esphome/components/sy6970/sensor/__init__.py @@ -12,56 +12,94 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns +from .. import CONF_SY6970_ID, REGISTERS, SY6970Component, publish_children, sy6970_ns DEPENDENCIES = ["sy6970"] CONF_VBUS_VOLTAGE = "vbus_voltage" CONF_SYSTEM_VOLTAGE = "system_voltage" -SY6970VbusVoltageSensor = sy6970_ns.class_("SY6970VbusVoltageSensor", sensor.Sensor) -SY6970BatteryVoltageSensor = sy6970_ns.class_( - "SY6970BatteryVoltageSensor", sensor.Sensor -) -SY6970SystemVoltageSensor = sy6970_ns.class_("SY6970SystemVoltageSensor", sensor.Sensor) -SY6970ChargeCurrentSensor = sy6970_ns.class_("SY6970ChargeCurrentSensor", sensor.Sensor) -SY6970PrechargeCurrentSensor = sy6970_ns.class_( - "SY6970PrechargeCurrentSensor", sensor.Sensor -) + +def _decode( + reg: cg.MockObj, + step: cg.MockObj, + base: cg.MockObj | None = None, + *, + shift: int = 0, + mask: int = 0x7F, + scale: float | None = None, +) -> cg.MockObj: + """``(base + ((registers[reg] >> shift) & mask) * step) * scale``.""" + raw = REGISTERS[reg] + if shift: + raw = raw >> shift + value = (raw & mask) * step + if base is not None: + value = base + value + return value if scale is None else value * scale + + +# Voltages in V from mV, currents in mA +STATES = { + CONF_VBUS_VOLTAGE: _decode( + sy6970_ns.SY6970_REG_VBUS_VOLTAGE, + sy6970_ns.VBUS_STEP_MV, + sy6970_ns.VBUS_BASE_MV, + scale=0.001, + ), + CONF_BATTERY_VOLTAGE: _decode( + sy6970_ns.SY6970_REG_BATV, + sy6970_ns.VBAT_STEP_MV, + sy6970_ns.VBAT_BASE_MV, + scale=0.001, + ), + CONF_SYSTEM_VOLTAGE: _decode( + sy6970_ns.SY6970_REG_VINDPM_STATUS, + sy6970_ns.VSYS_STEP_MV, + sy6970_ns.VSYS_BASE_MV, + scale=0.001, + ), + CONF_CHARGE_CURRENT: _decode( + sy6970_ns.SY6970_REG_CHARGE_CURRENT_MONITOR, sy6970_ns.CHG_CURRENT_STEP_MA + ), + CONF_PRECHARGE_CURRENT: _decode( + sy6970_ns.SY6970_REG_PRECHARGE_CURRENT, + sy6970_ns.PRE_CHG_STEP_MA, + sy6970_ns.PRE_CHG_BASE_MA, + shift=4, + mask=0x0F, + ), +} + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_SY6970_ID): cv.use_id(SY6970Component), cv.Optional(CONF_VBUS_VOLTAGE): sensor.sensor_schema( - SY6970VbusVoltageSensor, unit_of_measurement=UNIT_VOLT, accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema( - SY6970BatteryVoltageSensor, unit_of_measurement=UNIT_VOLT, accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_SYSTEM_VOLTAGE): sensor.sensor_schema( - SY6970SystemVoltageSensor, unit_of_measurement=UNIT_VOLT, accuracy_decimals=2, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CHARGE_CURRENT): sensor.sensor_schema( - SY6970ChargeCurrentSensor, unit_of_measurement=UNIT_MILLIAMP, accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_PRECHARGE_CURRENT): sensor.sensor_schema( - SY6970PrechargeCurrentSensor, unit_of_measurement=UNIT_MILLIAMP, accuracy_decimals=0, device_class=DEVICE_CLASS_CURRENT, @@ -74,9 +112,4 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SY6970_ID]) - sensors = sensor.sub_sensors(config) - await sensors(CONF_VBUS_VOLTAGE, hub.add_listener) - await sensors(CONF_BATTERY_VOLTAGE, hub.add_listener) - await sensors(CONF_SYSTEM_VOLTAGE, hub.add_listener) - await sensors(CONF_CHARGE_CURRENT, hub.add_listener) - await sensors(CONF_PRECHARGE_CURRENT, hub.add_listener) + await publish_children(hub, sensor.sub_sensors(config), STATES) diff --git a/esphome/components/sy6970/sensor/sy6970_sensor.h b/esphome/components/sy6970/sensor/sy6970_sensor.h deleted file mode 100644 index 61abbc3e36b..00000000000 --- a/esphome/components/sy6970/sensor/sy6970_sensor.h +++ /dev/null @@ -1,46 +0,0 @@ -#pragma once - -#include "../sy6970.h" -#include "esphome/components/sensor/sensor.h" - -namespace esphome::sy6970 { - -// Template for voltage sensors (converts mV to V) -template -class VoltageSensor : public SY6970Listener, public sensor::Sensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t val = data.registers[REG] & MASK; - uint16_t voltage_mv = BASE + (val * STEP); - this->publish_state(voltage_mv * 0.001f); // Convert mV to V - } -}; - -// Template for current sensors (returns mA) -template -class CurrentSensor : public SY6970Listener, public sensor::Sensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t val = data.registers[REG] & MASK; - uint16_t current_ma = BASE + (val * STEP); - this->publish_state(current_ma); - } -}; - -// Specialized sensor types using templates -using SY6970VbusVoltageSensor = VoltageSensor; -using SY6970BatteryVoltageSensor = VoltageSensor; -using SY6970SystemVoltageSensor = VoltageSensor; -using SY6970ChargeCurrentSensor = CurrentSensor; - -// Precharge current sensor needs special handling (bit shift) -class SY6970PrechargeCurrentSensor final : public SY6970Listener, public sensor::Sensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t iprechg = (data.registers[SY6970_REG_PRECHARGE_CURRENT] >> 4) & 0x0F; - uint16_t iprechg_ma = PRE_CHG_BASE_MA + (iprechg * PRE_CHG_STEP_MA); - this->publish_state(iprechg_ma); - } -}; - -} // namespace esphome::sy6970 diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index a5f6e620d15..51a93b16cd8 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -123,10 +123,10 @@ void SY6970Component::update() { return; } - // Notify all listeners with the new data - for (auto *listener : this->listeners_) { - listener->on_data(this->data_); - } +#ifdef USE_SY6970_DATA_PUBLISHER + if (this->data_publisher_ != nullptr) + this->data_publisher_(this->data_); +#endif } void SY6970Component::set_input_current_limit(uint16_t milliamps) { diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index b9c4f1d4870..86a04238ec3 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -2,7 +2,9 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -#include +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" namespace esphome::sy6970 { @@ -76,11 +78,15 @@ struct SY6970Data { uint8_t registers[21]; // Registers 0x00-0x14 (includes unused 0x0F, 0x10) }; -// Listener interface for components that want to receive SY6970 data updates -class SY6970Listener { - public: - virtual void on_data(const SY6970Data &data) = 0; -}; +inline bool is_charging(uint8_t charge_status) { + return charge_status == CHARGE_STATUS_PRE_CHARGE || charge_status == CHARGE_STATUS_FAST_CHARGE; +} + +// Indexed by status value; every value of the masked field has a string +PROGMEM_STRING_TABLE(BusStatusStrings, "No Input", "USB SDP", "USB CDP", "USB DCP", "HVDCP", "Adapter", + "Non-Standard Adapter", "OTG"); +PROGMEM_STRING_TABLE(ChargeStatusStrings, "Not Charging", "Pre-charge", "Fast Charge", "Charge Done"); +PROGMEM_STRING_TABLE(NtcStatusStrings, "Normal", "Unknown", "Warm", "Cool", "Unknown", "Cold", "Hot", "Unknown"); class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: @@ -99,8 +105,9 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { void dump_config() override; void update() override; - // Listener registration - void add_listener(SY6970Listener *listener) { this->listeners_.push_back(listener); } +#ifdef USE_SY6970_DATA_PUBLISHER + HUB_PUBLISHER(data, const SY6970Data &) +#endif // Configuration methods to be called from lambdas void set_input_current_limit(uint16_t milliamps); @@ -121,7 +128,6 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); SY6970Data data_{}; - std::vector listeners_; // Configuration values to set during setup() bool led_enabled_; diff --git a/esphome/components/sy6970/text_sensor/__init__.py b/esphome/components/sy6970/text_sensor/__init__.py index db756960e1b..a0e921db64f 100644 --- a/esphome/components/sy6970/text_sensor/__init__.py +++ b/esphome/components/sy6970/text_sensor/__init__.py @@ -3,7 +3,15 @@ from esphome.components import text_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_SY6970_ID, SY6970Component, sy6970_ns +from .. import ( + BUS_STATUS, + CHARGE_STATUS, + CONF_SY6970_ID, + REGISTERS, + SY6970Component, + publish_children, + sy6970_ns, +) DEPENDENCIES = ["sy6970"] @@ -11,36 +19,32 @@ CONF_BUS_STATUS = "bus_status" CONF_CHARGE_STATUS = "charge_status" CONF_NTC_STATUS = "ntc_status" -SY6970BusStatusTextSensor = sy6970_ns.class_( - "SY6970BusStatusTextSensor", text_sensor.TextSensor -) -SY6970ChargeStatusTextSensor = sy6970_ns.class_( - "SY6970ChargeStatusTextSensor", text_sensor.TextSensor -) -SY6970NtcStatusTextSensor = sy6970_ns.class_( - "SY6970NtcStatusTextSensor", text_sensor.TextSensor -) - CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(CONF_SY6970_ID): cv.use_id(SY6970Component), - cv.Optional(CONF_BUS_STATUS): text_sensor.text_sensor_schema( - SY6970BusStatusTextSensor - ), - cv.Optional(CONF_CHARGE_STATUS): text_sensor.text_sensor_schema( - SY6970ChargeStatusTextSensor - ), - cv.Optional(CONF_NTC_STATUS): text_sensor.text_sensor_schema( - SY6970NtcStatusTextSensor - ), + cv.Optional(CONF_BUS_STATUS): text_sensor.text_sensor_schema(), + cv.Optional(CONF_CHARGE_STATUS): text_sensor.text_sensor_schema(), + cv.Optional(CONF_NTC_STATUS): text_sensor.text_sensor_schema(), } ) +def _status_string(table: str, index: cg.MockObj) -> cg.MockObj: + """The string at ``index`` in the hub's PROGMEM_STRING_TABLE ``table``.""" + strings = sy6970_ns.namespace(table) + return strings.get_progmem_str(index, strings.LAST_INDEX) + + +STATES = { + CONF_BUS_STATUS: _status_string("BusStatusStrings", BUS_STATUS), + CONF_CHARGE_STATUS: _status_string("ChargeStatusStrings", CHARGE_STATUS), + CONF_NTC_STATUS: _status_string( + "NtcStatusStrings", REGISTERS[sy6970_ns.SY6970_REG_FAULT] & 0x07 + ), +} + + async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SY6970_ID]) - text_sensors = text_sensor.sub_text_sensors(config) - await text_sensors(CONF_BUS_STATUS, hub.add_listener) - await text_sensors(CONF_CHARGE_STATUS, hub.add_listener) - await text_sensors(CONF_NTC_STATUS, hub.add_listener) + await publish_children(hub, text_sensor.sub_text_sensors(config), STATES) diff --git a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h b/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h deleted file mode 100644 index e569bd0b902..00000000000 --- a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h +++ /dev/null @@ -1,96 +0,0 @@ -#pragma once - -#include "../sy6970.h" -#include "esphome/components/text_sensor/text_sensor.h" - -namespace esphome::sy6970 { - -// Bus status text sensor -class SY6970BusStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t status = (data.registers[SY6970_REG_STATUS] >> 5) & 0x07; - const char *status_str = this->get_bus_status_string_(status); - this->publish_state(status_str); - } - - protected: - const char *get_bus_status_string_(uint8_t status) { - switch (status) { - case BUS_STATUS_NO_INPUT: - return "No Input"; - case BUS_STATUS_USB_SDP: - return "USB SDP"; - case BUS_STATUS_USB_CDP: - return "USB CDP"; - case BUS_STATUS_USB_DCP: - return "USB DCP"; - case BUS_STATUS_HVDCP: - return "HVDCP"; - case BUS_STATUS_ADAPTER: - return "Adapter"; - case BUS_STATUS_NO_STD_ADAPTER: - return "Non-Standard Adapter"; - case BUS_STATUS_OTG: - return "OTG"; - default: - return "Unknown"; - } - } -}; - -// Charge status text sensor -class SY6970ChargeStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t status = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; - const char *status_str = this->get_charge_status_string_(status); - this->publish_state(status_str); - } - - protected: - const char *get_charge_status_string_(uint8_t status) { - switch (status) { - case CHARGE_STATUS_NOT_CHARGING: - return "Not Charging"; - case CHARGE_STATUS_PRE_CHARGE: - return "Pre-charge"; - case CHARGE_STATUS_FAST_CHARGE: - return "Fast Charge"; - case CHARGE_STATUS_CHARGE_DONE: - return "Charge Done"; - default: - return "Unknown"; - } - } -}; - -// NTC status text sensor -class SY6970NtcStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { - public: - void on_data(const SY6970Data &data) override { - uint8_t status = data.registers[SY6970_REG_FAULT] & 0x07; - const char *status_str = this->get_ntc_status_string_(status); - this->publish_state(status_str); - } - - protected: - const char *get_ntc_status_string_(uint8_t status) { - switch (status) { - case 0: - return "Normal"; - case 2: - return "Warm"; - case 3: - return "Cool"; - case 5: - return "Cold"; - case 6: - return "Hot"; - default: - return "Unknown"; - } - } -}; - -} // namespace esphome::sy6970 diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py index 2c33df3db91..da195527c55 100644 --- a/esphome/components/tca8418/__init__.py +++ b/esphome/components/tca8418/__init__.py @@ -16,15 +16,6 @@ DOMAIN = "tca8418" AUTO_LOAD = ["key_provider", "gpio_expander"] MULTI_CONF = True -# Each keypad keeps its listeners in a fixed size list; the define is the largest count any one keypad needs -_request_listener_slot = cg.slot_counter("TCA8418_LISTENER_COUNT") - - -async def register_listener(keypad: cg.MockObj, var: cg.MockObj) -> None: - _request_listener_slot(str(keypad)) - cg.add(keypad.register_listener(var)) - - CONF_GPI_EVENTS = "gpi_events" CONF_KEYPAD_ID = "keypad_id" @@ -45,6 +36,16 @@ TCA8418Component = tca8418_ns.class_( "TCA8418Component", key_provider.KeyProvider, cg.Component, i2c.I2CDevice ) +# Publishes the binary sensors on each key event +KEY_PUBLISHER = cg.HubFunction( + "key", + cg.void, + [(cg.uint8, "key"), (cg.bool_, "pressed")], + define="USE_TCA8418_KEY_PUBLISHER", +) +KEY = KEY_PUBLISHER.param("key") +PRESSED = KEY_PUBLISHER.param("pressed") + def _ascii_string(value: str) -> str: """A string of single byte characters, so it lines up with the matrix.""" diff --git a/esphome/components/tca8418/binary_sensor/__init__.py b/esphome/components/tca8418/binary_sensor/__init__.py index ee9086dd184..c1a25adec0d 100644 --- a/esphome/components/tca8418/binary_sensor/__init__.py +++ b/esphome/components/tca8418/binary_sensor/__init__.py @@ -1,3 +1,4 @@ +from esphome.automation import parent_ref import esphome.codegen as cg from esphome.components import binary_sensor from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS @@ -11,12 +12,14 @@ from .. import ( CONF_KEYPAD_ID, GPI_KEY_MAX, GPI_KEY_MIN, + KEY, + KEY_PUBLISHER, MATRIX_COLUMNS, MATRIX_KEY_MAX, + PRESSED, TCA8418Component, _ascii_string, _matrix_key, - register_listener, tca8418_ns, ) @@ -24,10 +27,6 @@ DEPENDENCIES = ["tca8418"] CONF_KEY_CODE = "key_code" -TCA8418BinarySensor = tca8418_ns.class_( - "TCA8418BinarySensor", binary_sensor.BinarySensorInitiallyOff -) - def _key_code(value: int) -> int: value = cv.int_(value) @@ -42,7 +41,7 @@ def _key_code(value: int) -> int: _POSITION_MSG = f"'{CONF_ROW}' and '{CONF_COL}' must be given together" CONFIG_SCHEMA = cv.All( - binary_sensor.binary_sensor_schema(TCA8418BinarySensor).extend( + binary_sensor.binary_sensor_schema(binary_sensor.BinarySensorInitiallyOff).extend( { cv.GenerateID(CONF_KEYPAD_ID): cv.use_id(TCA8418Component), cv.Inclusive(CONF_ROW, "position", msg=_POSITION_MSG): cv.int_range( @@ -136,6 +135,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - var = await binary_sensor.new_binary_sensor(config, config[CONF_KEY_CODE]) + var = await binary_sensor.new_binary_sensor(config) keypad = await cg.get_variable(config[CONF_KEYPAD_ID]) - await register_listener(keypad, var) + key_code = config[CONF_KEY_CODE] + KEY_PUBLISHER.add( + keypad, tca8418_ns.publish_key(parent_ref(var), KEY, key_code, PRESSED) + ) diff --git a/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h deleted file mode 100644 index d6d58b93bc4..00000000000 --- a/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "esphome/components/binary_sensor/binary_sensor.h" -#include "../tca8418.h" - -namespace esphome::tca8418 { - -/// One key of a TCA8418, matched by the key number codegen resolved from the config -class TCA8418BinarySensor : public binary_sensor::BinarySensorInitiallyOff, public TCA8418Listener { - public: - explicit TCA8418BinarySensor(uint8_t key) : key_(key) {} - - void key_pressed(uint8_t key) override { - if (key == this->key_) - this->publish_state(true); - } - void key_released(uint8_t key) override { - if (key == this->key_) - this->publish_state(false); - } - - protected: - const uint8_t key_; -}; - -} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp index 1fa4869757b..7592b78bfaa 100644 --- a/esphome/components/tca8418/tca8418.cpp +++ b/esphome/components/tca8418/tca8418.cpp @@ -120,14 +120,9 @@ uint8_t TCA8418Component::key_char_(uint8_t key) const { void TCA8418Component::dispatch_(uint8_t key, bool pressed) { ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released")); -#ifdef TCA8418_LISTENER_COUNT - for (auto *listener : this->listeners_) { - if (pressed) { - listener->key_pressed(key); - } else { - listener->key_released(key); - } - } +#ifdef USE_TCA8418_KEY_PUBLISHER + if (this->key_publisher_ != nullptr) + this->key_publisher_(key, pressed); #endif if (!pressed) diff --git a/esphome/components/tca8418/tca8418.h b/esphome/components/tca8418/tca8418.h index 83d564d742c..1921d870b96 100644 --- a/esphome/components/tca8418/tca8418.h +++ b/esphome/components/tca8418/tca8418.h @@ -31,12 +31,11 @@ static constexpr uint8_t TCA8418_KEY_CODE_MASK = 0x7F; // The event queue holds ten entries, which bounds how many can be read at once. static constexpr uint8_t TCA8418_FIFO_DEPTH = 10; -class TCA8418Listener { - public: - /// Called with the key number reported by the device (1..80 matrix, 97..114 individual inputs). - virtual void key_pressed(uint8_t key) {} - virtual void key_released(uint8_t key) {} -}; +// A template on the sensor type keeps binary_sensor.h out of this hub header +template inline void publish_key(T *sensor, uint8_t key, uint8_t sensor_key, bool pressed) { + if (key == sensor_key) + sensor->publish_state(pressed); +} class TCA8418Component : public key_provider::KeyProvider, public Component, public i2c::I2CDevice { public: @@ -53,8 +52,9 @@ class TCA8418Component : public key_provider::KeyProvider, public Component, pub } void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } -#ifdef TCA8418_LISTENER_COUNT - void register_listener(TCA8418Listener *listener) { this->listeners_.push_back(listener); } +#ifdef USE_TCA8418_KEY_PUBLISHER + // key is 1..80 for the matrix, 97..114 for individual inputs + HUB_PUBLISHER(key, uint8_t, bool) #endif /// Wakes the loop so queued events are read. Runs in interrupt context. @@ -73,9 +73,6 @@ class TCA8418Component : public key_provider::KeyProvider, public Component, pub const uint8_t *keys_{nullptr}; // PROGMEM, indexed by key number - 1 InternalGPIOPin *interrupt_pin_{nullptr}; uint32_t last_poll_{0}; -#ifdef TCA8418_LISTENER_COUNT - StaticVector listeners_; -#endif uint8_t rows_{0}; uint8_t columns_{0}; uint8_t keys_size_{0}; diff --git a/esphome/components/tm1638/binary_sensor/__init__.py b/esphome/components/tm1638/binary_sensor/__init__.py index 4f89b7bf5ef..7225757e1bd 100644 --- a/esphome/components/tm1638/binary_sensor/__init__.py +++ b/esphome/components/tm1638/binary_sensor/__init__.py @@ -1,14 +1,13 @@ +from esphome.automation import parent_ref import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_KEY from esphome.types import ConfigType -from ..display import CONF_TM1638_ID, TM1638Component, tm1638_ns +from ..display import CONF_TM1638_ID, KEYS, KEYS_PUBLISHER, TM1638Component, tm1638_ns -TM1638Key = tm1638_ns.class_("TM1638Key", binary_sensor.BinarySensor) - -CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(TM1638Key).extend( +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( { cv.GenerateID(CONF_TM1638_ID): cv.use_id(TM1638Component), cv.Required(CONF_KEY): cv.int_range(min=0, max=15), @@ -18,6 +17,8 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(TM1638Key).extend( async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) - cg.add(var.set_keycode(config[CONF_KEY])) hub = await cg.get_variable(config[CONF_TM1638_ID]) - cg.add(hub.register_listener(var)) + KEYS_PUBLISHER.add( + hub, + tm1638_ns.publish_key(parent_ref(var), KEYS, config[CONF_KEY]), + ) diff --git a/esphome/components/tm1638/binary_sensor/tm1638_key.cpp b/esphome/components/tm1638/binary_sensor/tm1638_key.cpp deleted file mode 100644 index 9eecf97a9bd..00000000000 --- a/esphome/components/tm1638/binary_sensor/tm1638_key.cpp +++ /dev/null @@ -1,11 +0,0 @@ -#include "tm1638_key.h" - -namespace esphome::tm1638 { - -void TM1638Key::keys_update(uint8_t keys) { - bool pressed = keys & (1 << key_code_); - if (pressed != this->state) - this->publish_state(pressed); -} - -} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/binary_sensor/tm1638_key.h b/esphome/components/tm1638/binary_sensor/tm1638_key.h deleted file mode 100644 index 1e6336a1f4a..00000000000 --- a/esphome/components/tm1638/binary_sensor/tm1638_key.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include "esphome/components/binary_sensor/binary_sensor.h" -#include "../tm1638.h" - -namespace esphome::tm1638 { - -class TM1638Key final : public binary_sensor::BinarySensor, public KeyListener { - public: - void set_keycode(uint8_t key_code) { key_code_ = key_code; }; - void keys_update(uint8_t keys) override; - - protected: - uint8_t key_code_{0}; -}; - -} // namespace esphome::tm1638 diff --git a/esphome/components/tm1638/display.py b/esphome/components/tm1638/display.py index d7e375ac885..783f4059bab 100644 --- a/esphome/components/tm1638/display.py +++ b/esphome/components/tm1638/display.py @@ -19,6 +19,15 @@ tm1638_ns = cg.esphome_ns.namespace("tm1638") TM1638Component = tm1638_ns.class_("TM1638Component", cg.PollingComponent) TM1638ComponentRef = TM1638Component.operator("ref") +# Publishes the key binary sensors from the key bitmask read on each loop +KEYS_PUBLISHER = cg.HubFunction( + "keys", + cg.void, + [(cg.uint8, "keys")], + define="USE_TM1638_KEYS_PUBLISHER", +) +KEYS = KEYS_PUBLISHER.param("keys") + CONFIG_SCHEMA = display.BASIC_DISPLAY_SCHEMA.extend( { diff --git a/esphome/components/tm1638/tm1638.cpp b/esphome/components/tm1638/tm1638.cpp index ffb0ead0cd8..0bfeb99cf89 100644 --- a/esphome/components/tm1638/tm1638.cpp +++ b/esphome/components/tm1638/tm1638.cpp @@ -48,12 +48,10 @@ void TM1638Component::dump_config() { } void TM1638Component::loop() { - if (this->listeners_.empty()) - return; - - uint8_t keys = this->get_keys(); - for (auto &listener : this->listeners_) - listener->keys_update(keys); +#ifdef USE_TM1638_KEYS_PUBLISHER + if (this->keys_publisher_ != nullptr) + this->keys_publisher_(this->get_keys()); +#endif } uint8_t TM1638Component::get_keys() { diff --git a/esphome/components/tm1638/tm1638.h b/esphome/components/tm1638/tm1638.h index ef791a07753..36b05b0f8cd 100644 --- a/esphome/components/tm1638/tm1638.h +++ b/esphome/components/tm1638/tm1638.h @@ -4,17 +4,18 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/time.h" #include "esphome/components/display/display.h" -#include - namespace esphome::tm1638 { -class KeyListener { - public: - virtual void keys_update(uint8_t keys) {}; -}; +// A template on the sensor type keeps binary_sensor.h out of this hub header +template inline void publish_key(T *sensor, uint8_t keys, uint8_t key) { + bool pressed = keys & (1 << key); + if (pressed != sensor->state) + sensor->publish_state(pressed); +} class TM1638Component; @@ -34,7 +35,9 @@ class TM1638Component final : public PollingComponent { void set_dio_pin(GPIOPin *pin) { this->dio_pin_ = pin; } void set_stb_pin(GPIOPin *pin) { this->stb_pin_ = pin; } - void register_listener(KeyListener *listener) { this->listeners_.push_back(listener); } +#ifdef USE_TM1638_KEYS_PUBLISHER + HUB_PUBLISHER(keys, uint8_t) +#endif /// Evaluate the printf-format and print the result at the given position. uint8_t printf(uint8_t pos, const char *format, ...) __attribute__((format(printf, 3, 4))); @@ -71,7 +74,6 @@ class TM1638Component final : public PollingComponent { GPIOPin *dio_pin_; uint8_t buffer_[8]{}; tm1638_writer_t writer_{}; - std::vector listeners_{}; }; } // namespace esphome::tm1638 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0c19a8aaae3..21d30f0a535 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -273,7 +273,6 @@ #define SYSTA_BUS_LISTENER_COUNT 2 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 -#define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT 1 @@ -340,11 +339,14 @@ #define USE_STATUS_SENSOR #define USE_SWITCH #define USE_SWITCH_STORAGE +#define USE_SY6970_DATA_PUBLISHER +#define USE_TCA8418_KEY_PUBLISHER #define USE_TEXT #define USE_TEXT_SENSOR #define USE_TEXT_SENSOR_FILTER #define USE_TIME #define USE_TIME_TRIGGERS +#define USE_TM1638_KEYS_PUBLISHER #define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE #define USE_TMP102_NUMBER diff --git a/tests/component_tests/sy6970/__init__.py b/tests/component_tests/sy6970/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/sy6970/config/hub_function.yaml b/tests/component_tests/sy6970/config/hub_function.yaml new file mode 100644 index 00000000000..c0dc91a5e2a --- /dev/null +++ b/tests/component_tests/sy6970/config/hub_function.yaml @@ -0,0 +1,59 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sy6970: + - id: charger + - id: idle_charger + address: 0x6B + +binary_sensor: + - platform: sy6970 + sy6970_id: charger + vbus_connected: + name: VBUS Connected + id: vbus_connected + charging: + name: Charging + id: charging + charge_done: + name: Charge Done + id: charge_done + +sensor: + - platform: sy6970 + sy6970_id: charger + vbus_voltage: + name: VBUS Voltage + id: vbus_voltage + battery_voltage: + name: Battery Voltage + id: battery_voltage + system_voltage: + name: System Voltage + id: system_voltage + charge_current: + name: Charge Current + id: charge_current + precharge_current: + name: Precharge Current + id: precharge_current + +text_sensor: + - platform: sy6970 + sy6970_id: charger + bus_status: + name: Bus Status + id: bus_status + charge_status: + name: Charge Status + id: charge_status + ntc_status: + name: NTC Status + id: ntc_status diff --git a/tests/component_tests/sy6970/test_hub_function.py b/tests/component_tests/sy6970/test_hub_function.py new file mode 100644 index 00000000000..a9ff78f756d --- /dev/null +++ b/tests/component_tests/sy6970/test_hub_function.py @@ -0,0 +1,63 @@ +"""Tests for the function that publishes the sy6970 child entities.""" + +from collections.abc import Callable +from pathlib import Path + +REG = "esphome__arg_data.registers" +STATUS = f"{REG}[sy6970::SY6970_REG_STATUS]" + +# Every child, in config order, with its register decode +EXPECTED_BODY = [ + f"::vbus_connected->publish_state(((({STATUS} >> 5) & 7) != sy6970::BUS_STATUS_NO_INPUT));", + f"::charging->publish_state(sy6970::is_charging((({STATUS} >> 3) & 3)));", + f"::charge_done->publish_state(((({STATUS} >> 3) & 3) == sy6970::CHARGE_STATUS_CHARGE_DONE));", + ( + "::vbus_voltage->publish_state(((sy6970::VBUS_BASE_MV + " + f"(({REG}[sy6970::SY6970_REG_VBUS_VOLTAGE] & 127) * sy6970::VBUS_STEP_MV)) * 0.001f));" + ), + ( + "::battery_voltage->publish_state(((sy6970::VBAT_BASE_MV + " + f"(({REG}[sy6970::SY6970_REG_BATV] & 127) * sy6970::VBAT_STEP_MV)) * 0.001f));" + ), + ( + "::system_voltage->publish_state(((sy6970::VSYS_BASE_MV + " + f"(({REG}[sy6970::SY6970_REG_VINDPM_STATUS] & 127) * sy6970::VSYS_STEP_MV)) * 0.001f));" + ), + ( + "::charge_current->publish_state(((" + f"{REG}[sy6970::SY6970_REG_CHARGE_CURRENT_MONITOR] & 127) * sy6970::CHG_CURRENT_STEP_MA));" + ), + ( + "::precharge_current->publish_state((sy6970::PRE_CHG_BASE_MA + " + f"((({REG}[sy6970::SY6970_REG_PRECHARGE_CURRENT] >> 4) & 15) * sy6970::PRE_CHG_STEP_MA)));" + ), + ( + "::bus_status->publish_state(sy6970::BusStatusStrings::get_progmem_str(" + f"(({STATUS} >> 5) & 7), sy6970::BusStatusStrings::LAST_INDEX));" + ), + ( + "::charge_status->publish_state(sy6970::ChargeStatusStrings::get_progmem_str(" + f"(({STATUS} >> 3) & 3), sy6970::ChargeStatusStrings::LAST_INDEX));" + ), + ( + "::ntc_status->publish_state(sy6970::NtcStatusStrings::get_progmem_str(" + f"({REG}[sy6970::SY6970_REG_FAULT] & 7), sy6970::NtcStatusStrings::LAST_INDEX));" + ), +] + + +def test_children_publish_through_hub_function( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("hub_function.yaml")) + + head = ( + "static void esphome__charger__data_fn(" + "const sy6970::SY6970Data & esphome__arg_data) {\n" + ) + body = main_cpp.split(head, 1)[1].split("\n}", 1)[0] + assert [line.strip() for line in body.splitlines()] == EXPECTED_BODY + assert "charger->set_data_publisher(esphome__charger__data_fn);" in main_cpp + # A hub without children keeps a null publisher + assert "idle_charger->set_data_publisher" not in main_cpp diff --git a/tests/component_tests/tca8418/config/hub_function.yaml b/tests/component_tests/tca8418/config/hub_function.yaml new file mode 100644 index 00000000000..70385a26b73 --- /dev/null +++ b/tests/component_tests/tca8418/config/hub_function.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +tca8418: + - id: keypad + rows: 2 + columns: 2 + keys: "1234" + - id: idle_keypad + address: 0x35 + +binary_sensor: + - platform: tca8418 + keypad_id: keypad + key: "4" + id: key_four + - platform: tca8418 + keypad_id: keypad + row: 0 + col: 0 + id: key_one diff --git a/tests/component_tests/tca8418/test_hub_function.py b/tests/component_tests/tca8418/test_hub_function.py new file mode 100644 index 00000000000..378a121dbc1 --- /dev/null +++ b/tests/component_tests/tca8418/test_hub_function.py @@ -0,0 +1,22 @@ +"""Tests for the function that publishes the tca8418 binary sensors.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_binary_sensors_publish_through_hub_function( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("hub_function.yaml")) + + assert ( + "static void esphome__keypad__key_fn(uint8_t esphome__arg_key, " + "bool esphome__arg_pressed) {\n" + " tca8418::publish_key(::key_four, esphome__arg_key, 12, esphome__arg_pressed);\n" + " tca8418::publish_key(::key_one, esphome__arg_key, 1, esphome__arg_pressed);\n" + "}" + ) in main_cpp + assert "keypad->set_key_publisher(esphome__keypad__key_fn);" in main_cpp + # A keypad without binary sensors keeps a null publisher + assert "idle_keypad->set_key_publisher" not in main_cpp diff --git a/tests/component_tests/tm1638/__init__.py b/tests/component_tests/tm1638/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/tm1638/config/hub_function.yaml b/tests/component_tests/tm1638/config/hub_function.yaml new file mode 100644 index 00000000000..2b699e6ddc7 --- /dev/null +++ b/tests/component_tests/tm1638/config/hub_function.yaml @@ -0,0 +1,27 @@ +esphome: + name: test + +esp32: + board: esp32dev + +display: + - platform: tm1638 + id: panel + clk_pin: GPIO16 + stb_pin: GPIO17 + dio_pin: GPIO18 + - platform: tm1638 + id: idle_panel + clk_pin: GPIO19 + stb_pin: GPIO21 + dio_pin: GPIO22 + +binary_sensor: + - platform: tm1638 + tm1638_id: panel + id: button0 + key: 0 + - platform: tm1638 + tm1638_id: panel + id: button7 + key: 7 diff --git a/tests/component_tests/tm1638/test_hub_function.py b/tests/component_tests/tm1638/test_hub_function.py new file mode 100644 index 00000000000..5f99889217d --- /dev/null +++ b/tests/component_tests/tm1638/test_hub_function.py @@ -0,0 +1,21 @@ +"""Tests for the function that publishes the tm1638 key binary sensors.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_keys_publish_through_hub_function( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("hub_function.yaml")) + + assert ( + "static void esphome__panel__keys_fn(uint8_t esphome__arg_keys) {\n" + " tm1638::publish_key(::button0, esphome__arg_keys, 0);\n" + " tm1638::publish_key(::button7, esphome__arg_keys, 7);\n" + "}" + ) in main_cpp + assert "panel->set_keys_publisher(esphome__panel__keys_fn);" in main_cpp + # A panel without keys keeps a null publisher, so its loop reads nothing + assert "idle_panel->set_keys_publisher" not in main_cpp From 7b380674ee679709fb4f575ff05a71b6dcd17497 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 20:14:38 -0600 Subject: [PATCH 51/55] [gpio_expander] Add an input store to the cached expander bases (#22056) --- .../components/gpio_expander/cached_gpio.h | 71 ++++++++++---- .../gpio_expander/cached_gpio_component.cpp | 5 +- .../gpio_expander/cached_gpio_component.h | 21 ++--- .../test_cached_gpio_component.cpp | 22 +++-- .../test_gpio_expander_inputs.cpp | 93 +++++++++++++++++++ 5 files changed, 170 insertions(+), 42 deletions(-) create mode 100644 tests/components/gpio_expander/test_gpio_expander_inputs.cpp diff --git a/esphome/components/gpio_expander/cached_gpio.h b/esphome/components/gpio_expander/cached_gpio.h index ddb9e63686f..bddcc28d4bf 100644 --- a/esphome/components/gpio_expander/cached_gpio.h +++ b/esphome/components/gpio_expander/cached_gpio.h @@ -9,6 +9,9 @@ namespace esphome::gpio_expander { +/// Pin number type for an expander with N pins. +template using expander_pin_t = std::conditional_t<(N > 256), uint16_t, uint8_t>; + /// @brief A class to cache the read state of a GPIO expander. /// This class caches reads between GPIO Pins which are on the same bank. /// This means that for reading whole Port (ex. 8 pins) component needs only one @@ -25,21 +28,25 @@ namespace esphome::gpio_expander { /// P - Type for pin number parameters (automatically selected based on N: /// uint8_t for N<=256, uint16_t for N>256). Can be explicitly specified /// if needed (e.g., for components like SN74HC165 with >256 pins) -template 256), uint16_t, uint8_t>::type> -class CachedGpioExpander { +/// +/// CachedGpioExpanderBase holds the cache; CachedGpioExpander adds the invalidate-on-read flag, and +/// CachedGpioExpanderComponent takes it from the interrupt pin instead. +template> class CachedGpioExpanderBase { public: - /// @brief Read the state of the given pin. - /// By default, each read invalidates the pin's cache entry so the next read - /// of the same pin triggers a fresh hardware read. When invalidate_on_read - /// is disabled, the cache stays valid until explicitly cleared via reset_pin_cache_(). - /// @param pin Pin number to read - /// @return Pin state - bool digital_read(P pin) { + void digital_write(P pin, bool value) { this->digital_write_hw(pin, value); } + + protected: + using bank_t = T; + using pin_t = P; + + /// Read pin through the bank cache. With invalidate_on_read the pin is read from hardware again next + /// time; otherwise the bank stays cached until reset_pin_cache_(). + bool cached_read_(P pin, bool invalidate_on_read) { const P bank = pin / BANK_SIZE; - const T pin_mask = (1 << (pin % BANK_SIZE)); + const T pin_mask = T(1) << (pin % BANK_SIZE); // Check if specific pin cache is valid if (this->read_cache_valid_[bank] & pin_mask) { - if (this->invalidate_on_read_) { + if (invalidate_on_read) { // Invalidate pin so next read triggers hardware read this->read_cache_valid_[bank] &= ~pin_mask; } @@ -49,14 +56,11 @@ class CachedGpioExpander { return false; // Mark bank cache as valid except the pin that is being returned now // (when not invalidating on read, mark all pins including this one as valid) - this->read_cache_valid_[bank] = std::numeric_limits::max() & ~(this->invalidate_on_read_ ? pin_mask : 0); + this->read_cache_valid_[bank] = std::numeric_limits::max() & ~(invalidate_on_read ? pin_mask : 0); } return this->digital_read_cache(pin); } - void digital_write(P pin, bool value) { this->digital_write_hw(pin, value); } - - protected: /// @brief Read GPIO bank from hardware into internal state /// @param pin Pin number (used to determine which bank to read) /// @return true if read succeeded, false on communication error @@ -77,18 +81,45 @@ class CachedGpioExpander { /// @brief Invalidate cache. This function should be called in component loop(). void reset_pin_cache_() { memset(this->read_cache_valid_, 0x00, CACHE_SIZE_BYTES); } - /// @brief Control whether digital_read() invalidates the pin's cache entry after reading. - /// When enabled (default), each read self-invalidates so the next read triggers a hardware read. - /// When disabled, cache stays valid until reset_pin_cache_() is explicitly called. - void set_invalidate_on_read_(bool invalidate) { this->invalidate_on_read_ = invalidate; } - static constexpr uint16_t BITS_PER_BYTE = 8; static constexpr uint16_t BANK_SIZE = sizeof(T) * BITS_PER_BYTE; static constexpr size_t BANKS = N / BANK_SIZE; static constexpr size_t CACHE_SIZE_BYTES = BANKS * sizeof(T); T read_cache_valid_[BANKS]{0}; +}; + +/// A CachedGpioExpanderBase whose digital_read() invalidates each pin it returns unless told otherwise. +template> +class CachedGpioExpander : public CachedGpioExpanderBase { + public: + bool digital_read(P pin) { return this->cached_read_(pin, this->invalidate_on_read_); } + + protected: + /// @brief Control whether digital_read() invalidates the pin's cache entry after reading. + /// When enabled (default), each read self-invalidates so the next read triggers a hardware read. + /// When disabled, cache stays valid until reset_pin_cache_() is explicitly called. + void set_invalidate_on_read_(bool invalidate) { this->invalidate_on_read_ = invalidate; } + bool invalidate_on_read_{true}; }; +/// Adds a store of the last value read from each bank to a read cache Base. digital_read_hw() stores the +/// bank it reads in input_[pin / BANK_SIZE] (a chip read in one transfer is one bank, input_[0]), and +/// digital_read_cache() is provided. +template class GpioExpanderInputs : public Base { + protected: + using typename Base::bank_t; + using typename Base::pin_t; + using Base::BANK_SIZE; + using Base::BANKS; + + bool digital_read_cache(pin_t pin) override { + return this->input_[pin / BANK_SIZE] & (bank_t(1) << (pin % BANK_SIZE)); + } + + /// The input state per bank - 1 means HIGH, 0 means LOW + bank_t input_[BANKS]{}; +}; + } // namespace esphome::gpio_expander diff --git a/esphome/components/gpio_expander/cached_gpio_component.cpp b/esphome/components/gpio_expander/cached_gpio_component.cpp index 7f32ce07d67..0be2a393b37 100644 --- a/esphome/components/gpio_expander/cached_gpio_component.cpp +++ b/esphome/components/gpio_expander/cached_gpio_component.cpp @@ -5,18 +5,17 @@ namespace esphome::gpio_expander { // Not a member of the template: GCC 10 (ESP8266) ignores section attributes on template instantiations. static void IRAM_ATTR interrupt_pin_isr(Component *arg) { arg->enable_loop_soon_any_context(); } -bool InterruptExpanderComponent::attach_interrupt_pin_() { +void InterruptExpanderComponent::setup_interrupt_pin_() { // Polling mode enables the loop in pin_mode() for the first input; interrupt mode from the ISR. this->disable_loop(); if (this->interrupt_pin_ == nullptr) - return false; + return; this->interrupt_pin_->setup(); this->interrupt_pin_->attach_interrupt(&interrupt_pin_isr, static_cast(this), gpio::INTERRUPT_FALLING_EDGE); // An INT line already low at attach gives no falling edge, so read the inputs now if (!this->interrupt_pin_->digital_read()) this->enable_loop(); - return true; } // Input changes that straddle the read leave INT asserted without a new falling edge, so keep looping diff --git a/esphome/components/gpio_expander/cached_gpio_component.h b/esphome/components/gpio_expander/cached_gpio_component.h index 1d748c9bbeb..adaf9ea57a2 100644 --- a/esphome/components/gpio_expander/cached_gpio_component.h +++ b/esphome/components/gpio_expander/cached_gpio_component.h @@ -14,9 +14,9 @@ class InterruptExpanderComponent : public Component { float get_setup_priority() const override { return setup_priority::IO; } protected: - /// Attach the falling-edge ISR when there is an interrupt pin, then disable the loop until an input - /// needs it. Returns whether the interrupt pin is in use. - bool attach_interrupt_pin_(); + /// Call at the end of setup(): attach the falling-edge ISR when there is an interrupt pin, then disable the + /// loop until an input needs it. + void setup_interrupt_pin_(); /// The loop runs again after the INT line falls; stop it once the line is released (HIGH). /// Only called with an interrupt pin. void disable_loop_when_released_(); @@ -29,22 +29,19 @@ class InterruptExpanderComponent : public Component { InternalGPIOPin *interrupt_pin_{nullptr}; }; -/// Base for expanders that cache reads per loop. With an interrupt pin the cache stays valid until INT fires. +/// Base for expanders that cache reads per loop, with the per-bank input store of GpioExpanderInputs. Without +/// an interrupt pin each read invalidates its pin; with one the cache stays valid until INT fires. template -class CachedGpioExpanderComponent : public InterruptExpanderComponent, public CachedGpioExpander { +class CachedGpioExpanderComponent : public InterruptExpanderComponent, + public GpioExpanderInputs> { public: + bool digital_read(expander_pin_t pin) { return this->cached_read_(pin, this->interrupt_pin_ == nullptr); } + void loop() override { this->reset_pin_cache_(); if (this->interrupt_pin_ != nullptr) this->disable_loop_when_released_(); } - - protected: - /// Call at the end of setup(). - void setup_interrupt_pin_() { - if (this->attach_interrupt_pin_()) - this->set_invalidate_on_read_(false); - } }; } // namespace esphome::gpio_expander diff --git a/tests/components/gpio_expander/test_cached_gpio_component.cpp b/tests/components/gpio_expander/test_cached_gpio_component.cpp index 5f357e0730e..ccf74e3d658 100644 --- a/tests/components/gpio_expander/test_cached_gpio_component.cpp +++ b/tests/components/gpio_expander/test_cached_gpio_component.cpp @@ -4,7 +4,7 @@ namespace esphome::gpio_expander::testing { -class FakeComponent : public CachedGpioExpanderComponent { +class FakeComponent : public CachedGpioExpanderComponent { public: void setup() override { this->setup_interrupt_pin_(); } void pin_mode(uint8_t pin, gpio::Flags flags) { @@ -12,14 +12,15 @@ class FakeComponent : public CachedGpioExpanderComponent { this->enable_polling_loop_(); } + uint8_t chip[2]{}; unsigned reads{0}; protected: bool digital_read_hw(uint8_t pin) override { this->reads++; + this->input_[pin / 8] = this->chip[pin / 8]; return true; } - bool digital_read_cache(uint8_t pin) override { return false; } void digital_write_hw(uint8_t pin, bool value) override {} }; @@ -57,13 +58,20 @@ TEST_F(CachedGpioComponentTest, WithoutAnInterruptPinEveryReadHitsTheChip) { TEST_F(CachedGpioComponentTest, InterruptPinKeepsTheCacheUntilTheLoopRuns) { this->component_.set_interrupt_pin(&this->int_pin_); this->component_.setup(); - this->component_.digital_read(0); - this->component_.digital_read(0); - EXPECT_EQ(this->component_.reads, 1u); + this->component_.chip[0] = 0x01; + this->component_.chip[1] = 0x80; + EXPECT_TRUE(this->component_.digital_read(0)); + EXPECT_TRUE(this->component_.digital_read(15)); + this->component_.chip[0] = 0x00; + this->component_.chip[1] = 0x00; + EXPECT_TRUE(this->component_.digital_read(0)); + EXPECT_TRUE(this->component_.digital_read(15)); + EXPECT_EQ(this->component_.reads, 2u); this->component_.loop(); - this->component_.digital_read(0); - EXPECT_EQ(this->component_.reads, 2u); + EXPECT_FALSE(this->component_.digital_read(0)); + EXPECT_FALSE(this->component_.digital_read(15)); + EXPECT_EQ(this->component_.reads, 4u); } TEST_F(CachedGpioComponentTest, LoopStaysOnUntilIntIsReleased) { diff --git a/tests/components/gpio_expander/test_gpio_expander_inputs.cpp b/tests/components/gpio_expander/test_gpio_expander_inputs.cpp new file mode 100644 index 00000000000..e77633dfb4a --- /dev/null +++ b/tests/components/gpio_expander/test_gpio_expander_inputs.cpp @@ -0,0 +1,93 @@ +#include + +#include "esphome/components/gpio_expander/cached_gpio.h" + +namespace esphome::gpio_expander::testing { + +// Reads one bank of the fake chip per digital_read_hw() call. +template class FakeInputsExpander : public GpioExpanderInputs> { + using Base = GpioExpanderInputs>; + + public: + using Base::BANKS; + + void keep_cache() { this->set_invalidate_on_read_(false); } + void reset() { this->reset_pin_cache_(); } + + T chip[BANKS]{}; + unsigned reads[BANKS]{}; + bool fail{false}; + + protected: + bool digital_read_hw(uint8_t pin) override { + const uint8_t bank = pin / this->BANK_SIZE; + this->reads[bank]++; + if (this->fail) + return false; + this->input_[bank] = this->chip[bank]; + return true; + } + void digital_write_hw(uint8_t pin, bool value) override {} +}; + +// Every pin returns its bit of the packed chip state, with one read per bank. +template void expect_every_pin(uint32_t packed) { + FakeInputsExpander expander; + constexpr uint8_t bank_size = sizeof(T) * 8; + for (size_t bank = 0; bank < expander.BANKS; bank++) + expander.chip[bank] = packed >> (bank * bank_size); + for (uint16_t pin = 0; pin < N; pin++) + EXPECT_EQ(expander.digital_read(pin), bool((packed >> pin) & 1)) << "pin " << unsigned(pin); + for (size_t bank = 0; bank < expander.BANKS; bank++) + EXPECT_EQ(expander.reads[bank], 1u) << "bank " << bank; +} + +TEST(GpioExpanderInputs, ReturnsEveryPin) { + expect_every_pin(0x3CA5); + expect_every_pin(0x8001); // one bank read in one transfer + expect_every_pin(0x40028001); +} + +TEST(GpioExpanderInputs, ReadingOneBankKeepsTheOther) { + FakeInputsExpander expander; + expander.chip[0] = 0xFF; + expander.chip[1] = 0x0F; + EXPECT_TRUE(expander.digital_read(0)); + EXPECT_TRUE(expander.digital_read(8)); + + // Pin 8 was invalidated by its read, so only bank 1 is read again. + expander.chip[0] = 0x00; + expander.chip[1] = 0xF0; + EXPECT_FALSE(expander.digital_read(8)); + EXPECT_TRUE(expander.digital_read(12)); + EXPECT_TRUE(expander.digital_read(1)); + EXPECT_EQ(expander.reads[0], 1u); + EXPECT_EQ(expander.reads[1], 2u); +} + +TEST(GpioExpanderInputs, FailedReadReturnsLowAndKeepsOtherPinsCached) { + FakeInputsExpander expander; + expander.chip[0] = 0x03; + EXPECT_TRUE(expander.digital_read(0)); + + expander.fail = true; + expander.chip[0] = 0x00; + EXPECT_FALSE(expander.digital_read(0)); + EXPECT_TRUE(expander.digital_read(1)); +} + +TEST(GpioExpanderInputs, KeepCacheUntilReset) { + FakeInputsExpander expander; + expander.keep_cache(); + expander.chip[0] = 0x01; + EXPECT_TRUE(expander.digital_read(0)); + expander.chip[0] = 0x00; + EXPECT_TRUE(expander.digital_read(0)); + EXPECT_EQ(expander.reads[0], 1u); + + expander.reset(); + EXPECT_FALSE(expander.digital_read(0)); + EXPECT_EQ(expander.reads[0], 2u); +} + +} // namespace esphome::gpio_expander::testing From fc106564ed1fd2e1047428878b0333baf1cbcd29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 20:16:06 -0600 Subject: [PATCH 52/55] [bme680_bsec] Publish pending readings from fixed storage instead of a heap queue (#22193) --- .../components/bme680_bsec/bme680_bsec.cpp | 84 ++++++++++++++----- esphome/components/bme680_bsec/bme680_bsec.h | 32 ++++++- tests/components/bme680_bsec/test_publish.cpp | 17 +++- 3 files changed, 105 insertions(+), 28 deletions(-) diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index 36b92ea9efb..1822f8403b7 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -193,12 +193,54 @@ void BME680BSECComponent::loop() { } 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. - if (this->queue_.size()) { - auto action = std::move(this->queue_.front()); - this->queue_.pop(); - action(); + // Publish a single waiting state. All of them in one call take too long. + if (this->pending_.empty()) + return; + const PublishSlot slot = this->pending_.front(); + this->pending_.pop(); + this->pending_mask_.erase(slot); + switch (slot) { + case PUBLISH_SLOT_IAQ: + this->publish_sensor_(this->iaq_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_IAQ_ACCURACY_TEXT: + this->publish_sensor_( + this->iaq_accuracy_text_sensor_, + IaqAccuracyStrings::get_progmem_str(this->pending_accuracy_, IaqAccuracyStrings::LAST_INDEX)); + break; + case PUBLISH_SLOT_IAQ_ACCURACY: + this->publish_sensor_(this->iaq_accuracy_sensor_, this->pending_accuracy_, true); + break; + case PUBLISH_SLOT_SAVE_STATE: + this->save_state_(this->pending_accuracy_); + break; + case PUBLISH_SLOT_CO2_EQUIVALENT: + this->publish_sensor_(this->co2_equivalent_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_BREATH_VOC_EQUIVALENT: + this->publish_sensor_(this->breath_voc_equivalent_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_PRESSURE: + this->publish_sensor_(this->pressure_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_GAS_RESISTANCE: + this->publish_sensor_(this->gas_resistance_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_TEMPERATURE: + this->publish_sensor_(this->temperature_sensor_, this->pending_values_[slot]); + break; + case PUBLISH_SLOT_HUMIDITY: + this->publish_sensor_(this->humidity_sensor_, this->pending_values_[slot]); + break; + default: + break; + } +} + +void BME680BSECComponent::queue_publish_(PublishSlot slot) { + if (!this->pending_mask_.count(slot)) { + this->pending_mask_.insert(slot); + this->pending_.push(slot); } } @@ -385,35 +427,31 @@ void BME680BSECComponent::publish_(const bsec_output_t *outputs, uint8_t num_out float signal = outputs[i].signal; switch (outputs[i].sensor_id) { case BSEC_OUTPUT_IAQ: - case BSEC_OUTPUT_STATIC_IAQ: { - uint8_t accuracy = std::min(outputs[i].accuracy, IaqAccuracyStrings::LAST_INDEX); - this->queue_push_([this, signal]() { this->publish_sensor_(this->iaq_sensor_, signal); }); - this->queue_push_([this, accuracy]() { - this->publish_sensor_(this->iaq_accuracy_text_sensor_, - IaqAccuracyStrings::get_progmem_str(accuracy, IaqAccuracyStrings::LAST_INDEX)); - }); - this->queue_push_([this, accuracy]() { this->publish_sensor_(this->iaq_accuracy_sensor_, accuracy, true); }); - + case BSEC_OUTPUT_STATIC_IAQ: + this->pending_accuracy_ = std::min(outputs[i].accuracy, IaqAccuracyStrings::LAST_INDEX); + this->queue_publish_(signal); + this->queue_publish_(PUBLISH_SLOT_IAQ_ACCURACY_TEXT); + this->queue_publish_(PUBLISH_SLOT_IAQ_ACCURACY); // Queue up an opportunity to save state - this->queue_push_([this, accuracy]() { this->save_state_(accuracy); }); - } break; + this->queue_publish_(PUBLISH_SLOT_SAVE_STATE); + break; case BSEC_OUTPUT_CO2_EQUIVALENT: - this->queue_push_([this, signal]() { this->publish_sensor_(this->co2_equivalent_sensor_, signal); }); + this->queue_publish_(signal); break; case BSEC_OUTPUT_BREATH_VOC_EQUIVALENT: - this->queue_push_([this, signal]() { this->publish_sensor_(this->breath_voc_equivalent_sensor_, signal); }); + this->queue_publish_(signal); break; case BSEC_OUTPUT_RAW_PRESSURE: - this->queue_push_([this, signal]() { this->publish_sensor_(this->pressure_sensor_, signal / 100.0f); }); + this->queue_publish_(signal / 100.0f); break; case BSEC_OUTPUT_RAW_GAS: - this->queue_push_([this, signal]() { this->publish_sensor_(this->gas_resistance_sensor_, signal); }); + this->queue_publish_(signal); break; case BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE: - this->queue_push_([this, signal]() { this->publish_sensor_(this->temperature_sensor_, signal); }); + this->queue_publish_(signal); break; case BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY: - this->queue_push_([this, signal]() { this->publish_sensor_(this->humidity_sensor_, signal); }); + this->queue_publish_(signal); break; } } diff --git a/esphome/components/bme680_bsec/bme680_bsec.h b/esphome/components/bme680_bsec/bme680_bsec.h index 3670866912f..276ab9af256 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.h +++ b/esphome/components/bme680_bsec/bme680_bsec.h @@ -6,8 +6,9 @@ #include "esphome/components/i2c/i2c.h" #include "esphome/core/preferences.h" #include "esphome/core/defines.h" +#include "esphome/core/finite_set_mask.h" +#include "esphome/core/helpers.h" #include -#include #ifdef USE_BSEC #include @@ -67,6 +68,22 @@ class BME680BSECComponent final : public Component, public i2c::I2CDevice { void loop() override; protected: + // States waiting for loop() to publish them, one per pass + enum PublishSlot : uint8_t { + PUBLISH_SLOT_IAQ, + PUBLISH_SLOT_CO2_EQUIVALENT, + PUBLISH_SLOT_BREATH_VOC_EQUIVALENT, + PUBLISH_SLOT_PRESSURE, + PUBLISH_SLOT_GAS_RESISTANCE, + PUBLISH_SLOT_TEMPERATURE, + PUBLISH_SLOT_HUMIDITY, + // These take pending_accuracy_ instead of a value + PUBLISH_SLOT_IAQ_ACCURACY_TEXT, + PUBLISH_SLOT_IAQ_ACCURACY, + PUBLISH_SLOT_SAVE_STATE, + PUBLISH_SLOT_COUNT, + }; + void set_config_(); float calc_sensor_sample_rate_(SampleRate sample_rate); void update_subscription_(); @@ -91,7 +108,12 @@ class BME680BSECComponent final : public Component, public i2c::I2CDevice { void save_state_( uint8_t accuracy); // Save the bsec_state_data_ member (volatile memory) to the ESP preferences (storage) - void queue_push_(std::function &&f) { this->queue_.push(std::move(f)); } + void queue_publish_(PublishSlot slot); + template void queue_publish_(float value) { + static_assert(S < PUBLISH_SLOT_IAQ_ACCURACY_TEXT, "slot has no value"); + this->pending_values_[S] = value; + this->queue_publish_(S); + } static uint8_t work_buffer_[BSEC_MAX_WORKBUFFER_SIZE]; struct bme680_dev bme680_; @@ -102,7 +124,11 @@ class BME680BSECComponent final : public Component, public i2c::I2CDevice { uint32_t millis_overflow_counter_{0}; int64_t next_call_ns_{0}; - std::queue> queue_; + // Arrival order; a slot already waiting keeps its place and takes the newer value + StaticRingBuffer pending_; + FiniteSetMask> pending_mask_; + float pending_values_[PUBLISH_SLOT_IAQ_ACCURACY_TEXT]; + uint8_t pending_accuracy_{0}; bool bsec_state_data_valid_; uint8_t bsec_state_data_[BSEC_MAX_STATE_BLOB_SIZE]; // This is the current snapshot of the BSEC library state diff --git a/tests/components/bme680_bsec/test_publish.cpp b/tests/components/bme680_bsec/test_publish.cpp index bb0a7872fec..d061a6b4460 100644 --- a/tests/components/bme680_bsec/test_publish.cpp +++ b/tests/components/bme680_bsec/test_publish.cpp @@ -181,8 +181,21 @@ TEST_F(BSECPublishTest, NewReadingBeforeTheLastWentOut) { output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 21.0f), output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 41.0f), }); - // Both readings go out in full, in arrival order. - EXPECT_EQ(this->passes(4), (Passes{{"humidity=40"}, {"temperature=21"}, {"humidity=41"}, {}})); + // A state still waiting keeps its place and takes the newer value; the others queue behind it. + EXPECT_EQ(this->passes(3), (Passes{{"humidity=41"}, {"temperature=21"}, {}})); +} + +TEST_F(BSECPublishTest, WaitingAccuracyTakesTheNewerReading) { + this->attach_all(); + this->component.set_state_save_interval(0); + this->arm({output(BSEC_OUTPUT_IAQ, 30.0f, 1)}); + EXPECT_EQ(this->pass(), (std::vector{"iaq=30"})); + this->arm({output(BSEC_OUTPUT_IAQ, 31.0f, 3)}); + // The accuracy and save slots still waiting go out once, with the newer accuracy; IAQ queues behind them. + EXPECT_EQ(this->passes(3), (Passes{{"iaq_accuracy_text=Calibrated"}, {"iaq_accuracy=3"}, {}})); + EXPECT_EQ(bsec_double::get_state_calls, 1); + EXPECT_EQ(this->passes(2), (Passes{{"iaq=31"}, {}})); + EXPECT_EQ(bsec_double::get_state_calls, 1); } } // namespace From ce35de646839515485312f3dd81c67992020fe50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 20:29:01 -0600 Subject: [PATCH 53/55] [components] Use the gpio_expander input store in every cached expander (#22057) --- esphome/components/mcp23016/mcp23016.cpp | 3 +-- esphome/components/mcp23016/mcp23016.h | 3 --- .../mcp23x08_base/mcp23x08_base.cpp | 4 +--- .../components/mcp23x08_base/mcp23x08_base.h | 4 ---- .../mcp23x17_base/mcp23x17_base.cpp | 18 ++++------------- .../components/mcp23x17_base/mcp23x17_base.h | 4 ---- esphome/components/pca6416a/pca6416a.cpp | 10 +--------- esphome/components/pca6416a/pca6416a.h | 7 ++----- esphome/components/pca9505/pca9505.cpp | 2 -- esphome/components/pca9505/pca9505.h | 3 --- esphome/components/pca9554/pca9554.cpp | 17 +++------------- esphome/components/pca9554/pca9554.h | 3 --- esphome/components/pcf8574/pcf8574.cpp | 11 +++------- esphome/components/pcf8574/pcf8574.h | 3 --- .../components/pi4ioe5v6408/pi4ioe5v6408.cpp | 4 +--- .../components/pi4ioe5v6408/pi4ioe5v6408.h | 3 --- esphome/components/tca9555/tca9555.cpp | 20 ++++--------------- esphome/components/tca9555/tca9555.h | 3 --- .../waveshare_io_ch32v003.cpp | 3 +-- .../waveshare_io_ch32v003.h | 9 ++++----- 20 files changed, 25 insertions(+), 109 deletions(-) diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index cd7c2943995..bfc8b8a5ac0 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -26,9 +26,8 @@ void MCP23016::setup() { this->setup_interrupt_pin_(); } -bool MCP23016::digital_read_hw(uint8_t pin) { return this->read_reg_(MCP23016_GP1, &this->input_mask_); } +bool MCP23016::digital_read_hw(uint8_t pin) { return this->read_reg_(MCP23016_GP1, &this->input_[0]); } -bool MCP23016::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } void MCP23016::digital_write_hw(uint8_t pin, bool value) { this->update_reg_(pin, value, MCP23016_OLAT1); } void MCP23016::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 1c2d270f916..ec547bf9499 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -35,7 +35,6 @@ class MCP23016 final : public gpio_expander::CachedGpioExpanderComponent; diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index 4eb4946b93e..91795c18e5e 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -7,7 +7,7 @@ namespace esphome::mcp23x08_base { ESPHOME_LOG_TAG(TAG, "mcp23x08_base"); bool MCP23X08Base::digital_read_hw(uint8_t pin) { - if (!this->read_reg(mcp23x08_base::MCP23X08_GPIO, &this->input_mask_)) { + if (!this->read_reg(mcp23x08_base::MCP23X08_GPIO, &this->input_[0])) { this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return false; } @@ -19,8 +19,6 @@ void MCP23X08Base::digital_write_hw(uint8_t pin, bool value) { this->update_reg(pin, value, reg_addr); } -bool MCP23X08Base::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } - void MCP23X08Base::pin_mode(uint8_t pin, gpio::Flags flags) { uint8_t iodir = mcp23x08_base::MCP23X08_IODIR; uint8_t gppu = mcp23x08_base::MCP23X08_GPPU; diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.h b/esphome/components/mcp23x08_base/mcp23x08_base.h index 0d9fefaf948..88e98994bf1 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.h +++ b/esphome/components/mcp23x08_base/mcp23x08_base.h @@ -25,7 +25,6 @@ class MCP23X08Base : public mcp23xxx_base::MCP23XXXBase<8> { public: bool digital_read_hw(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; - bool digital_read_cache(uint8_t pin) override; void pin_mode(uint8_t pin, gpio::Flags flags) override; void pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) override; @@ -34,9 +33,6 @@ class MCP23X08Base : public mcp23xxx_base::MCP23XXXBase<8> { void update_reg(uint8_t pin, bool pin_value, uint8_t reg_a) override; uint8_t olat_{0x00}; - - /// State read in digital_read_hw - uint8_t input_mask_{0x00}; }; } // namespace esphome::mcp23x08_base diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index 1e430eb002c..4f650625b0e 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -8,19 +8,11 @@ ESPHOME_LOG_TAG(TAG, "mcp23x17_base"); bool MCP23X17Base::digital_read_hw(uint8_t pin) { uint8_t data; - if (pin < 8) { - if (!this->read_reg(mcp23x17_base::MCP23X17_GPIOA, &data)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return false; - } - this->input_mask_ = encode_uint16(this->input_mask_ >> 8, data); - } else { - if (!this->read_reg(mcp23x17_base::MCP23X17_GPIOB, &data)) { - this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); - return false; - } - this->input_mask_ = encode_uint16(data, this->input_mask_ & 0xFF); + if (!this->read_reg(pin < 8 ? mcp23x17_base::MCP23X17_GPIOA : mcp23x17_base::MCP23X17_GPIOB, &data)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; } + this->input_[pin / 8] = data; return true; } @@ -29,8 +21,6 @@ void MCP23X17Base::digital_write_hw(uint8_t pin, bool value) { this->update_reg(pin, value, reg_addr); } -bool MCP23X17Base::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } - void MCP23X17Base::pin_mode(uint8_t pin, gpio::Flags flags) { uint8_t iodir = pin < 8 ? mcp23x17_base::MCP23X17_IODIRA : mcp23x17_base::MCP23X17_IODIRB; uint8_t gppu = pin < 8 ? mcp23x17_base::MCP23X17_GPPUA : mcp23x17_base::MCP23X17_GPPUB; diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.h b/esphome/components/mcp23x17_base/mcp23x17_base.h index bddfff132de..2820b2ba449 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.h +++ b/esphome/components/mcp23x17_base/mcp23x17_base.h @@ -37,7 +37,6 @@ class MCP23X17Base : public mcp23xxx_base::MCP23XXXBase<16> { public: bool digital_read_hw(uint8_t pin) override; void digital_write_hw(uint8_t pin, bool value) override; - bool digital_read_cache(uint8_t pin) override; void pin_mode(uint8_t pin, gpio::Flags flags) override; void pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterruptMode interrupt_mode) override; @@ -47,9 +46,6 @@ class MCP23X17Base : public mcp23xxx_base::MCP23XXXBase<16> { uint8_t olat_a_{0x00}; uint8_t olat_b_{0x00}; - - /// State read in digital_read_hw - uint16_t input_mask_{0x00}; }; } // namespace esphome::mcp23x17_base diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index b8ce216ba22..2c3ff02d60c 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -69,18 +69,10 @@ bool PCA6416AComponent::digital_read_hw(uint8_t pin) { if (!this->read_register_(reg_addr, &value)) { return false; } - - // Update the appropriate part of input_mask_ - if (pin < 8) { - this->input_mask_ = (this->input_mask_ & 0xFF00) | value; - } else { - this->input_mask_ = (this->input_mask_ & 0x00FF) | (uint16_t(value) << 8); - } + this->input_[pin / 8] = value; return true; } -bool PCA6416AComponent::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } - void PCA6416AComponent::digital_write_hw(uint8_t pin, bool value) { uint8_t reg_addr = pin < 8 ? PCA6416A_OUTPUT0 : PCA6416A_OUTPUT1; this->update_register_(pin, value, reg_addr); diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index eef63fe2506..4173262e3ba 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -22,7 +22,6 @@ class PCA6416AComponent final : public gpio_expander::CachedGpioExpanderComponen protected: // Virtual methods from CachedGpioExpander 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; bool read_register_(uint8_t reg, uint8_t *value); @@ -32,12 +31,10 @@ class PCA6416AComponent final : public gpio_expander::CachedGpioExpanderComponen /// The mask to write as output state - 1 means HIGH, 0 means LOW uint8_t output_0_{0x00}; uint8_t output_1_{0x00}; - /// Cache for input values (16-bit combined for both banks) - uint16_t input_mask_{0x00}; - /// Storage for last I2C error seen - esphome::i2c::ErrorCode last_error_; /// Only the PCAL6416A has pull-up resistors bool has_pullup_{false}; + /// Storage for last I2C error seen + esphome::i2c::ErrorCode last_error_; }; using PCA6416AGPIOPin = gpio_expander::ExpanderGPIOPin; diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp index 56d60f5b313..78bda0659da 100644 --- a/esphome/components/pca9505/pca9505.cpp +++ b/esphome/components/pca9505/pca9505.cpp @@ -49,8 +49,6 @@ void PCA9505Component::dump_config() { bool PCA9505Component::digital_read_hw(uint8_t pin) { return this->read_bank_(pin / 8); } -bool PCA9505Component::digital_read_cache(uint8_t pin) { return this->input_[pin / 8] & (1 << (pin % 8)); } - void PCA9505Component::digital_write_hw(uint8_t pin, bool value) { const uint8_t bank = pin / 8; const uint8_t mask = 1 << (pin % 8); diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h index da9c874078e..6c1ae3d6861 100644 --- a/esphome/components/pca9505/pca9505.h +++ b/esphome/components/pca9505/pca9505.h @@ -24,7 +24,6 @@ class PCA9505Component final : public gpio_expander::CachedGpioExpanderComponent protected: 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; bool read_bank_(uint8_t bank); @@ -35,8 +34,6 @@ class PCA9505Component final : public gpio_expander::CachedGpioExpanderComponent uint8_t config_[BANK_COUNT]{}; /// The output state per bank - 1 means HIGH, 0 means LOW uint8_t output_[BANK_COUNT]{}; - /// The input pin states per bank - 1 means HIGH, 0 means LOW - uint8_t input_[BANK_COUNT]{}; /// Which pins may pull INT low - 1 means masked, matching the chip uint8_t mask_[BANK_COUNT]{}; }; diff --git a/esphome/components/pca9554/pca9554.cpp b/esphome/components/pca9554/pca9554.cpp index db1d12beef3..96000398d0b 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -67,15 +67,7 @@ void PCA9554Component::dump_config() { LOG_I2C_DEVICE(this) } -bool PCA9554Component::digital_read_hw(uint8_t pin) { - // Read all pins from hardware into input_mask_ - return this->read_inputs_(); // Return true if I2C read succeeded, false on error -} - -bool PCA9554Component::digital_read_cache(uint8_t pin) { - // Return the cached pin state from input_mask_ - return this->input_mask_ & (1 << pin); -} +bool PCA9554Component::digital_read_hw(uint8_t pin) { return this->read_inputs_(); } void PCA9554Component::digital_write_hw(uint8_t pin, bool value) { assign_bits(this->output_mask_, 1 << pin, value); @@ -113,7 +105,7 @@ void PCA9554Component::pin_mode(uint8_t pin, gpio::Flags flags) { } bool PCA9554Component::read_inputs_() { - uint8_t inputs[2]; + uint8_t inputs[2]{}; // the second byte stays 0 on the 8-pin parts if (this->is_failed()) { ESP_LOGD(TAG, "Device marked failed"); @@ -125,10 +117,7 @@ bool PCA9554Component::read_inputs_() { ESP_LOGE(TAG, "read_register_(): I2C I/O error: %d", (int) err); return false; } - this->input_mask_ = inputs[0]; - if (this->reg_width_ == 2) { - this->input_mask_ |= inputs[1] << 8; - } + this->input_[0] = encode_value_le(inputs); #ifdef USE_PCA9554_PCAL // The bus answers again, so retry the settings a failed write left off the chip this->retry_pcal_shadows_(); diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 77ba6399c13..8fb73c300b8 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -50,7 +50,6 @@ class PCA9554Component final : public gpio_expander::CachedGpioExpanderComponent // Virtual methods from CachedGpioExpander 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; #ifdef USE_PCA9554_PCAL @@ -61,8 +60,6 @@ class PCA9554Component final : public gpio_expander::CachedGpioExpanderComponent uint16_t config_mask_{0x00}; /// The mask to write as output state - 1 means HIGH, 0 means LOW uint16_t output_mask_{0x00}; - /// The state of the actual input pins - 1 means HIGH, 0 means LOW - uint16_t input_mask_{0x00}; #ifdef USE_PCA9554_PCAL // Shadows of the PCAL registers. setup() writes them, so every part starts with the pulls disconnected // whatever its power-on state is (the PCAL9555 powers up with pull-ups enabled). diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 929ef5498e8..a5e16126991 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -17,7 +17,7 @@ void PCF8574Component::setup() { this->write_gpio_(); this->read_gpio_(); } else { - this->output_mask_ = this->input_mask_; + this->output_mask_ = this->input_[0]; this->mode_mask_ = ~this->configured_input_pins_; } @@ -32,12 +32,7 @@ void PCF8574Component::dump_config() { LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) } -bool PCF8574Component::digital_read_hw(uint8_t pin) { - // Read all pins from hardware into input_mask_ - return this->read_gpio_(); // Return true if I2C read succeeded, false on error -} - -bool PCF8574Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } +bool PCF8574Component::digital_read_hw(uint8_t pin) { return this->read_gpio_(); } void PCF8574Component::digital_write_hw(uint8_t pin, bool value) { assign_bits(this->output_mask_, 1 << pin, value); @@ -61,7 +56,7 @@ bool PCF8574Component::read_gpio_() { uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read. if (!this->status_comm_result(this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1))) return false; - this->input_mask_ = encode_value_le(data); + this->input_[0] = encode_value_le(data); return true; } bool PCF8574Component::write_gpio_() { diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index ae2e3445fe9..6eed647f8fd 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -28,7 +28,6 @@ class PCF8574Component final : public gpio_expander::CachedGpioExpanderComponent protected: 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; bool read_gpio_(); @@ -38,8 +37,6 @@ class PCF8574Component final : public gpio_expander::CachedGpioExpanderComponent uint16_t mode_mask_{0x00}; /// The mask to write as output state - 1 means HIGH, 0 means LOW uint16_t output_mask_{0x00}; - /// The state read in read_gpio_ - 1 means HIGH, 0 means LOW - uint16_t input_mask_{0x00}; // Pins configured as inputs before setup; 1 means input. With reset disabled, all other pins // (including undeclared pins) are treated as outputs seeded from their observed levels. uint16_t configured_input_pins_{0x00}; diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index f5dc4c31cfa..f5664fab466 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -96,7 +96,7 @@ bool PI4IOE5V6408Component::digital_read_hw(uint8_t pin) { 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->input_[0] = data; return true; } @@ -149,8 +149,6 @@ bool PI4IOE5V6408Component::write_gpio_modes_() { return true; } -bool PI4IOE5V6408Component::digital_read_cache(uint8_t pin) { return (this->input_mask_ & (1 << 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 9e0ffc02f84..f166321fd28 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -22,15 +22,12 @@ class PI4IOE5V6408Component final : public gpio_expander::CachedGpioExpanderComp protected: 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; /// Mask for the pin mode - 1 means output, 0 means input uint8_t mode_mask_{0x00}; /// The mask to write as output state - 1 means HIGH, 0 means LOW uint8_t output_mask_{0x00}; - /// The state read in digital_read_hw - 1 means HIGH, 0 means LOW - uint8_t input_mask_{0x00}; /// The mask to write as input buffer state - 1 means enabled, 0 means disabled uint8_t pull_enable_mask_{0x00}; /// The mask to write as pullup state - 1 means pullup, 0 means pulldown diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index f5943fe4a02..bd7de188418 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -64,21 +64,11 @@ bool TCA9555Component::digital_read_hw(uint8_t pin) { if (this->is_failed()) return false; uint8_t data; - uint8_t bank_number = pin < 8 ? 0 : 1; - uint8_t register_to_read = bank_number ? TCA9555_INPUT_PORT_REGISTER_1 : TCA9555_INPUT_PORT_REGISTER_0; - if (!this->read_bytes(register_to_read, &data, 1)) { - this->status_set_warning(LOG_STR("Failed to read input register")); + if (!this->status_comm_result( + this->read_byte(pin < 8 ? TCA9555_INPUT_PORT_REGISTER_0 : TCA9555_INPUT_PORT_REGISTER_1, &data), + LOG_STR("Failed to read input register"))) return false; - } - uint8_t second_half = this->input_mask_ >> 8; - uint8_t first_half = this->input_mask_; - if (bank_number) { - this->input_mask_ = (data << 8) | (uint16_t(first_half) << 0); - } else { - this->input_mask_ = (uint16_t(second_half) << 8) | (data << 0); - } - - this->status_clear_warning(); + this->input_[pin / 8] = data; return true; } @@ -99,8 +89,6 @@ bool TCA9555Component::write_gpio_modes_() { LOG_STR("Failed to write mode register")); } -bool TCA9555Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << 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 2e419586267..7571cb8ae2e 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -21,15 +21,12 @@ class TCA9555Component final : public gpio_expander::CachedGpioExpanderComponent protected: // Virtual methods from GpioExpander base class — names come from base bool digital_read_hw(uint8_t pin) override; // NOLINT(readability-identifier-naming) - bool digital_read_cache(uint8_t pin) override; // NOLINT(readability-identifier-naming) void digital_write_hw(uint8_t pin, bool value) override; // NOLINT(readability-identifier-naming) /// Mask for the pin mode - 1 means output, 0 means input uint16_t mode_mask_{0x00}; /// The mask to write as output state - 1 means HIGH, 0 means LOW uint16_t output_mask_{0x00}; - /// The state read in digital_read_hw - 1 means HIGH, 0 means LOW - uint16_t input_mask_{0x00}; bool read_gpio_modes_(); bool write_gpio_modes_(); diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 48671b780d3..5f25750c731 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -111,7 +111,7 @@ bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { 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->input_[0] = data; return true; } @@ -126,7 +126,6 @@ void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) { LOG_STR("Failed to write output register")); } -bool WaveshareIOCH32V003Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } float WaveshareIOCH32V003Component::get_setup_priority() const { return setup_priority::IO; } } // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h index ed11b72e078..36e62d1475c 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h @@ -8,9 +8,10 @@ namespace esphome::waveshare_io_ch32v003 { -class WaveshareIOCH32V003Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class WaveshareIOCH32V003Component + : public Component, + public gpio_expander::GpioExpanderInputs>, + public i2c::I2CDevice { public: WaveshareIOCH32V003Component() = default; @@ -31,12 +32,10 @@ class WaveshareIOCH32V003Component : public Component, protected: 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; uint8_t mode_mask_{0x00}; // Mask for the pin mode - 1 means output, 0 means input uint8_t output_mask_{0x00}; // The mask to write as output state - 1 means HIGH, 0 means LOW - uint8_t input_mask_{0x00}; // The state read in digital_read_hw - 1 means HIGH, 0 means LOW bool write_gpio_modes_(); bool write_gpio_outputs_(); From 82824861ae87406ddb6e471c8081846a111aee70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 20:39:56 -0600 Subject: [PATCH 54/55] [thermopro_ble] Simplify the parser and pass the MAC address to the constructor (#22052) --- esphome/components/thermopro_ble/sensor.py | 30 +-- .../thermopro_ble/thermopro_ble.cpp | 211 +++++++----------- .../components/thermopro_ble/thermopro_ble.h | 13 +- .../sensor/test_thermopro_ble.cpp | 3 +- 4 files changed, 99 insertions(+), 158 deletions(-) diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py index f47109b9cb3..dd5cb8b1714 100644 --- a/esphome/components/thermopro_ble/sensor.py +++ b/esphome/components/thermopro_ble/sensor.py @@ -29,24 +29,24 @@ ThermoProBLE = thermopro_ble_ns.class_( "ThermoProBLE", ble_device_base.ESPBTDeviceListener, cg.Component ) + +def _temperature_schema() -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ) + + CONFIG_SCHEMA = cv.All( ble_device_base.rename_legacy_hub_id("thermopro_ble"), cv.Schema( { cv.GenerateID(): cv.declare_id(ThermoProBLE), cv.Required(CONF_MAC_ADDRESS): cv.mac_address, - 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_EXTERNAL_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_TEMPERATURE): _temperature_schema(), + cv.Optional(CONF_EXTERNAL_TEMPERATURE): _temperature_schema(), cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0, @@ -75,9 +75,9 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = await ble_device_base.new_ble_device_component(config) - - cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) + var = await ble_device_base.new_ble_device_component( + config, config[CONF_MAC_ADDRESS].as_hex + ) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 11a4ec3de1f..20f99a5626f 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -1,28 +1,74 @@ #include "thermopro_ble.h" +#include #include #include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::thermopro_ble { -// this size must be large enough to hold the largest data frame -// of all supported devices -static constexpr std::size_t MAX_DATA_SIZE = 24; +ESPHOME_LOG_TAG(TAG, "thermopro_ble"); + +// Largest record (company ID plus payload) of all supported devices +static constexpr size_t MAX_DATA_SIZE = 24; + +struct ParseResult { + float temperature{NAN}; + float external_temperature{NAN}; + float humidity{NAN}; + float battery_level{NAN}; +}; + +using DeviceParser = bool (*)(const uint8_t *data, size_t data_size, ParseResult &result); + +// Fitted A*tanh(B*x+C)+D to the TP96x battery voltage curve, used with permission from +// https://github.com/Bluetooth-Devices/thermopro-ble/blob/main/src/thermopro_ble/parser.py +static float tp96_battery(uint16_t voltage) { + float level = 52.317286f * std::tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; + return clamp(level, 0.0f, 100.0f); +} + +static bool parse_tp972(const uint8_t *data, size_t data_size, ParseResult &result) { + if (data_size != 23) { + ESP_LOGVV(TAG, "TP972 payload size %u != 23", static_cast(data_size)); + return false; + } + // TODO: both temperatures are °F + 54 and the internal one is a float; fixed in a follow-up + result.external_temperature = static_cast(encode_value_le(data + 1)) - 54.0f; + result.battery_level = tp96_battery(encode_value_le(data + 3)); + result.temperature = static_cast(encode_value_le(data + 9)) - 54.0f; + return true; +} + +static bool parse_tp96(const uint8_t *data, size_t data_size, ParseResult &result) { + if (data_size != 7) { + ESP_LOGVV(TAG, "TP96 payload size %u != 7", static_cast(data_size)); + return false; + } + // Temperatures are offset by 30 °C + result.temperature = static_cast(encode_value_le(data + 1)) - 30.0f; + result.battery_level = tp96_battery(encode_value_le(data + 3)); + result.external_temperature = static_cast(encode_value_le(data + 5)) - 30.0f; + return true; +} + +static bool parse_tp3(const uint8_t *data, size_t data_size, ParseResult &result) { + if (data_size < 6) { + ESP_LOGVV(TAG, "TP3 payload size %u < 6", static_cast(data_size)); + return false; + } + result.temperature = static_cast(encode_value_le(data + 1)) * 0.1f; + result.humidity = static_cast(data[3]); + // Two battery bits, 50 % per step + result.battery_level = static_cast(data[4] & 0x3) * 50.0f; + return true; +} struct DeviceParserMapping { const char *prefix; DeviceParser parser; }; -static float tp96_battery(uint16_t voltage); - -static optional parse_tp972(const uint8_t *data, std::size_t data_size); -static optional parse_tp96(const uint8_t *data, std::size_t data_size); -static optional parse_tp3(const uint8_t *data, std::size_t data_size); - -ESPHOME_LOG_TAG(TAG, "thermopro_ble"); - -static const struct DeviceParserMapping DEVICE_PARSER_MAP[] = { +static constexpr DeviceParserMapping DEVICE_PARSER_MAP[] = { {"TP972", parse_tp972}, {"TP970", parse_tp96}, {"TP96", parse_tp96}, {"TP3", parse_tp3}}; static DeviceParser find_device_parser(StringRef device_name) { @@ -30,155 +76,60 @@ static DeviceParser find_device_parser(StringRef device_name) { if (device_name.starts_with(mapping.prefix)) return mapping.parser; } - ESP_LOGVV(TAG, "find_device_parser(): unknown device type %s.", device_name.c_str()); return nullptr; } +static void publish_if_set(sensor::Sensor *sensor, float value) { + if (sensor != nullptr && !std::isnan(value)) + sensor->publish_state(value); +} + void ThermoProBLE::dump_config() { ESP_LOGCONFIG(TAG, "ThermoPro BLE"); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); - LOG_SENSOR(" ", "External temperature", this->external_temperature_sensor_); + LOG_SENSOR(" ", "External Temperature", this->external_temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Signal Strength", this->signal_strength_sensor_); } bool ThermoProBLE::parse_device(const ble_device_base::ESPBTDevice &device) { - // check for matching mac address - if (device.address_uint64() != this->address_) { - ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); + if (device.address_uint64() != this->address_) return false; - } - // check for valid device type DeviceParser parser = find_device_parser(device.get_name()); if (parser == nullptr) { - ESP_LOGVV(TAG, "parse_device(): invalid device type."); + ESP_LOGVV(TAG, "Unknown device type %s", device.get_name().c_str()); return false; } - char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - ESP_LOGVV(TAG, "parse_device(): MAC address %s found.", device.address_str_to(addr_buf)); - - // publish signal strength - float signal_strength = float(device.get_rssi()); - PUBLISH_STATE(this->signal_strength_sensor_, signal_strength); + PUBLISH_STATE(this->signal_strength_sensor_, device.get_rssi()); bool success = false; - for (auto &service_data : device.get_manufacturer_datas()) { - // check maximum data size - std::size_t data_size = service_data.data.size() + 2; + for (const auto &mfg_data : device.get_manufacturer_datas()) { + size_t data_size = mfg_data.data.size() + 2; if (data_size > MAX_DATA_SIZE) { - ESP_LOGVV(TAG, "parse_device(): maximum data size exceeded!"); + ESP_LOGVV(TAG, "Payload size %u > %u", static_cast(data_size), static_cast(MAX_DATA_SIZE)); continue; } - // reconstruct whole record from 2 byte uuid and data - uint16_t svc_uuid16 = service_data.uuid.uuid16(); - uint8_t data[MAX_DATA_SIZE] = {static_cast(svc_uuid16), static_cast(svc_uuid16 >> 8)}; - std::copy(service_data.data.begin(), service_data.data.end(), std::begin(data) + 2); + // The parsers index the whole record, company ID included + uint16_t company_id = mfg_data.uuid.uuid16(); + uint8_t data[MAX_DATA_SIZE] = {static_cast(company_id), static_cast(company_id >> 8)}; + std::copy(mfg_data.data.begin(), mfg_data.data.end(), data + 2); - // dispatch data to parser - optional result = parser(data, data_size); - if (!result.has_value()) { + ParseResult result; + if (!parser(data, data_size, result)) continue; - } - - // publish sensor values - publish_optional(this->temperature_sensor_, result->temperature); - publish_optional(this->external_temperature_sensor_, result->external_temperature); - publish_optional(this->humidity_sensor_, result->humidity); - publish_optional(this->battery_level_sensor_, result->battery_level); + publish_if_set(this->temperature_sensor_, result.temperature); + publish_if_set(this->external_temperature_sensor_, result.external_temperature); + publish_if_set(this->humidity_sensor_, result.humidity); + publish_if_set(this->battery_level_sensor_, result.battery_level); success = true; } return success; } -static inline uint16_t read_uint16(const uint8_t *data, std::size_t offset) { - return encode_value_le(&data[offset]); -} - -static inline int16_t read_int16(const uint8_t *data, std::size_t offset) { - return encode_value_le(&data[offset]); -} - -static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) { - return encode_value_le(&data[offset]); -} - -// Battery calculation used with permission from: -// https://github.com/Bluetooth-Devices/thermopro-ble/blob/main/src/thermopro_ble/parser.py -// -// TP96x battery values appear to be a voltage reading, probably in millivolts. -// This means that calculating battery life from it is a non-linear function. -// Examining the curve, it looked fairly close to a curve from the tanh function. -// So, I created a script to use Tensorflow to optimize an equation in the format -// A*tanh(B*x+C)+D -// Where A,B,C,D are the variables to optimize for. This yielded the below function -static float tp96_battery(uint16_t voltage) { - float level = 52.317286f * std::tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; - return std::max(0.0f, std::min(level, 100.0f)); -} - -static optional parse_tp972(const uint8_t *data, std::size_t data_size) { - if (data_size != 23) { - ESP_LOGVV(TAG, "parse_tp972(): payload has wrong size of %d (!= 23)!", data_size); - return {}; - } - - ParseResult result; - - // ambient temperature, 2 bytes, 16-bit unsigned integer, -54 °C offset - result.external_temperature = static_cast(read_uint16(data, 1)) - 54.0f; - - // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage) - result.battery_level = tp96_battery(read_uint16(data, 3)); - - // internal temperature, 4 bytes, float, -54 °C offset - result.temperature = static_cast(read_uint32(data, 9)) - 54.0f; - - return result; -} - -static optional parse_tp96(const uint8_t *data, std::size_t data_size) { - if (data_size != 7) { - ESP_LOGVV(TAG, "parse_tp96(): payload has wrong size of %d (!= 7)!", data_size); - return {}; - } - - ParseResult result; - - // internal temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset - result.temperature = static_cast(read_uint16(data, 1)) - 30.0f; - - // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage) - result.battery_level = tp96_battery(read_uint16(data, 3)); - - // ambient temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset - result.external_temperature = static_cast(read_uint16(data, 5)) - 30.0f; - - return result; -} - -static optional parse_tp3(const uint8_t *data, std::size_t data_size) { - if (data_size < 6) { - ESP_LOGVV(TAG, "parse_tp3(): payload has wrong size of %d (< 6)!", data_size); - return {}; - } - - ParseResult result; - - // temperature, 2 bytes, 16-bit signed integer, 0.1 °C - result.temperature = static_cast(read_int16(data, 1)) * 0.1f; - - // humidity, 1 byte, 8-bit unsigned integer, 1.0 % - result.humidity = static_cast(data[3]); - - // battery level, 2 bits (0-2) - result.battery_level = static_cast(data[4] & 0x3) * 50.0f; - - return result; -} - } // namespace esphome::thermopro_ble diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h index fc09cbb150f..0216d29b506 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.h +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -6,15 +6,6 @@ namespace esphome::thermopro_ble { -struct ParseResult { - optional temperature; - optional external_temperature; - optional humidity; - optional battery_level; -}; - -using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); - class ThermoProBLE final : public Component, public ble_device_base::ESPBTDeviceListener { SUB_SENSOR(signal_strength) SUB_SENSOR(temperature) @@ -23,13 +14,13 @@ class ThermoProBLE final : public Component, public ble_device_base::ESPBTDevice SUB_SENSOR(battery_level) public: - void set_address(uint64_t address) { this->address_ = address; }; + explicit ThermoProBLE(uint64_t address) : address_(address) {} bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; protected: - uint64_t address_; + const uint64_t address_; }; } // namespace esphome::thermopro_ble diff --git a/tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp b/tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp index d5705ee0df2..7b53b551924 100644 --- a/tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp +++ b/tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp @@ -10,7 +10,6 @@ namespace esphome::thermopro_ble::testing { class ThermoProBLETest : public ::testing::Test { protected: void SetUp() override { - this->hub_.set_address(device_from(MAC, nullptr, {}).address_uint64()); this->hub_.set_temperature_sensor(&this->temperature_); this->hub_.set_external_temperature_sensor(&this->external_temperature_); this->hub_.set_humidity_sensor(&this->humidity_); @@ -27,7 +26,7 @@ class ThermoProBLETest : public ::testing::Test { !this->humidity_.has_state() && !this->battery_level_.has_state(); } - ThermoProBLE hub_; + ThermoProBLE hub_{device_from(MAC, nullptr, {}).address_uint64()}; sensor::Sensor temperature_; sensor::Sensor external_temperature_; sensor::Sensor humidity_; From 2499ecaa51426a0cd3736a35f840e4c67700acad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Nov 2026 20:47:10 -0600 Subject: [PATCH 55/55] [remote_base] Generate transmit_raw and rc_switch transmits as inline actions (#22005) --- esphome/components/remote_base/__init__.py | 347 +++++++++++++----- esphome/components/remote_base/raw_protocol.h | 40 +- .../remote_base/rc_switch_protocol.h | 106 ------ .../test_rc_switch_protocol.py | 4 +- .../config/raw_code_storage_id.yaml | 18 + .../test_raw_rc_switch_actions.py | 260 ++++++++----- 6 files changed, 445 insertions(+), 330 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/raw_code_storage_id.yaml diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 8af15f97a00..d142d290add 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1,9 +1,9 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable import dataclasses from dataclasses import dataclass import logging from pathlib import Path -from typing import Any +from typing import Any, NamedTuple from esphome import automation import esphome.codegen as cg @@ -56,7 +56,14 @@ from esphome.const import ( CONF_WAND_ID, CONF_ZERO, ) -from esphome.core import CORE, ID, CoroPriority, coroutine, coroutine_with_priority +from esphome.core import ( + CORE, + ID, + CoroPriority, + Lambda, + coroutine, + coroutine_with_priority, +) from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, SafeExpType, TemplateArgsType @@ -430,8 +437,18 @@ def register_protocol_action( call=f"{call}<{ns.class_(f'{protocol}Protocol')}>", id_key=CONF_TRANSMITTER_ID, ) + _register_apply_transmit(name, action, validator, builder) - async def protocol_builder( + +def _register_apply_transmit( + name: str, + action: str, + validator: Any, + builder: Callable[..., Awaitable[MockObj]], +) -> None: + """Register ``builder`` for ``action`` as a core ``ApplyAction`` that keeps the protocol's source.""" + + async def transmit_builder( config: ConfigType, action_id: ID, template_arg: cg.TemplateArguments, @@ -442,7 +459,70 @@ def register_protocol_action( automation.register_action( action, automation.ApplyAction, validator, synchronous=True - )(protocol_builder) + )(transmit_builder) + + +# The transmit data the statements of a ``_register_transmit_action`` encoder fill +_CALL_DATA = "esphome__call.get_data()" + + +async def _transmit_value( + value: Any, + args: TemplateArgsType, + type_: SafeExpType, + const_fn: Callable[[Any], Any] = cg.safe_exp, +) -> str: + """Argument text for one templatable transmit value. + + A lambda keeps the trigger's own argument types and is always called, so its body may + modify its by-value arguments. + """ + if isinstance(value, Lambda): + lamb = await cg.process_lambda(value, args, return_type=type_) + return str(automation.immediate_lambda_call(lamb)) + return str(const_fn(value)) + + +def _register_transmit_action( + name: str, + schema: cv.Schema, + encode: Callable[[ConfigType, TemplateArgsType], Awaitable[list[str]]], +) -> None: + """Register ``transmit_`` as inline statements, with no C++ action class. + + ``encode(config, args)`` returns the statements that fill ``_CALL_DATA``; + they run before ``repeat`` is read and the call is performed, in the order the old + ``RemoteTransmitterActionBase::play()`` used. + """ + action, _, validator = _transmit_action(name, schema, None) + + async def builder( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + _request_protocol_if_in_tree(name) + transmitter = automation.parent_ref( + await cg.get_variable(config[CONF_TRANSMITTER_ID]) + ) + statements = [ + f"auto esphome__call = {transmitter.transmit()};", + *await encode(config, args), + ] + if (repeat := config.get(CONF_REPEAT)) is not None: + for key, setter in ( + (CONF_TIMES, "set_send_times"), + (CONF_WAIT_TIME, "set_send_wait"), + ): + value = await _transmit_value(repeat[key], args, cg.uint32) + statements.append(f"esphome__call.{setter}({value});") + statements.append("esphome__call.perform();") + return automation.apply_statements_action( + action_id, template_arg, args, statements + ) + + _register_apply_transmit(name, action, validator, builder) def declare_protocol(name): @@ -1568,7 +1648,8 @@ def validate_raw_alternating(value): return value -RawData, RawBinarySensor, RawTrigger, RawAction, RawDumper = declare_protocol("Raw") +RawData, RawBinarySensor, RawTrigger, _, RawDumper = declare_protocol("Raw") +encode_raw_timings = ns.encode_raw_timings CONF_CODE_STORAGE_ID = "code_storage_id" RAW_SCHEMA = cv.Schema( { @@ -1600,9 +1681,32 @@ def raw_dumper(var, config): pass -@register_action( +async def _raw_encode(config: ConfigType, args: TemplateArgsType) -> list[str]: + data = _CALL_DATA + code = config[CONF_CODE] + if config[CONF_CODE_STORAGE_ID].is_manual: + _LOGGER.warning( + "transmit_raw ignores code_storage_id '%s'; equal codes share one table", + config[CONF_CODE_STORAGE_ID], + ) + if isinstance(code, Lambda): + timings = await _transmit_value(code, args, cg.std_vector.template(cg.int32)) + statements = [f"{data}->set_data({timings});"] + else: + # Equal codes share one flash table + table = cg.shared_progmem_array( + "remote_base_raw_code", cg.int32, cg.ArrayInitializer(*code) + ) + statements = [f"{encode_raw_timings}({data}, ::{table}, {len(code)});"] + # transmit() already resets the frequency to 0 + if (frequency := config[CONF_CARRIER_FREQUENCY]) != 0: + frequency = await _transmit_value(frequency, args, cg.uint32) + statements.append(f"{data}->set_carrier_frequency({frequency});") + return statements + + +_register_transmit_action( "raw", - RawAction, RAW_SCHEMA.extend( { cv.Optional(CONF_CARRIER_FREQUENCY, default="0Hz"): cv.All( @@ -1610,18 +1714,8 @@ def raw_dumper(var, config): ), } ), + _raw_encode, ) -async def raw_action(var, config, args): - code_ = config[CONF_CODE] - if cg.is_template(code_): - template_ = await cg.templatable(code_, args, cg.std_vector.template(cg.int32)) - cg.add(var.set_code_template(template_)) - else: - code_ = config[CONF_CODE] - arr = cg.progmem_array(config[CONF_CODE_STORAGE_ID], code_) - cg.add(var.set_code_static(arr, len(code_))) - templ = await cg.templatable(config[CONF_CARRIER_FREQUENCY], args, cg.uint32) - cg.add(var.set_carrier_frequency(templ)) # Drayton @@ -1979,15 +2073,11 @@ RC_SWITCH_TRANSMITTER = cv.Schema( rc_switch_protocol = ns.rc_switch_protocol rc_switch_protocol_copy = ns.rc_switch_protocol_copy RC_SWITCH_PROTOCOLS = ns.RC_SWITCH_PROTOCOLS +decode_binary_string = ns.decode_binary_string RCSwitchData = ns.struct("RCSwitchData") RCSwitchBase = ns.class_("RCSwitchBase") RCSwitchTrigger = ns.class_("RCSwitchTrigger", RemoteReceiverTrigger) RCSwitchDumper = ns.class_("RCSwitchDumper", RemoteReceiverDumperBase) -RCSwitchRawAction = ns.class_("RCSwitchRawAction", RemoteTransmitterActionBase) -RCSwitchTypeAAction = ns.class_("RCSwitchTypeAAction", RemoteTransmitterActionBase) -RCSwitchTypeBAction = ns.class_("RCSwitchTypeBAction", RemoteTransmitterActionBase) -RCSwitchTypeCAction = ns.class_("RCSwitchTypeCAction", RemoteTransmitterActionBase) -RCSwitchTypeDAction = ns.class_("RCSwitchTypeDAction", RemoteTransmitterActionBase) RCSwitchRawReceiver = ns.class_("RCSwitchRawReceiver", RemoteReceiverBinarySensorBase) @@ -1997,19 +2087,6 @@ def rc_switch_raw_binary_sensor(var, config): cg.add(var.set_code(config[CONF_CODE])) -@register_action( - "rc_switch_raw", - RCSwitchRawAction, - RC_SWITCH_RAW_SCHEMA.extend(RC_SWITCH_TRANSMITTER), -) -async def rc_switch_raw_action(var, config, args): - proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value - ) - cg.add(var.set_protocol(proto)) - cg.add(var.set_code(await cg.templatable(config[CONF_CODE], args, cg.std_string))) - - @register_binary_sensor( "rc_switch_type_a", RCSwitchRawReceiver, RC_SWITCH_TYPE_A_SCHEMA ) @@ -2018,23 +2095,6 @@ def rc_switch_type_a_binary_sensor(var, config): cg.add(var.set_type_a(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) -@register_action( - "rc_switch_type_a", - RCSwitchTypeAAction, - RC_SWITCH_TYPE_A_SCHEMA.extend(RC_SWITCH_TRANSMITTER), -) -async def rc_switch_type_a_action(var, config, args): - proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value - ) - cg.add(var.set_protocol(proto)) - cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.std_string))) - cg.add( - var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.std_string)) - ) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) - - @register_binary_sensor( "rc_switch_type_b", RCSwitchRawReceiver, RC_SWITCH_TYPE_B_SCHEMA ) @@ -2045,21 +2105,6 @@ def rc_switch_type_b_binary_sensor(var, config): ) -@register_action( - "rc_switch_type_b", - RCSwitchTypeBAction, - RC_SWITCH_TYPE_B_SCHEMA.extend(RC_SWITCH_TRANSMITTER), -) -async def rc_switch_type_b_action(var, config, args): - proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value - ) - cg.add(var.set_protocol(proto)) - cg.add(var.set_address(await cg.templatable(config[CONF_ADDRESS], args, cg.uint8))) - cg.add(var.set_channel(await cg.templatable(config[CONF_CHANNEL], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) - - @register_binary_sensor( "rc_switch_type_c", RCSwitchRawReceiver, RC_SWITCH_TYPE_C_SCHEMA ) @@ -2075,24 +2120,6 @@ def rc_switch_type_c_binary_sensor(var, config): ) -@register_action( - "rc_switch_type_c", - RCSwitchTypeCAction, - RC_SWITCH_TYPE_C_SCHEMA.extend(RC_SWITCH_TRANSMITTER), -) -async def rc_switch_type_c_action(var, config, args): - proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value - ) - cg.add(var.set_protocol(proto)) - cg.add( - var.set_family(await cg.templatable(config[CONF_FAMILY], args, cg.std_string)) - ) - cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.uint8))) - cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) - - @register_binary_sensor( "rc_switch_type_d", RCSwitchRawReceiver, @@ -2103,19 +2130,141 @@ def rc_switch_type_d_binary_sensor(var, config): cg.add(var.set_type_d(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) -@register_action( - "rc_switch_type_d", - RCSwitchTypeDAction, - RC_SWITCH_TYPE_D_SCHEMA.extend(RC_SWITCH_TRANSMITTER), -) -async def rc_switch_type_d_action(var, config, args): - proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value +def _rc_switch_bits(code: str) -> int: + """``decode_binary_string()`` at build time: every character but ``0`` is a one.""" + return int("".join("0" if c == "0" else "1" for c in code), 2) + + +def _rc_switch_letter_index(value: str) -> int: + """A family or group letter as its index, as the C++ ``tolower(value[0]) - 'a'``.""" + return ord(value[0].lower()) - ord("a") + + +class _RCSwitchArg(NamedTuple): + """One argument of an ``RCSwitchBase::type_*_code()`` call, read from config.""" + + key: str + # What the user lambda returns, and how its value becomes the argument + lambda_type: SafeExpType = cg.uint8 + convert: str = "{}" + const_fn: Callable[[Any], Any] = cg.safe_exp + + +def _rc_switch_binary(key: str) -> _RCSwitchArg: + return _RCSwitchArg( + key, cg.std_string, f"{decode_binary_string}({{}})", _rc_switch_bits ) - cg.add(var.set_protocol(proto)) - cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.std_string))) - cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.bool_))) + + +def _rc_switch_letter(key: str) -> _RCSwitchArg: + return _RCSwitchArg( + key, + cg.std_string, + "static_cast(tolower({}[0]) - 'a')", + _rc_switch_letter_index, + ) + + +_RC_SWITCH_STATE = _RCSwitchArg(CONF_STATE, cg.bool_) + + +async def _rc_switch_send( + config: ConfigType, args: TemplateArgsType, code: str, nbits: str +) -> str: + """The statement that sends ``code``; the protocol is read last, as before.""" + proto = await _transmit_value( + config[CONF_PROTOCOL], args, RCSwitchBase, rc_switch_protocol_value + ) + return f"{proto}.transmit({_CALL_DATA}, {code}, {nbits});" + + +async def _rc_switch_raw_encode( + config: ConfigType, args: TemplateArgsType +) -> list[str]: + code = config[CONF_CODE] + if isinstance(code, Lambda): + value = await _transmit_value(code, args, cg.std_string) + statements = [f"const std::string esphome__code = {value};"] + bits, nbits = f"{decode_binary_string}(esphome__code)", "esphome__code.size()" + else: + statements = [] + bits, nbits = str(cg.safe_exp(_rc_switch_bits(code))), str(len(code)) + return [*statements, await _rc_switch_send(config, args, bits, nbits)] + + +def _rc_switch_type_encode( + type_code: str, *fields: _RCSwitchArg +) -> Callable[[ConfigType, TemplateArgsType], Awaitable[list[str]]]: + """Statements running ``RCSwitchBase::`` on the fields, in order, then sending.""" + + async def encode(config: ConfigType, args: TemplateArgsType) -> list[str]: + statements: list[str] = [] + values: list[str] = [] + for field in fields: + value = config[field.key] + arg = await _transmit_value(value, args, field.lambda_type, field.const_fn) + if isinstance(value, Lambda): + # A local per lambda keeps the old evaluation order + local = f"esphome__{field.key}" + statements.append(f"const auto {local} = {field.convert.format(arg)};") + arg = local + values.append(arg) + return [ + *statements, + "uint64_t esphome__code;", + "uint8_t esphome__nbits;", + f"{RCSwitchBase}::{type_code}({', '.join(values)}, &esphome__code, &esphome__nbits);", + await _rc_switch_send(config, args, "esphome__code", "esphome__nbits"), + ] + + return encode + + +for _name, _schema, _encode in ( + ("rc_switch_raw", RC_SWITCH_RAW_SCHEMA, _rc_switch_raw_encode), + ( + "rc_switch_type_a", + RC_SWITCH_TYPE_A_SCHEMA, + _rc_switch_type_encode( + "type_a_code", + _rc_switch_binary(CONF_GROUP), + _rc_switch_binary(CONF_DEVICE), + _RC_SWITCH_STATE, + ), + ), + ( + "rc_switch_type_b", + RC_SWITCH_TYPE_B_SCHEMA, + _rc_switch_type_encode( + "type_b_code", + _RCSwitchArg(CONF_ADDRESS), + _RCSwitchArg(CONF_CHANNEL), + _RC_SWITCH_STATE, + ), + ), + ( + "rc_switch_type_c", + RC_SWITCH_TYPE_C_SCHEMA, + _rc_switch_type_encode( + "type_c_code", + _rc_switch_letter(CONF_FAMILY), + _RCSwitchArg(CONF_GROUP), + _RCSwitchArg(CONF_DEVICE), + _RC_SWITCH_STATE, + ), + ), + ( + "rc_switch_type_d", + RC_SWITCH_TYPE_D_SCHEMA, + _rc_switch_type_encode( + "type_d_code", + _rc_switch_letter(CONF_GROUP), + _RCSwitchArg(CONF_DEVICE), + _RC_SWITCH_STATE, + ), + ), +): + _register_transmit_action(_name, _schema.extend(RC_SWITCH_TRANSMITTER), _encode) @register_trigger("rc_switch", RCSwitchTrigger, RCSwitchData) diff --git a/esphome/components/remote_base/raw_protocol.h b/esphome/components/remote_base/raw_protocol.h index ff0329b9c61..06d5459a3dd 100644 --- a/esphome/components/remote_base/raw_protocol.h +++ b/esphome/components/remote_base/raw_protocol.h @@ -48,41 +48,17 @@ class RawTrigger final : public Trigger, public RemoteReceiverListen RawTimings timings_; }; -template class RawAction : public RemoteTransmitterActionBase { - public: - void set_code_template(RawTimings (*func)(Ts...)) { - this->code_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - void set_code_static(const int32_t *code, size_t len) { - this->code_.data = code; - this->len_ = len; // Length >= 0 indicates static mode - } - TEMPLATABLE_VALUE(uint32_t, carrier_frequency); - - void encode(RemoteTransmitData *dst, Ts... x) override { - if (this->len_ >= 0) { - for (size_t i = 0; i < static_cast(this->len_); i++) { - auto val = this->code_.data[i]; - if (val < 0) { - dst->space(static_cast(-val)); - } else { - dst->mark(static_cast(val)); - } - } +/// Appends constant timings, marks positive and spaces negative; the table may be in flash +inline void encode_raw_timings(RemoteTransmitData *dst, const int32_t *data, size_t len) { + for (size_t i = 0; i < len; i++) { + auto val = data[i]; + if (val < 0) { + dst->space(static_cast(-val)); } else { - dst->set_data(this->code_.func(x...)); + dst->mark(static_cast(val)); } - dst->set_carrier_frequency(this->carrier_frequency_.value(x...)); } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Code { - RawTimings (*func)(Ts...); - const int32_t *data; - } code_; -}; +} class RawDumper : public RemoteReceiverDumperBase { public: diff --git a/esphome/components/remote_base/rc_switch_protocol.h b/esphome/components/remote_base/rc_switch_protocol.h index a102da3ff67..25c35f5ba03 100644 --- a/esphome/components/remote_base/rc_switch_protocol.h +++ b/esphome/components/remote_base/rc_switch_protocol.h @@ -100,112 +100,6 @@ uint64_t decode_binary_string(const std::string &data); uint64_t decode_binary_string_mask(const std::string &data); -template class RCSwitchRawAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(RCSwitchBase, protocol); - TEMPLATABLE_VALUE(std::string, code); - - void encode(RemoteTransmitData *dst, Ts... x) override { - auto code = this->code_.value(x...); - uint64_t the_code = decode_binary_string(code); - uint8_t nbits = code.size(); - - auto proto = this->protocol_.value(x...); - proto.transmit(dst, the_code, nbits); - } -}; - -template class RCSwitchTypeAAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(RCSwitchBase, protocol); - TEMPLATABLE_VALUE(std::string, group); - TEMPLATABLE_VALUE(std::string, device); - TEMPLATABLE_VALUE(bool, state); - - void encode(RemoteTransmitData *dst, Ts... x) override { - auto group = this->group_.value(x...); - auto device = this->device_.value(x...); - auto state = this->state_.value(x...); - uint8_t u_group = decode_binary_string(group); - uint8_t u_device = decode_binary_string(device); - - uint64_t code; - uint8_t nbits; - RCSwitchBase::type_a_code(u_group, u_device, state, &code, &nbits); - - auto proto = this->protocol_.value(x...); - proto.transmit(dst, code, nbits); - } -}; - -template class RCSwitchTypeBAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(RCSwitchBase, protocol); - TEMPLATABLE_VALUE(uint8_t, address); - TEMPLATABLE_VALUE(uint8_t, channel); - TEMPLATABLE_VALUE(bool, state); - - void encode(RemoteTransmitData *dst, Ts... x) override { - auto address = this->address_.value(x...); - auto channel = this->channel_.value(x...); - auto state = this->state_.value(x...); - - uint64_t code; - uint8_t nbits; - RCSwitchBase::type_b_code(address, channel, state, &code, &nbits); - - auto proto = this->protocol_.value(x...); - proto.transmit(dst, code, nbits); - } -}; - -template class RCSwitchTypeCAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(RCSwitchBase, protocol); - TEMPLATABLE_VALUE(std::string, family); - TEMPLATABLE_VALUE(uint8_t, group); - TEMPLATABLE_VALUE(uint8_t, device); - TEMPLATABLE_VALUE(bool, state); - - void encode(RemoteTransmitData *dst, Ts... x) override { - auto family = this->family_.value(x...); - auto group = this->group_.value(x...); - auto device = this->device_.value(x...); - auto state = this->state_.value(x...); - - auto u_family = static_cast(tolower(family[0]) - 'a'); - - uint64_t code; - uint8_t nbits; - RCSwitchBase::type_c_code(u_family, group, device, state, &code, &nbits); - - auto proto = this->protocol_.value(x...); - proto.transmit(dst, code, nbits); - } -}; -template class RCSwitchTypeDAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(RCSwitchBase, protocol); - TEMPLATABLE_VALUE(std::string, group); - TEMPLATABLE_VALUE(uint8_t, device); - TEMPLATABLE_VALUE(bool, state); - - void encode(RemoteTransmitData *dst, Ts... x) override { - auto group = this->group_.value(x...); - auto device = this->device_.value(x...); - auto state = this->state_.value(x...); - - auto u_group = static_cast(tolower(group[0]) - 'a'); - - uint64_t code; - uint8_t nbits; - RCSwitchBase::type_d_code(u_group, device, state, &code, &nbits); - - auto proto = this->protocol_.value(x...); - proto.transmit(dst, code, nbits); - } -}; - class RCSwitchRawReceiver : public RemoteReceiverBinarySensorBase { public: /// `protocol` must outlive the receiver: a RC_SWITCH_PROTOCOLS entry or a codegen flash table. diff --git a/tests/component_tests/remote_receiver/test_rc_switch_protocol.py b/tests/component_tests/remote_receiver/test_rc_switch_protocol.py index 5c0d14c7945..aee308a2d54 100644 --- a/tests/component_tests/remote_receiver/test_rc_switch_protocol.py +++ b/tests/component_tests/remote_receiver/test_rc_switch_protocol.py @@ -20,6 +20,6 @@ def test_rc_switch_protocols_share_flash_tables( # Receivers point into flash instead of holding a copy. assert "->set_protocol(&remote_base::RC_SWITCH_PROTOCOLS[1]);" in main_cpp assert "->set_protocol(rc_switch_custom_protocol_2);" in main_cpp - # Numbered and lambda protocols keep their existing paths. - assert "return remote_base::rc_switch_protocol(1);" in main_cpp + # A numbered protocol is copied out where it is sent; a lambda keeps its own path. + assert "remote_base::rc_switch_protocol(1).transmit(" in main_cpp assert "return remote_base::rc_switch_protocol(2);" in main_cpp diff --git a/tests/component_tests/remote_transmitter/config/raw_code_storage_id.yaml b/tests/component_tests/remote_transmitter/config/raw_code_storage_id.yaml new file mode 100644 index 00000000000..7820b217be5 --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/raw_code_storage_id.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + id: tx + pin: GPIO4 + carrier_duty_percent: 50% + +button: + - platform: template + name: Send + on_press: + - remote_transmitter.transmit_raw: + code: [1000, -1000] + code_storage_id: my_code diff --git a/tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py b/tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py index 1703ddf6ede..2726c5a532b 100644 --- a/tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py +++ b/tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py @@ -6,8 +6,15 @@ import re import pytest +from esphome.components.remote_base import _rc_switch_bits, _rc_switch_letter_index + CONFIG = "raw_rc_switch_actions.yaml" +_TRANSMIT_BODY = re.compile( + r"auto esphome__call = ::tx->transmit\(\);\n(.*?)esphome__call\.perform\(\);", + re.DOTALL, +) + @pytest.fixture def main_cpp( @@ -19,117 +26,188 @@ def main_cpp( return re.sub(r"\n *(#line .*|// ESPHOME_LINE_DIRECTIVE_RESET)(?=\n)", "", main) -def _action(main_cpp: str, action_id: str) -> str: - """The setup statements of one action object.""" - marker = f"new({action_id}) " - start = main_cpp.index(marker) - # Ends at the next object constructed, or at the end of the file - end = main_cpp.find("new(", start + len(marker)) - return main_cpp[start : end if end != -1 else len(main_cpp)] +@pytest.fixture +def bodies(main_cpp: str) -> list[str]: + """The statements of each transmit: the script's two, then one per button.""" + found = _TRANSMIT_BODY.findall(main_cpp) + assert len(found) == 14 + return found -def test_raw_static_code_in_flash(main_cpp: str) -> None: - raw = _action(main_cpp, "remote_base_rawaction_id_2") +def _in_order(body: str, *parts: str) -> None: + positions = [body.index(part) for part in parts] + assert positions == sorted(positions) + + +def test_code_storage_id_warns( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + generate_main(component_config_path("raw_code_storage_id.yaml")) + assert "transmit_raw ignores code_storage_id 'my_code'" in caplog.text + + +def test_no_action_objects(main_cpp: str) -> None: + assert "RawAction" not in main_cpp + assert "RCSwitch" not in main_cpp.replace("RCSwitchBase", "") + # The script's two transmits merge into one action; the buttons call functions directly + assert main_cpp.count("new(applyaction_id") == 1 + assert main_cpp.count("new(automation_id") == 1 + + +def test_raw_static_code_in_flash(main_cpp: str, bodies: list[str]) -> None: + # Both buttons send the same code from one flash table assert ( - "static constexpr int32_t int32_t_id_2[] PROGMEM = {1000, -1000, 500, -500};" - in raw + main_cpp.count( + "static constexpr int32_t remote_base_raw_code[] PROGMEM = " + "{1000, -1000, 500, -500};" + ) + == 1 ) - assert "->set_code_static(int32_t_id_2, 4);" in raw - assert "->set_carrier_frequency([]() -> uint32_t {\n return 0;\n});" in raw + raw = bodies[2] + assert ( + "remote_base::encode_raw_timings(esphome__call.get_data(), " + "::remote_base_raw_code, 4);" + ) in raw + # transmit() already resets the carrier frequency to 0 + assert "set_carrier_frequency" not in raw # Without repeat the call sends once assert "set_send_times" not in raw assert "set_send_wait" not in raw -def test_raw_repeat_and_carrier(main_cpp: str) -> None: - raw = _action(main_cpp, "remote_base_rawaction_id_3") - assert "->set_send_times([]() -> uint32_t {\n return 2;\n});" in raw - assert "->set_send_wait([]() -> uint32_t {\n return 10000;\n});" in raw - assert "->set_carrier_frequency([]() -> uint32_t {\n return 38000;\n});" in raw +def test_raw_repeat_and_carrier(bodies: list[str]) -> None: + _in_order( + bodies[3], + "encode_raw_timings(", + "set_carrier_frequency(38000);", + "esphome__call.set_send_times(2);", + "esphome__call.set_send_wait(10000);", + ) -def test_raw_lambda_code(main_cpp: str) -> None: - raw = _action(main_cpp, "remote_base_rawaction_id_4") +def test_raw_lambda_code(bodies: list[str]) -> None: assert ( - "->set_code_template([]() -> std::vector {\n" + "esphome__call.get_data()->set_data([]() -> std::vector {\n" " return {2000, -1000};\n" - "});" - ) in raw + " }());" + ) in bodies[4] -def test_rc_switch_raw(main_cpp: str) -> None: - constant = _action(main_cpp, "remote_base_rcswitchrawaction_id") - assert "return remote_base::rc_switch_protocol(2);" in constant - assert '->set_code(ESPHOME_F("00101001100111110101xxxx"));' in constant +def test_rc_switch_raw(bodies: list[str]) -> None: + # The code is decoded at build time; every character but 0 is a one + assert ( + "remote_base::rc_switch_protocol(2).transmit(esphome__call.get_data(), " + "2727775, 24);" + ) in bodies[5] # The rc_switch actions also send once without repeat - assert "set_send_times" not in constant - lambda_ = _action(main_cpp, "remote_base_rcswitchrawaction_id_2") - assert "return remote_base::rc_switch_protocol(1);" in lambda_ - assert ' return std::string("0010100110");' in lambda_ - assert "->set_send_times([]() -> uint32_t {\n return 2;\n});" in lambda_ - assert "->set_send_wait([]() -> uint32_t {\n return 10000;\n});" in lambda_ + assert "set_send_times" not in bodies[5] + lambda_ = bodies[6] + _in_order( + lambda_, + "const std::string esphome__code = []() -> std::string {\n" + ' return std::string("0010100110");', + "remote_base::rc_switch_protocol(1).transmit(esphome__call.get_data(), " + "remote_base::decode_binary_string(esphome__code), esphome__code.size());", + "esphome__call.set_send_times(2);", + "esphome__call.set_send_wait(10000);", + ) -def test_rc_switch_type_a(main_cpp: str) -> None: - constant = _action(main_cpp, "remote_base_rcswitchtypeaaction_id") +@pytest.mark.parametrize( + ("code", "value"), + [("0", 0), ("1", 1), ("x", 1), ("10x0", 0b1010), ("1" * 64, 2**64 - 1)], +) +def test_rc_switch_bits_match_decode_binary_string(code: str, value: int) -> None: + assert _rc_switch_bits(code) == value + + +@pytest.mark.parametrize(("letter", "index"), [("a", 0), ("C", 2), ("p", 15)]) +def test_rc_switch_letter_index_matches_tolower(letter: str, index: int) -> None: + assert _rc_switch_letter_index(letter) == index + + +def test_rc_switch_type_a(bodies: list[str]) -> None: assert ( - "return remote_base::rc_switch_protocol_copy(rc_switch_custom_protocol);" - in constant - ) - assert '->set_group(ESPHOME_F("11001"));' in constant - assert '->set_device(ESPHOME_F("01000"));' in constant - assert "->set_state([]() -> bool {\n return true;\n});" in constant - lambda_ = _action(main_cpp, "remote_base_rcswitchtypeaaction_id_2") - assert '->set_group([]() -> std::string {\n return std::string("11001");' in ( - lambda_ - ) - assert '->set_device([]() -> std::string {\n return std::string("01000");' in ( - lambda_ - ) - assert "->set_state([]() -> bool {\n return false;" in lambda_ - - -def test_rc_switch_type_b(main_cpp: str) -> None: - constant = _action(main_cpp, "remote_base_rcswitchtypebaction_id_2") - assert "->set_address([]() -> uint8_t {\n return 4;\n});" in constant - assert "->set_channel([]() -> uint8_t {\n return 2;\n});" in constant - assert "->set_state([]() -> bool {\n return true;\n});" in constant - - -def test_rc_switch_type_c(main_cpp: str) -> None: - constant = _action(main_cpp, "remote_base_rcswitchtypecaction_id") - assert '->set_family(ESPHOME_F("c"));' in constant - assert "->set_group([]() -> uint8_t {\n return 1;\n});" in constant - assert "->set_device([]() -> uint8_t {\n return 2;\n});" in constant - lambda_ = _action(main_cpp, "remote_base_rcswitchtypecaction_id_2") - assert '->set_family([]() -> std::string {\n return std::string("C");' in ( - lambda_ - ) - - -def test_rc_switch_type_d(main_cpp: str) -> None: - constant = _action(main_cpp, "remote_base_rcswitchtypedaction_id") - assert '->set_group(ESPHOME_F("b"));' in constant - assert "->set_device([]() -> uint8_t {\n return 3;\n});" in constant - assert "->set_state([]() -> bool {\n return false;\n});" in constant - lambda_ = _action(main_cpp, "remote_base_rcswitchtypedaction_id_2") - assert '->set_group([]() -> std::string {\n return std::string("b");' in lambda_ - - -def test_lambdas_take_args_by_value(main_cpp: str) -> None: - """User lambdas get the script argument by value, so they may modify it.""" - raw = _action(main_cpp, "remote_base_rawaction_id") + "remote_base::RCSwitchBase::type_a_code(25, 8, true, " + "&esphome__code, &esphome__nbits);" + ) in bodies[7] assert ( - "[](int x) -> std::vector {\n x *= 2;\n return {x, -1000};\n}" - in raw + "remote_base::rc_switch_protocol_copy(rc_switch_custom_protocol).transmit(" + "esphome__call.get_data(), esphome__code, esphome__nbits);" + ) in bodies[7] + _in_order( + bodies[8], + "const auto esphome__group = remote_base::decode_binary_string(" + '[]() -> std::string {\n return std::string("11001");', + "const auto esphome__device = remote_base::decode_binary_string(", + "const auto esphome__state = []() -> bool {\n return false;", + "remote_base::RCSwitchBase::type_a_code(esphome__group, esphome__device, " + "esphome__state, &esphome__code, &esphome__nbits);", + "remote_base::rc_switch_protocol(1).transmit(", ) - assert "[](int x) -> uint32_t {\n return x *= 1000;\n}" in raw - assert "[](int x) -> uint32_t {\n return x *= 1;\n}" in raw - assert "[](int x) -> uint32_t {\n x *= 3; return x;\n}" in raw - type_b = _action(main_cpp, "remote_base_rcswitchtypebaction_id") - assert "[](int x) -> uint8_t {\n x *= 2; return x;\n}" in type_b - assert "[](int x) -> uint8_t {\n return x *= 1;\n}" in type_b + + +def test_rc_switch_type_b(bodies: list[str]) -> None: assert ( + "remote_base::RCSwitchBase::type_b_code(4, 2, true, " + "&esphome__code, &esphome__nbits);" + ) in bodies[9] + + +def test_rc_switch_type_c(bodies: list[str]) -> None: + assert ( + "remote_base::RCSwitchBase::type_c_code(2, 1, 2, true, " + "&esphome__code, &esphome__nbits);" + ) in bodies[10] + _in_order( + bodies[11], + "const auto esphome__family = static_cast(tolower(" + "[]() -> std::string {\n return std::string(\"C\");\n }()[0]) - 'a');", + "const auto esphome__group = []() -> uint8_t {", + "const auto esphome__device = []() -> uint8_t {", + "const auto esphome__state = []() -> bool {", + "remote_base::RCSwitchBase::type_c_code(esphome__family, esphome__group, " + "esphome__device, esphome__state, &esphome__code, &esphome__nbits);", + ) + + +def test_rc_switch_type_d(bodies: list[str]) -> None: + assert ( + "remote_base::RCSwitchBase::type_d_code(1, 3, false, " + "&esphome__code, &esphome__nbits);" + ) in bodies[12] + assert ( + "const auto esphome__group = static_cast(tolower(" + "[]() -> std::string {\n return std::string(\"b\");\n }()[0]) - 'a');" + ) in bodies[13] + + +def test_lambdas_take_args_by_value(bodies: list[str]) -> None: + """User lambdas get the script argument by value, so they may modify it. + + They are called rather than reduced, so even a single return modifies only its own copy. + """ + raw, type_b = bodies[0], bodies[1] + _in_order( + raw, + "set_data([](int x) -> std::vector {\n" + " x *= 2;\n return {x, -1000};\n }(x));", + "set_carrier_frequency([](int x) -> uint32_t {\n" + " return x *= 1000;\n }(x));", + "set_send_times([](int x) -> uint32_t {\n return x *= 1;\n }(x));", + "set_send_wait([](int x) -> uint32_t {\n x *= 3; return x;\n }(x));", + ) + _in_order( + type_b, + "const auto esphome__address = [](int x) -> uint8_t {\n" + " x *= 2; return x;\n }(x);", + "const auto esphome__channel = [](int x) -> uint8_t {\n" + " return x *= 1;\n }(x);", + "remote_base::RCSwitchBase::type_b_code(", + # The protocol is read last, as before "[](int x) -> remote_base::RCSwitchBase {\n" - " x *= 1; return remote_base::rc_switch_protocol(x);\n}" - ) in type_b + " x *= 1; return remote_base::rc_switch_protocol(x);\n" + " }(x).transmit(esphome__call.get_data(), esphome__code, esphome__nbits);", + )