From e708c85100f1b2342a2d3b0c60e70d8aa5abde42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:12:34 -0500 Subject: [PATCH 001/128] [uart] Skip the parity setter when it matches the default (#20069) --- esphome/components/uart/__init__.py | 4 +++- esphome/components/uart/uart_component.h | 2 +- .../uart/config/parity_default.yaml | 12 ++++++++++ .../uart/config/parity_even.yaml | 13 ++++++++++ tests/component_tests/uart/test_parity.py | 24 +++++++++++++++++++ 5 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/uart/config/parity_default.yaml create mode 100644 tests/component_tests/uart/config/parity_even.yaml create mode 100644 tests/component_tests/uart/test_parity.py diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 598e3df168a..24a854ec392 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -402,7 +402,9 @@ async def to_code(config): cg.add(var.set_clock_source(UART_CLOCK_SOURCES[clock_source])) cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) cg.add(var.set_data_bits(config[CONF_DATA_BITS])) - cg.add(var.set_parity(config[CONF_PARITY])) + # Skip the setter when the config matches the C++ initializer (UART_CONFIG_PARITY_NONE). + if (parity := config[CONF_PARITY]) != "NONE": + cg.add(var.set_parity(parity)) if CONF_DEBUG in config: await debug_to_code(config[CONF_DEBUG], var) diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 4269ef0b16f..8f143b79777 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -217,7 +217,7 @@ class UARTComponent { uint32_t baud_rate_{0}; uint8_t stop_bits_{0}; uint8_t data_bits_{0}; - UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; + UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; // Must match the parity check in __init__.py #ifdef USE_UART_DEBUGGER CallbackManager debug_callback_{}; #endif diff --git a/tests/component_tests/uart/config/parity_default.yaml b/tests/component_tests/uart/config/parity_default.yaml new file mode 100644 index 00000000000..0f073aaac8d --- /dev/null +++ b/tests/component_tests/uart/config/parity_default.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +uart: + - id: default_uart + tx_pin: GPIO17 + rx_pin: GPIO16 + baud_rate: 9600 diff --git a/tests/component_tests/uart/config/parity_even.yaml b/tests/component_tests/uart/config/parity_even.yaml new file mode 100644 index 00000000000..1ae742d57a7 --- /dev/null +++ b/tests/component_tests/uart/config/parity_even.yaml @@ -0,0 +1,13 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +uart: + - id: even_uart + tx_pin: GPIO17 + rx_pin: GPIO16 + baud_rate: 9600 + parity: EVEN diff --git a/tests/component_tests/uart/test_parity.py b/tests/component_tests/uart/test_parity.py new file mode 100644 index 00000000000..c18869a8c4d --- /dev/null +++ b/tests/component_tests/uart/test_parity.py @@ -0,0 +1,24 @@ +"""Tests for UART parity code generation.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_parity_is_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Parity NONE is the C++ initializer, so the setter is skipped.""" + main_cpp = generate_main(component_config_path("parity_default.yaml")) + + assert "default_uart->set_parity(" not in main_cpp + + +def test_custom_parity_is_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A non default parity still reaches the setter.""" + main_cpp = generate_main(component_config_path("parity_even.yaml")) + + assert "even_uart->set_parity(uart::UART_CONFIG_PARITY_EVEN);" in main_cpp From e09cab94d459fb874b8e956f9c724a1ce6accd92 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:13:01 -0500 Subject: [PATCH 002/128] [version] Skip the hide hash and hide timestamp setters when they match the defaults (#20067) --- esphome/components/version/text_sensor.py | 7 +++++-- .../components/version/version_text_sensor.h | 4 ++-- tests/component_tests/version/__init__.py | 0 .../component_tests/version/config/version.yaml | 16 ++++++++++++++++ tests/component_tests/version/test_version.py | 17 +++++++++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/version/__init__.py create mode 100644 tests/component_tests/version/config/version.yaml create mode 100644 tests/component_tests/version/test_version.py diff --git a/esphome/components/version/text_sensor.py b/esphome/components/version/text_sensor.py index cad239dc415..189d18256f1 100644 --- a/esphome/components/version/text_sensor.py +++ b/esphome/components/version/text_sensor.py @@ -35,5 +35,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - cg.add(var.set_hide_hash(config[CONF_HIDE_HASH])) - cg.add(var.set_hide_timestamp(config[CONF_HIDE_TIMESTAMP])) + # Both flags default to false in C++, so only true needs a setter. + if config[CONF_HIDE_HASH]: + cg.add(var.set_hide_hash(True)) + if config[CONF_HIDE_TIMESTAMP]: + cg.add(var.set_hide_timestamp(True)) diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index 7ff6ac4d352..85374350790 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -15,8 +15,8 @@ class VersionTextSensor final : public text_sensor::TextSensor, public Component void dump_config() override; protected: - bool hide_hash_{false}; - bool hide_timestamp_{false}; + bool hide_hash_{false}; // Must match the setter check in text_sensor.py + bool hide_timestamp_{false}; // Must match the setter check in text_sensor.py }; } // namespace esphome::version diff --git a/tests/component_tests/version/__init__.py b/tests/component_tests/version/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/version/config/version.yaml b/tests/component_tests/version/config/version.yaml new file mode 100644 index 00000000000..c602f881834 --- /dev/null +++ b/tests/component_tests/version/config/version.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +text_sensor: + - platform: version + id: default_version + name: Default Version + - platform: version + id: hidden_version + name: Hidden Version + hide_hash: true + hide_timestamp: true diff --git a/tests/component_tests/version/test_version.py b/tests/component_tests/version/test_version.py new file mode 100644 index 00000000000..14886264a20 --- /dev/null +++ b/tests/component_tests/version/test_version.py @@ -0,0 +1,17 @@ +"""Tests for the version text sensor codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_default_hide_flags_are_not_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Both flags are false in C++, so only true reaches the setters.""" + main_cpp = generate_main(component_config_path("version.yaml")) + + assert "default_version->set_hide_hash(" not in main_cpp + assert "default_version->set_hide_timestamp(" not in main_cpp + assert "hidden_version->set_hide_hash(true);" in main_cpp + assert "hidden_version->set_hide_timestamp(true);" in main_cpp From 31e0a31c651d7ce7ba59353760dd9b36cb498e39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:13:41 -0500 Subject: [PATCH 003/128] [api] Skip the listen backlog setter when it matches the default (#20066) --- esphome/components/api/__init__.py | 7 +++++-- esphome/components/api/api_server.h | 4 +--- tests/component_tests/api/config/custom.yaml | 1 + tests/component_tests/api/config/defaults.yaml | 1 + tests/component_tests/api/config/esp8266.yaml | 12 ++++++++++++ tests/component_tests/api/test_default_setters.py | 14 +++++++++++++- 6 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/api/config/esp8266.yaml diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 25ebc934586..764a3d0f1e6 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -142,6 +142,7 @@ CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" DEFAULT_PORT = 6053 DEFAULT_REBOOT_TIMEOUT = "15min" DEFAULT_BATCH_DELAY = "100ms" +DEFAULT_LISTEN_BACKLOG = 4 def _register_provisioning_source(config: ConfigType) -> ConfigType: @@ -477,8 +478,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_reboot_timeout(reboot_timeout)) if (batch_delay := config[CONF_BATCH_DELAY]) != cv.time_period(DEFAULT_BATCH_DELAY): cg.add(var.set_batch_delay(batch_delay)) - if CONF_LISTEN_BACKLOG in config: - cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) + if ( + listen_backlog := config.get(CONF_LISTEN_BACKLOG) + ) is not None and listen_backlog != DEFAULT_LISTEN_BACKLOG: + cg.add(var.set_listen_backlog(listen_backlog)) cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 865cd17156d..491ab383425 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -357,9 +357,7 @@ class APIServer final : public Component // Group smaller types together uint16_t port_{6053}; // Keep in sync with DEFAULT_PORT in __init__.py uint16_t batch_delay_{100}; // Keep in sync with DEFAULT_BATCH_DELAY in __init__.py - // Connection limits - these defaults will be overridden by config values - // from cv.SplitDefault in __init__.py which sets platform-specific defaults. - uint8_t listen_backlog_{4}; + uint8_t listen_backlog_{4}; // Keep in sync with DEFAULT_LISTEN_BACKLOG in __init__.py bool shutting_down_ = false; uint8_t api_connection_count_{0}; #if defined(USE_PROVISIONING) && defined(USE_API_NOISE) diff --git a/tests/component_tests/api/config/custom.yaml b/tests/component_tests/api/config/custom.yaml index cdf4038d5d5..476a46ebffa 100644 --- a/tests/component_tests/api/config/custom.yaml +++ b/tests/component_tests/api/config/custom.yaml @@ -13,3 +13,4 @@ api: port: 6054 reboot_timeout: 0s batch_delay: 0ms + listen_backlog: 2 diff --git a/tests/component_tests/api/config/defaults.yaml b/tests/component_tests/api/config/defaults.yaml index b20fd9b884c..49d7b0abcce 100644 --- a/tests/component_tests/api/config/defaults.yaml +++ b/tests/component_tests/api/config/defaults.yaml @@ -13,3 +13,4 @@ api: port: 6053 reboot_timeout: 15min batch_delay: 100ms + listen_backlog: 4 diff --git a/tests/component_tests/api/config/esp8266.yaml b/tests/component_tests/api/config/esp8266.yaml new file mode 100644 index 00000000000..0ba6ccbd33c --- /dev/null +++ b/tests/component_tests/api/config/esp8266.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: testtest + +api: diff --git a/tests/component_tests/api/test_default_setters.py b/tests/component_tests/api/test_default_setters.py index 32d35cacb7c..2c7f843c680 100644 --- a/tests/component_tests/api/test_default_setters.py +++ b/tests/component_tests/api/test_default_setters.py @@ -12,7 +12,7 @@ def test_default_values_are_not_emitted( component_config_path: Callable[[str], Path], config_file: str, ) -> None: - """Port 6053, a 15 min reboot timeout and 100 ms batch delay are C++ initializers. + """Port 6053, a 15 min reboot timeout, 100 ms batch delay and backlog 4 are C++ initializers. Both the schema defaults and the same values written explicitly take the skip path. """ @@ -21,6 +21,7 @@ def test_default_values_are_not_emitted( assert "api_apiserver_id->set_port(" not in main_cpp assert "api_apiserver_id->set_reboot_timeout(" not in main_cpp assert "api_apiserver_id->set_batch_delay(" not in main_cpp + assert "api_apiserver_id->set_listen_backlog(" not in main_cpp def test_custom_values_are_emitted( @@ -33,3 +34,14 @@ def test_custom_values_are_emitted( assert "api_apiserver_id->set_port(6054);" in main_cpp assert "api_apiserver_id->set_reboot_timeout(0);" in main_cpp assert "api_apiserver_id->set_batch_delay(0);" in main_cpp + assert "api_apiserver_id->set_listen_backlog(2);" in main_cpp + + +def test_esp8266_listen_backlog_is_emitted( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The ESP8266 schema default of 1 differs from the C++ initializer, so it is set.""" + main_cpp = generate_main(component_config_path("esp8266.yaml")) + + assert "api_apiserver_id->set_listen_backlog(1);" in main_cpp From 4f9cd7821a7433ddf9fe9edf6bc52ccc3b89576d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:14:42 -0500 Subject: [PATCH 004/128] [core] Log ignored --skip-bootloader at debug level (#20059) --- esphome/__main__.py | 6 +++--- tests/unit_tests/test_main.py | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 40457dbcc2b..6c984050f42 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -829,9 +829,9 @@ def write_cpp_file() -> int: def compile_program(args: ArgsProtocol, config: ConfigType) -> int: if CORE.skip_bootloader and not (CORE.is_esp32 and CORE.using_toolchain_esp_idf): - # Info, not a warning: an orchestrator cannot see YAML toolchain - # overrides, this is its expected no-op, and a full build is safe. - _LOGGER.info( + # Debug only: an orchestrator cannot see YAML toolchain overrides, + # so this is its expected no-op, and a full build is safe. + _LOGGER.debug( "--skip-bootloader ignored: only supported on ESP32 with the " "esp-idf toolchain" ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e46b61cfd57..4edf6d5fc31 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -5358,7 +5358,7 @@ def _setup_build_info_test( return build_info_path, firmware_path -def test_compile_program_warns_and_ignores_skip_bootloader_elsewhere( +def test_compile_program_quietly_ignores_skip_bootloader_elsewhere( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A full build is always safe; orchestrators cannot see YAML overrides.""" @@ -5372,11 +5372,13 @@ def test_compile_program_warns_and_ignores_skip_bootloader_elsewhere( side_effect=EsphomeError("stop here"), ), pytest.raises(EsphomeError, match="stop here"), - caplog.at_level("INFO"), + caplog.at_level("DEBUG"), ): compile_program(MockArgs(), config) - assert "--skip-bootloader ignored" in caplog.text + assert [ + r.levelno for r in caplog.records if "--skip-bootloader ignored" in r.message + ] == [logging.DEBUG] assert CORE.skip_bootloader is False From feedea4954a342091c9751ccfd1ad68db59ef6e3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:15:58 -0500 Subject: [PATCH 005/128] [web_server] Remove dead ESP-IDF event source connect callback and pack expose_log_ (#20130) --- esphome/components/web_server/web_server.h | 4 +++- esphome/components/web_server_idf/web_server_idf.cpp | 7 +------ esphome/components/web_server_idf/web_server_idf.h | 7 ------- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 143e9a15eae..6f08a8dc8f7 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -531,6 +531,9 @@ class WebServer final : public Component, public AsyncWebHandler { bool include_internal_{false}; protected: + // Declared here so it packs into the padding after include_internal_ + bool expose_log_{true}; + void add_sorting_info_(JsonObject &root, EntityBase *entity); #ifdef USE_LIGHT @@ -614,7 +617,6 @@ class WebServer final : public Component, public AsyncWebHandler { #ifdef USE_WEBSERVER_JS_INCLUDE const char *js_include_{nullptr}; #endif - bool expose_log_{true}; #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. diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 510b4e50f1a..9798410e238 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -761,7 +761,7 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { this->has_pending_sessions_.store(false, std::memory_order_relaxed); } for (auto *rsp : incoming) { - // Already disconnected? Drop it; skip on_connect_/session start on a dead session. + // Already disconnected? Drop it; skip session start on a dead session. if (rsp->safe_to_delete_()) { delete rsp; // NOLINT(cppcoreguidelines-owning-memory) continue; @@ -775,12 +775,7 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { continue; } this->sessions_.push_back(rsp); - // Prime first so on_connect_ observes a session that has already sent its - // initial ping/config/sorting_groups, matching the pre-refactor ordering. rsp->start_session_main_loop_(); - if (this->on_connect_) { - this->on_connect_(rsp); - } } } // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 2b62d68790e..581ba8323b5 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -379,7 +379,6 @@ using AsyncEventSourceClient = AsyncEventSourceResponse; class AsyncEventSource : public AsyncWebHandler { friend class AsyncEventSourceResponse; - using connect_handler_t = std::function; public: AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {} @@ -394,11 +393,6 @@ class AsyncEventSource : public AsyncWebHandler { } // NOLINTNEXTLINE(readability-identifier-naming) void handleRequest(AsyncWebServerRequest *request) override; - // Callback runs on the main loop (not the httpd task) after the session's - // initial ping/config/sorting_groups have been sent. - // NOLINTNEXTLINE(readability-identifier-naming) - void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } - void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); @@ -418,7 +412,6 @@ class AsyncEventSource : public AsyncWebHandler { // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. std::vector pending_sessions_; Mutex pending_mutex_; - connect_handler_t on_connect_{}; esphome::web_server::WebServer *web_server_; std::atomic has_pending_sessions_{false}; }; From b36c5441cb2d997bc528677624f6a351eef7a5ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:16:16 -0500 Subject: [PATCH 006/128] [esp32] Remove the unused loop_task_handle global (#20132) --- esphome/components/esp32/core.cpp | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index a6916fe7394..f718d16df46 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -17,8 +17,7 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { // HAL functions live in hal.cpp. This file keeps only the loop task setup. -TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static StackType_t loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -40,11 +39,10 @@ extern "C" void app_main() { initArduino(); esp32::setup_preferences(); #if CONFIG_FREERTOS_UNICORE - loop_task_handle = xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, - &loop_task_tcb); + xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, &loop_task_tcb); #else - loop_task_handle = xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, - loop_task_stack, &loop_task_tcb, 1); + xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, + &loop_task_tcb, 1); #endif } From acf7257293fd6f04182f0f5a7d91d8e0c535c18b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:17:22 -0500 Subject: [PATCH 007/128] [core] Use the static queue structure as the FreeRTOSQueue handle (#20133) --- esphome/core/freertos_queue.h | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/esphome/core/freertos_queue.h b/esphome/core/freertos_queue.h index 2f3faf818a3..1067ae07ccf 100644 --- a/esphome/core/freertos_queue.h +++ b/esphome/core/freertos_queue.h @@ -27,9 +27,7 @@ namespace esphome { template class FreeRTOSQueue { public: - FreeRTOSQueue() : dropped_count_(0) { - this->handle_ = xQueueCreateStatic(SIZE, sizeof(T *), this->storage_, &this->queue_buf_); - } + FreeRTOSQueue() : dropped_count_(0) { xQueueCreateStatic(SIZE, sizeof(T *), this->storage_, &this->queue_buf_); } // No destructor — ESPHome components are never destroyed. Intentionally // omitted to avoid pulling in vQueueDelete code on resource-constrained targets. @@ -44,7 +42,7 @@ template class FreeRTOSQueue { if (element == nullptr) return false; - if (xQueueSend(this->handle_, &element, 0) != pdPASS) { + if (xQueueSend(this->handle_(), &element, 0) != pdPASS) { this->increment_dropped_count(); return false; } @@ -53,7 +51,7 @@ template class FreeRTOSQueue { T *pop() { T *element; - if (xQueueReceive(this->handle_, &element, 0) != pdTRUE) { + if (xQueueReceive(this->handle_(), &element, 0) != pdTRUE) { return nullptr; } return element; @@ -80,18 +78,24 @@ template class FreeRTOSQueue { portEXIT_CRITICAL(); } - bool empty() const { return uxQueueMessagesWaiting(this->handle_) == 0; } + bool empty() const { return uxQueueMessagesWaiting(this->handle_()) == 0; } - bool full() const { return uxQueueSpacesAvailable(this->handle_) == 0; } + bool full() const { return uxQueueSpacesAvailable(this->handle_()) == 0; } - size_t size() const { return uxQueueMessagesWaiting(this->handle_); } + size_t size() const { return uxQueueMessagesWaiting(this->handle_()); } protected: // Static storage for the queue — lives in BSS, no heap allocation uint8_t storage_[SIZE * sizeof(T *)]; StaticQueue_t queue_buf_; - QueueHandle_t handle_; uint16_t dropped_count_; + + // xQueueCreateStatic() returns the static queue structure itself as the handle; + // its only other checks are asserts, and a zero length is the one we could hit + static_assert(SIZE > 0, "FreeRTOSQueue needs at least one slot"); + QueueHandle_t handle_() const { + return reinterpret_cast(const_cast(&this->queue_buf_)); + } }; } // namespace esphome From ad1b6ab37e501db1b5d02d7830518da70cdac0af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:23:36 -0500 Subject: [PATCH 008/128] [web_server] Keep allowed origins in a shared flash table (#20086) --- esphome/components/web_server/__init__.py | 11 ++++++++++- esphome/components/web_server/web_server.cpp | 3 ++- esphome/components/web_server/web_server.h | 6 +++--- .../web_server/config/allowed_origins.yaml | 15 +++++++++++++++ .../web_server/test_allowed_origins.py | 18 ++++++++++++++++++ 5 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/web_server/config/allowed_origins.yaml create mode 100644 tests/component_tests/web_server/test_allowed_origins.py diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2b00a6f8de1..6bc4d7ab5c9 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -413,7 +413,16 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") - cg.add(var.set_allowed_origins(allowed_origins)) + # Shared flash table ended by nullptr, so the server stores only a pointer. + cg.add( + var.set_allowed_origins( + cg.shared_progmem_array( + "web_server_allowed_origins", + cg.const_char_ptr, + [*allowed_origins, cg.nullptr], + ) + ) + ) if (auth := config.get(CONF_AUTH)) is not None: cg.add_define("USE_WEBSERVER_AUTH") # The scheme is fixed at build time so the unused Basic/Digest code path is compiled diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 74641738ec3..90c6b167f6a 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -495,7 +495,8 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const #ifdef USE_WEBSERVER_ALLOWED_ORIGINS // Otherwise the origin must be explicitly allowed via configuration. - for (const char *allowed_origin : this->allowed_origins_) { + 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; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 6f08a8dc8f7..1f9b6cb9348 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -254,9 +254,9 @@ class WebServer final : public Component, public AsyncWebHandler { * * This list is also used to authorize Private Network Access requests when that feature is enabled. * - * @param origins The list of allowed origins. + * @param origins nullptr-terminated table of allowed origins; must outlive the server. */ - void set_allowed_origins(std::initializer_list origins) { this->allowed_origins_ = origins; } + void set_allowed_origins(const char *const *origins) { this->allowed_origins_ = origins; } #endif // ========== INTERNAL METHODS ========== @@ -620,7 +620,7 @@ class WebServer final : public Component, public AsyncWebHandler { #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. - FixedVector allowed_origins_; + const char *const *allowed_origins_{nullptr}; #endif /// Check whether the given request Origin is permitted. Same-origin (matching the Host the diff --git a/tests/component_tests/web_server/config/allowed_origins.yaml b/tests/component_tests/web_server/config/allowed_origins.yaml new file mode 100644 index 00000000000..2ced49d6d8c --- /dev/null +++ b/tests/component_tests/web_server/config/allowed_origins.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 new file mode 100644 index 00000000000..7a6c602c129 --- /dev/null +++ b/tests/component_tests/web_server/test_allowed_origins.py @@ -0,0 +1,18 @@ +"""Tests for the web_server allowed_origins codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_allowed_origins_are_a_progmem_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Allowed origins live in a nullptr-terminated flash table instead of a heap vector.""" + main_cpp = generate_main(component_config_path("allowed_origins.yaml")) + + assert ( + "static constexpr const char * web_server_allowed_origins[] PROGMEM = " + '{"https://app.esphome.io", "*", nullptr};' in main_cpp + ) + assert "->set_allowed_origins(web_server_allowed_origins);" in main_cpp From 0017b9c439db5c78988f7a0e1595d4b20e6acd8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:26:42 -0500 Subject: [PATCH 009/128] [matrix_keypad] Keep the keys in flash (#20110) --- esphome/components/matrix_keypad/__init__.py | 11 ++++++-- .../matrix_keypad/matrix_keypad.cpp | 8 +++--- .../components/matrix_keypad/matrix_keypad.h | 5 ++-- .../component_tests/matrix_keypad/__init__.py | 0 .../matrix_keypad/config/keys.yaml | 23 ++++++++++++++++ .../matrix_keypad/test_keys.py | 27 +++++++++++++++++++ 6 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/matrix_keypad/__init__.py create mode 100644 tests/component_tests/matrix_keypad/config/keys.yaml create mode 100644 tests/component_tests/matrix_keypad/test_keys.py diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index 2e43eaf7e22..f9b744daf0a 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -27,6 +27,10 @@ CONF_HAS_PULLDOWNS = "has_pulldowns" def check_keys(obj: ConfigType) -> ConfigType: + for ch in obj.get(CONF_KEYS, ""): + if not ch.isascii(): + # Each key is reported as one byte, so only ASCII characters can be key codes + raise cv.Invalid(f"Key code {ch!r} is not an ASCII character") if CONF_KEYS in obj and len(obj[CONF_KEYS]) != len(obj[CONF_ROWS]) * len( obj[CONF_COLUMNS] ): @@ -74,8 +78,11 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(conf[CONF_PIN]) col_pins.append(pin) cg.add(var.set_columns(col_pins)) - if CONF_KEYS in config: - cg.add(var.set_keys(config[CONF_KEYS])) + if (keys := config.get(CONF_KEYS)) is not None: + table = cg.shared_progmem_array( + "matrix_keypad_keys", cg.uint8, list(keys.encode()) + ) + cg.add(var.set_keys(table)) cg.add(var.set_debounce_time(config[CONF_DEBOUNCE_TIME])) if CONF_HAS_DIODES in config: cg.add(var.set_has_diodes(config[CONF_HAS_DIODES])) diff --git a/esphome/components/matrix_keypad/matrix_keypad.cpp b/esphome/components/matrix_keypad/matrix_keypad.cpp index 3b71b50fd87..97641ac2d39 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.cpp +++ b/esphome/components/matrix_keypad/matrix_keypad.cpp @@ -58,8 +58,8 @@ void MatrixKeypad::loop() { ESP_LOGD(TAG, "key @ row %d, col %d released", row, col); for (auto &listener : this->listeners_) listener->button_released(row, col); - if (this->pressed_key_ < (int) this->keys_.size()) { - uint8_t keycode = this->keys_[this->pressed_key_]; + if (this->keys_ != nullptr) { + uint8_t keycode = progmem_read_byte(&this->keys_[this->pressed_key_]); ESP_LOGD(TAG, "key '%c' released", keycode); for (auto &listener : this->listeners_) listener->key_released(keycode); @@ -81,8 +81,8 @@ void MatrixKeypad::loop() { ESP_LOGD(TAG, "key @ row %d, col %d pressed", row, col); for (auto &listener : this->listeners_) listener->button_pressed(row, col); - if (key < (int) this->keys_.size()) { - uint8_t keycode = this->keys_[key]; + if (this->keys_ != nullptr) { + uint8_t keycode = progmem_read_byte(&this->keys_[key]); ESP_LOGD(TAG, "key '%c' pressed", keycode); for (auto &trigger : this->key_triggers_) trigger->trigger(keycode); diff --git a/esphome/components/matrix_keypad/matrix_keypad.h b/esphome/components/matrix_keypad/matrix_keypad.h index 8c9acc8e0cb..2384691e32f 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.h +++ b/esphome/components/matrix_keypad/matrix_keypad.h @@ -27,7 +27,8 @@ class MatrixKeypad final : public key_provider::KeyProvider, public Component { void dump_config() override; void set_columns(std::vector pins) { columns_ = std::move(pins); }; void set_rows(std::vector pins) { rows_ = std::move(pins); }; - void set_keys(std::string keys) { keys_ = std::move(keys); }; + /// `keys` is a codegen PROGMEM table with one key code per button. + void set_keys(const uint8_t *keys) { this->keys_ = keys; } void set_debounce_time(uint32_t debounce_time) { debounce_time_ = debounce_time; }; void set_has_diodes(bool has_diodes) { has_diodes_ = has_diodes; }; void set_has_pulldowns(bool has_pulldowns) { has_pulldowns_ = has_pulldowns; }; @@ -38,7 +39,7 @@ class MatrixKeypad final : public key_provider::KeyProvider, public Component { protected: std::vector rows_; std::vector columns_; - std::string keys_; + const uint8_t *keys_{nullptr}; uint32_t debounce_time_ = 0; bool has_diodes_{false}; bool has_pulldowns_{false}; diff --git a/tests/component_tests/matrix_keypad/__init__.py b/tests/component_tests/matrix_keypad/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/matrix_keypad/config/keys.yaml b/tests/component_tests/matrix_keypad/config/keys.yaml new file mode 100644 index 00000000000..99d32ffd147 --- /dev/null +++ b/tests/component_tests/matrix_keypad/config/keys.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +matrix_keypad: + - id: keypad_a + rows: + - pin: 12 + - pin: 13 + columns: + - pin: 14 + - pin: 15 + keys: "1234" + - id: keypad_b + rows: + - pin: 4 + - pin: 5 + columns: + - pin: 0 + - pin: 2 + keys: "1234" diff --git a/tests/component_tests/matrix_keypad/test_keys.py b/tests/component_tests/matrix_keypad/test_keys.py new file mode 100644 index 00000000000..7993a20b705 --- /dev/null +++ b/tests/component_tests/matrix_keypad/test_keys.py @@ -0,0 +1,27 @@ +"""Matrix keypad key codes live in a shared PROGMEM table instead of a std::string.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.matrix_keypad import check_keys +import esphome.config_validation as cv + + +def test_keys_use_shared_progmem_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("keys.yaml")) + + assert ( + "static constexpr uint8_t matrix_keypad_keys[] PROGMEM = {49, 50, 51, 52};" + in main_cpp + ) + assert main_cpp.count("->set_keys(matrix_keypad_keys);") == 2 + + +def test_non_ascii_keys_are_rejected() -> None: + with pytest.raises(cv.Invalid, match="'é' is not an ASCII"): + check_keys({"keys": "1é34"}) From 99bf0800928176a61e70cfa26272abb2c635dca9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:30:44 -0500 Subject: [PATCH 010/128] [select] Cover select.is in the component test (#20112) --- tests/components/select/common.yaml | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/components/select/common.yaml b/tests/components/select/common.yaml index f20c024cb8c..ff41b48ff42 100644 --- a/tests/components/select/common.yaml +++ b/tests/components/select/common.yaml @@ -33,6 +33,20 @@ button: cycle: false - select.first: select_test_select - select.last: select_test_select + - if: + condition: + select.is: + id: select_test_select + options: [one, two] + then: + - select.next: select_test_select + - if: + condition: + select.is: + id: select_test_select + lambda: return current == "three"; + then: + - select.first: select_test_select - select.operation: id: select_test_select operation: next @@ -41,3 +55,47 @@ button: id: select_test_select operation: !lambda return SELECT_OP_PREVIOUS; cycle: !lambda return true; + - platform: template + name: "Test Select Options Lambdas" + on_press: + - lambda: |- + auto &options = id(select_test_select).traits.get_options(); + for (size_t i = 0; i < options.size(); i++) + ESP_LOGD("test", "%s", options[i]); + - lambda: |- + std::string wanted = "two"; + const auto &opts = id(select_test_select).traits.get_options(); + if (opts.empty()) + return; + for (int i = 0; i < (int) opts.size(); i++) { + if (opts[i] == wanted) + ESP_LOGD("test", "found at %d", i); + } + if (id(select_test_select).traits.get_options()[0] == wanted) + ESP_LOGD("test", "first"); + auto it = std::find(opts.begin(), opts.end(), std::string("three")); + ESP_LOGD("test", "index %d", (int) (it - opts.begin())); + - lambda: |- + static std::vector names{"red", "green"}; + FixedVector fresh; + fresh.init(names.size()); + for (auto &name : names) + fresh.push_back(name.c_str()); + if (id(select_test_select).traits.get_options().size() != fresh.size()) + id(select_test_select).traits.set_options(fresh); + id(select_test_select).traits.set_options({"one", "two", "three"}); + - lambda: |- + const char *value = "two"; + const auto &options = id(select_test_select).traits.get_options(); + const auto *it = std::find_if(options.begin(), options.end(), + [value](const char *option) { return strcmp(option, value) == 0; }); + if (it == options.end()) + return; + uint32_t index = (uint32_t) (it - options.begin()); + ESP_LOGD("test", "index %u", (unsigned) index); + const auto *it2 = std::find(options.begin(), options.end(), options[0]); + ESP_LOGD("test", "first at %d", (int) (it2 - options.begin())); + - lambda: |- + auto &options = id(select_test_select).traits.get_options(); + for (size_t i = 0; i < options.size(); i++) + ESP_LOGD("test", "%u: %s", (unsigned) i, options.at(i)); From e4be4c7fcbda0e8f1dfcf848e030dd79fc8aeecb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 18:30:45 -0500 Subject: [PATCH 011/128] [select] Keep select.is option lists in flash (#20108) --- esphome/components/select/__init__.py | 10 +--- tests/component_tests/select/__init__.py | 0 .../select/config/select_is.yaml | 52 +++++++++++++++++++ .../component_tests/select/test_select_is.py | 22 ++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/select/__init__.py create mode 100644 tests/component_tests/select/config/select_is.yaml create mode 100644 tests/component_tests/select/test_select_is.py diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index ae78e729e6e..fec88d2bfd1 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -187,14 +187,8 @@ automation.register_apply_action( async def select_is_to_code(config, condition_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) if options := config.get(CONF_OPTIONS): - # List of constant options - # Create a constexpr and pass that with a template length - arr_id = ID( - f"{condition_id}_data", - is_declaration=True, - type=global_ns.namespace("constexpr char * const"), - ) - arg = cg.static_const_array(arr_id, cg.ArrayInitializer(*options)) + # Shared flash table of option pointers, length passed as a template argument + arg = cg.shared_progmem_array("select_is_options", cg.const_char_ptr, options) template_arg = TemplateArguments(len(options), *template_arg) else: # Lambda diff --git a/tests/component_tests/select/__init__.py b/tests/component_tests/select/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/select/config/select_is.yaml b/tests/component_tests/select/config/select_is.yaml new file mode 100644 index 00000000000..3bc3bdcc022 --- /dev/null +++ b/tests/component_tests/select/config/select_is.yaml @@ -0,0 +1,52 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +select: + - platform: template + id: mode + optimistic: true + options: [one, two, three] + +button: + - platform: template + name: A + on_press: + - if: + condition: + select.is: + id: mode + options: [one, two] + then: + - logger.log: a + - platform: template + name: B + on_press: + - if: + condition: + select.is: + id: mode + options: [one, two] + then: + - logger.log: b + - platform: template + name: C + on_press: + - if: + condition: + select.is: + id: mode + options: three + then: + - logger.log: c + - if: + condition: + select.is: + id: mode + lambda: return current == "two"; + then: + - logger.log: d + +logger: diff --git a/tests/component_tests/select/test_select_is.py b/tests/component_tests/select/test_select_is.py new file mode 100644 index 00000000000..6c179c3c853 --- /dev/null +++ b/tests/component_tests/select/test_select_is.py @@ -0,0 +1,22 @@ +"""select.is option lists live in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_select_is_options_use_shared_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("select_is.yaml")) + + tables = re.findall( + r"static constexpr const char \* (select_is_options\w*)\[\] PROGMEM = \{([^;]*)\};", + main_cpp, + ) + # Two conditions with [one, two] share one table; [three] gets its own + assert sorted(body for _, body in tables) == ['"one", "two"', '"three"'] + one_two = next(name for name, body in tables if body == '"one", "two"') + assert main_cpp.count(f", {one_two});") == 2 + assert "SelectIsCondition<0" in main_cpp From 60e511282ac5e0464f98aaf0562e1084e95de952 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 19:45:18 -0500 Subject: [PATCH 012/128] [udp] Keep addresses in a shared flash table (#20085) --- esphome/components/udp/__init__.py | 10 ++++++- esphome/components/udp/udp_component.cpp | 9 ++++-- esphome/components/udp/udp_component.h | 7 ++--- tests/component_tests/udp/__init__.py | 0 .../udp/config/udp_addresses.yaml | 14 +++++++++ .../udp/config/udp_no_addresses.yaml | 12 ++++++++ .../component_tests/udp/test_udp_addresses.py | 29 +++++++++++++++++++ 7 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/udp/__init__.py create mode 100644 tests/component_tests/udp/config/udp_addresses.yaml create mode 100644 tests/component_tests/udp/config/udp_no_addresses.yaml create mode 100644 tests/component_tests/udp/test_udp_addresses.py diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index d96a731e9c0..316d4fc311e 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -124,7 +124,15 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_broadcast_port(conf_port[CONF_BROADCAST_PORT])) if (listen_address := str(config[CONF_LISTEN_ADDRESS])) != "255.255.255.255": cg.add(var.set_listen_address(listen_address)) - cg.add(var.set_addresses([str(addr) for addr in config[CONF_ADDRESSES]])) + # Shared flash table ended by nullptr, so the component stores only a pointer. + if addresses := [str(addr) for addr in config[CONF_ADDRESSES]]: + cg.add( + var.set_addresses( + cg.shared_progmem_array( + "udp_addresses", cg.const_char_ptr, [*addresses, cg.nullptr] + ) + ) + ) for conf in config.get(CONF_ON_RECEIVE, []): trigger_id = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) trigger = await automation.build_automation(trigger_id, trigger_argtype, conf) diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index f662cba4cfb..904ed97dc0d 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -11,7 +11,8 @@ static const char *const TAG = "udp"; void UDPComponent::setup() { #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - for (const auto &address : this->addresses_) { + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { + const char *address = *it; struct sockaddr saddr {}; if (socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_) == 0) { ESP_LOGW(TAG, "Invalid address %s", address); @@ -97,7 +98,8 @@ void UDPComponent::setup() { #endif #ifdef USE_SOCKET_IMPL_LWIP_TCP // 8266 and RP2040 `Duino - for (const auto &address : this->addresses_) { + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { + const char *address = *it; auto ipaddr = IPAddress(); if (!ipaddr.fromString(address)) { ESP_LOGW(TAG, "Invalid address %s", address); @@ -138,7 +140,8 @@ void UDPComponent::dump_config() { " Listen Port: %u\n" " Broadcast Port: %u", this->listen_port_, this->broadcast_port_); - for (const char *address : this->addresses_) { + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { + const char *address = *it; ESP_LOGCONFIG(TAG, " Address: %s", address); } if (this->listen_address_.has_value()) { diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 274e0119ee5..17e27fb7704 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -20,9 +20,8 @@ namespace esphome::udp { static const size_t MAX_PACKET_SIZE = 508; class UDPComponent final : public Component { public: - void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } - /// Prevent accidental use of std::string which would dangle - void set_addresses(std::initializer_list addresses) = delete; + /// nullptr-terminated table of addresses; must outlive the component. + void set_addresses(const char *const *addresses) { this->addresses_ = addresses; } void set_listen_address(const char *listen_addr) { this->listen_address_ = network::IPAddress(listen_addr); } void set_listen_port(uint16_t port) { this->listen_port_ = port; } void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } @@ -52,7 +51,7 @@ class UDPComponent final : public Component { std::vector ipaddrs_{}; WiFiUDP udp_client_{}; #endif - FixedVector addresses_{}; + const char *const *addresses_{nullptr}; optional listen_address_{}; }; diff --git a/tests/component_tests/udp/__init__.py b/tests/component_tests/udp/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/udp/config/udp_addresses.yaml b/tests/component_tests/udp/config/udp_addresses.yaml new file mode 100644 index 00000000000..405c863e101 --- /dev/null +++ b/tests/component_tests/udp/config/udp_addresses.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: password1 + +udp: + addresses: + - 10.0.0.1 + - 10.0.0.2 diff --git a/tests/component_tests/udp/config/udp_no_addresses.yaml b/tests/component_tests/udp/config/udp_no_addresses.yaml new file mode 100644 index 00000000000..d9d3a53a655 --- /dev/null +++ b/tests/component_tests/udp/config/udp_no_addresses.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: password1 + +udp: + addresses: [] diff --git a/tests/component_tests/udp/test_udp_addresses.py b/tests/component_tests/udp/test_udp_addresses.py new file mode 100644 index 00000000000..434ef02dcff --- /dev/null +++ b/tests/component_tests/udp/test_udp_addresses.py @@ -0,0 +1,29 @@ +"""Tests for the udp component codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_addresses_are_a_progmem_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Addresses live in a nullptr-terminated flash table instead of a heap vector.""" + main_cpp = generate_main(component_config_path("udp_addresses.yaml")) + + assert ( + "static constexpr const char * udp_addresses[] PROGMEM = " + '{"10.0.0.1", "10.0.0.2", nullptr};' in main_cpp + ) + assert "->set_addresses(udp_addresses);" in main_cpp + + +def test_empty_addresses_emit_no_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """An empty address list leaves the pointer null instead of emitting a table.""" + main_cpp = generate_main(component_config_path("udp_no_addresses.yaml")) + + assert "udp_addresses" not in main_cpp + assert "set_addresses(" not in main_cpp From 66eec7af15eba41697eca4eb33c0508ab818e295 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 19:58:35 -0500 Subject: [PATCH 013/128] [wifi] Keep a second fallback AP start from resetting the LN882x (#20168) --- esphome/components/wifi/wifi_component_libretiny.cpp | 12 ++++++++++++ tests/components/wifi/test-ap.ln882x-ard.yaml | 9 +++++++++ 2 files changed, 21 insertions(+) create mode 100644 tests/components/wifi/test-ap.ln882x-ard.yaml diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 940f2a07830..1d9fda6d2f0 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -28,6 +28,11 @@ extern "C" { } #endif +#ifdef USE_LN882X +#include +#undef DATA // clashes with setup_priority::DATA +#endif + #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -770,6 +775,13 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { yield(); +#ifdef USE_LN882X + // LibreTiny's LN882H softAP() points ap.bssid at a static array and frees it + // on the next call, tripping FreeRTOS's configASSERT until the watchdog + // resets the chip. Clear it so that a second AP start frees nothing. + static_cast(WiFi.data)->ap.bssid = nullptr; +#endif + return WiFi.softAP(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), ap.has_channel() ? ap.get_channel() : 1, ap.get_hidden()); } diff --git a/tests/components/wifi/test-ap.ln882x-ard.yaml b/tests/components/wifi/test-ap.ln882x-ard.yaml new file mode 100644 index 00000000000..6a38eb5f274 --- /dev/null +++ b/tests/components/wifi/test-ap.ln882x-ard.yaml @@ -0,0 +1,9 @@ +wifi: + networks: + - ssid: MySSID + password: password1 + ap: + ssid: Fallback Hotspot + password: password2 + +captive_portal: From 8b9d51cf93972a9a94f2abf0010fcbcc983a1bb8 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:21:46 -0500 Subject: [PATCH 014/128] Bump bundled esphome-device-builder to 1.20.0 (#20172) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 8d3a5d2cc50..50cb8e137d4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.19.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.20.0 RUN \ platformio settings set enable_telemetry No \ From 6f2d2a2001556ad25c6b824851eac85f16207893 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 20:53:47 -0500 Subject: [PATCH 015/128] [libretiny] Fix LN882x boot crash with the logger or a uart on UART0 (#20162) --- esphome/components/libretiny/core.cpp | 24 +++++++++++++++++-- esphome/components/libretiny/core.h | 17 ++++++++++++- .../components/logger/logger_libretiny.cpp | 5 ++++ .../uart/uart_component_libretiny.cpp | 4 ++++ .../logger/test-uart0.ln882x-ard.yaml | 2 ++ tests/components/uart/test.ln882x-ard.yaml | 14 +++++++++++ 6 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/components/logger/test-uart0.ln882x-ard.yaml create mode 100644 tests/components/uart/test.ln882x-ard.yaml diff --git a/esphome/components/libretiny/core.cpp b/esphome/components/libretiny/core.cpp index 8686a41e646..1f5de35d2cb 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -1,6 +1,26 @@ #ifdef USE_LIBRETINY -// HAL functions live in hal.cpp. core.cpp is intentionally empty for -// libretiny — there is no extra component bootstrap to keep here. +// HAL functions live in hal.cpp; this file only holds the serial port +// workaround below. + +#include "core.h" + +#ifdef USE_LN882X +#include + +// The SDK's open port handles (components/serial/serial.c); not in its header. +extern "C" Serial_t *serial_handles[SER_PORT_NUM]; + +namespace esphome::libretiny { + +void ensure_serial_handle(uint8_t port) { + if (port >= SER_PORT_NUM || serial_handles[port] != nullptr) + return; + // serial_init() zeroes and opens it; the port stays open for good. + serial_handles[port] = new Serial_t(); +} + +} // namespace esphome::libretiny +#endif // USE_LN882X #endif // USE_LIBRETINY diff --git a/esphome/components/libretiny/core.h b/esphome/components/libretiny/core.h index f909db4f0f8..8f6760c0fc1 100644 --- a/esphome/components/libretiny/core.h +++ b/esphome/components/libretiny/core.h @@ -4,6 +4,21 @@ #include -namespace esphome::libretiny {} // namespace esphome::libretiny +namespace esphome::libretiny { + +#ifdef USE_LN882X +/// Give a hardware UART its SDK handle before its Serial begin()s. +/// +/// LibreTiny's LN882H Serial passes the SDK's per port handle to +/// serial_init(), which fills it in, but only the default log port (UART1) +/// has one at boot. Any other port hands it NULL and the chip faults before +/// setup, so a logger or uart on UART0 never boots. Drop this once LibreTiny +/// gives each port storage of its own. +void ensure_serial_handle(uint8_t port); +#else +inline void ensure_serial_handle(uint8_t /*port*/) {} +#endif + +} // namespace esphome::libretiny #endif // USE_LIBRETINY diff --git a/esphome/components/logger/logger_libretiny.cpp b/esphome/components/logger/logger_libretiny.cpp index bc3922c436a..9a322f751df 100644 --- a/esphome/components/logger/logger_libretiny.cpp +++ b/esphome/components/logger/logger_libretiny.cpp @@ -1,6 +1,8 @@ #ifdef USE_LIBRETINY #include "logger.h" +#include "esphome/components/libretiny/core.h" + namespace esphome::logger { static const char *const TAG = "logger"; @@ -11,18 +13,21 @@ void Logger::pre_setup() { #if LT_HW_UART0 case UART_SELECTION_UART0: this->hw_serial_ = &Serial0; + libretiny::ensure_serial_handle(0); Serial0.begin(this->baud_rate_); break; #endif #if LT_HW_UART1 case UART_SELECTION_UART1: this->hw_serial_ = &Serial1; + libretiny::ensure_serial_handle(1); Serial1.begin(this->baud_rate_); break; #endif #if LT_HW_UART2 case UART_SELECTION_UART2: this->hw_serial_ = &Serial2; + libretiny::ensure_serial_handle(2); Serial2.begin(this->baud_rate_); break; #endif diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 4eacd980db3..293cb9e786f 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -6,6 +6,8 @@ #include "esphome/core/log.h" #include "uart_component_libretiny.h" +#include "esphome/components/libretiny/core.h" + #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -118,6 +120,8 @@ void LibreTinyUARTComponent::setup() { #endif } + if (this->hardware_idx_ >= 0) + libretiny::ensure_serial_handle(this->hardware_idx_); this->serial_->begin(this->baud_rate_, get_config()); } diff --git a/tests/components/logger/test-uart0.ln882x-ard.yaml b/tests/components/logger/test-uart0.ln882x-ard.yaml new file mode 100644 index 00000000000..864d2b96dea --- /dev/null +++ b/tests/components/logger/test-uart0.ln882x-ard.yaml @@ -0,0 +1,2 @@ +logger: + hardware_uart: UART0 diff --git a/tests/components/uart/test.ln882x-ard.yaml b/tests/components/uart/test.ln882x-ard.yaml new file mode 100644 index 00000000000..ccb4edfe405 --- /dev/null +++ b/tests/components/uart/test.ln882x-ard.yaml @@ -0,0 +1,14 @@ +uart: + - id: uart_id + tx_pin: PA2 + rx_pin: PA3 + baud_rate: 9600 + data_bits: 8 + parity: NONE + stop_bits: 1 + +switch: + - platform: uart + name: "UART Switch" + uart_id: uart_id + data: [0x01, 0x02, 0x03] From d9b3f299e0c893e281ec646a2c5361e8a781756d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:19:12 +1100 Subject: [PATCH 016/128] [exponential_moving_average] Add EMA preserved across reboots (#19882) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../exponential_moving_average/__init__.py | 1 + .../exponential_moving_average_sensor.cpp | 116 ++++++ .../exponential_moving_average_sensor.h | 61 +++ .../exponential_moving_average/sensor.py | 103 +++++ .../exponential_moving_average/__init__.py | 0 .../exponential_moving_average_test.yaml | 40 ++ .../test_exponential_moving_average.py | 166 ++++++++ .../exponential_moving_average/common.h | 52 +++ .../exponential_moving_average/common.yaml | 25 ++ .../test_exponential_moving_average.cpp | 356 ++++++++++++++++++ .../test.esp32-idf.yaml | 2 + 12 files changed, 923 insertions(+) create mode 100644 esphome/components/exponential_moving_average/__init__.py create mode 100644 esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp create mode 100644 esphome/components/exponential_moving_average/exponential_moving_average_sensor.h create mode 100644 esphome/components/exponential_moving_average/sensor.py create mode 100644 tests/component_tests/exponential_moving_average/__init__.py create mode 100644 tests/component_tests/exponential_moving_average/config/exponential_moving_average_test.yaml create mode 100644 tests/component_tests/exponential_moving_average/test_exponential_moving_average.py create mode 100644 tests/components/exponential_moving_average/common.h create mode 100644 tests/components/exponential_moving_average/common.yaml create mode 100644 tests/components/exponential_moving_average/sensor/test_exponential_moving_average.cpp create mode 100644 tests/components/exponential_moving_average/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index b453cf45389..80f243e82e7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -191,6 +191,7 @@ esphome/components/espnow/* @jesserockz esphome/components/espnow/packet_transport/* @EasilyBoredEngineer esphome/components/ethernet_info/* @gtjadsonsantos esphome/components/event/* @nohat +esphome/components/exponential_moving_average/* @clydebarrow esphome/components/exposure_notifications/* @OttoWinter esphome/components/ezo/* @ssieb esphome/components/ezo_pmp/* @carlos-sarmiento diff --git a/esphome/components/exponential_moving_average/__init__.py b/esphome/components/exponential_moving_average/__init__.py new file mode 100644 index 00000000000..c58ce8a01e8 --- /dev/null +++ b/esphome/components/exponential_moving_average/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp new file mode 100644 index 00000000000..243e2cf31e1 --- /dev/null +++ b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp @@ -0,0 +1,116 @@ +#include "exponential_moving_average_sensor.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::exponential_moving_average { + +static const char *const TAG = "exponential_moving_average"; + +const LogString *time_weighting_to_string(TimeWeighting weighting) { + switch (weighting) { + case TIME_WEIGHTING_PREVIOUS: + return LOG_STR("previous"); + case TIME_WEIGHTING_LINEAR: + return LOG_STR("linear"); + default: + return LOG_STR("new"); + } +} + +ScaledDuration scale_duration(uint32_t ms) { + if (ms < 1000) + return {static_cast(ms), LOG_STR("ms"), 0}; + if (ms < 60 * 1000) + return {ms / 1000.0f, LOG_STR("s"), 1}; + if (ms < 60 * 60 * 1000) + return {ms / (60 * 1000.0f), LOG_STR("min"), 1}; + return {ms / (60 * 60 * 1000.0f), LOG_STR("h"), 1}; +} + +void ExponentialMovingAverageSensor::setup() { + if (this->restore_) { + this->pref_ = this->make_entity_preference(); + float restored; + if (this->pref_.load(&restored) && std::isfinite(restored)) { + this->accumulator_ = restored; + this->publish_state(restored); + } + } + const uint32_t now = App.get_loop_component_start_time(); + this->last_update_ = now; + this->source_->add_on_state_callback( + [this](float value) { this->process_(value, App.get_loop_component_start_time()); }); + // The source may have published during its own setup(), before the callback was added. + if (this->source_->has_state()) + this->process_(this->source_->state, now); +} + +void ExponentialMovingAverageSensor::dump_config() { + LOG_SENSOR("", "Exponential Moving Average Sensor", this); + if (this->time_constant_ms_ != 0) { + const ScaledDuration time_constant = scale_duration(this->time_constant_ms_); + ESP_LOGCONFIG(TAG, + " Time Constant: %.*f %s\n" + " Time Weighting: %s", + time_constant.decimals, time_constant.value, LOG_STR_ARG(time_constant.unit), + LOG_STR_ARG(time_weighting_to_string(this->time_weighting_))); + } else { + ESP_LOGCONFIG(TAG, " Alpha: %.3f", this->alpha_); + } + ESP_LOGCONFIG(TAG, " Restore: %s", YESNO(this->restore_)); +} + +void ExponentialMovingAverageSensor::reset() { this->publish_and_save_(NAN); } + +void ExponentialMovingAverageSensor::process_(float value, uint32_t now) { + if (std::isnan(value)) + return; + // After a reboot the downtime is unknown, so the first interval is measured from setup(). + const uint32_t dt = now - this->last_update_; + this->last_update_ = now; + const float previous = this->previous_value_; + this->previous_value_ = value; + if (std::isnan(this->accumulator_)) { + this->publish_and_save_(value); + return; + } + if (this->time_constant_ms_ == 0) { + this->publish_and_save_(this->alpha_ * value + (1.0f - this->alpha_) * this->accumulator_); + return; + } + // Computed in double with expm1(): when the interval is short compared to the time constant, the weights are + // tiny and float rounding of exp() would swamp them. + const double x = static_cast(dt) / this->time_constant_ms_; + // The share of the old average replaced during this interval. + const double gain = -std::expm1(-x); + const double average = this->accumulator_; + // After a reboot there is no previous reading, so only the new value can be used. + const TimeWeighting weighting = std::isnan(previous) ? TIME_WEIGHTING_NEW : this->time_weighting_; + double result; + switch (weighting) { + case TIME_WEIGHTING_PREVIOUS: + result = average + gain * (previous - average); + break; + case TIME_WEIGHTING_LINEAR: { + // Exact result for a value moving in a straight line from the previous reading to the new one. + const double weight_new = x > 0.0 ? (x + std::expm1(-x)) / x : 0.0; + result = average + (gain - weight_new) * (previous - average) + weight_new * (value - average); + break; + } + default: + result = average + gain * (value - average); + break; + } + this->publish_and_save_(static_cast(result)); +} + +void ExponentialMovingAverageSensor::publish_and_save_(float value) { + this->accumulator_ = value; + this->publish_state(value); + if (this->restore_) + this->pref_.save(&value); +} + +} // namespace esphome::exponential_moving_average diff --git a/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h new file mode 100644 index 00000000000..98bd88553bb --- /dev/null +++ b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::exponential_moving_average { + +/// Which value is assumed to apply during the time between two readings, when a time constant is used. +enum TimeWeighting : uint8_t { + TIME_WEIGHTING_NEW = 0, + TIME_WEIGHTING_PREVIOUS, + TIME_WEIGHTING_LINEAR, +}; + +const LogString *time_weighting_to_string(TimeWeighting weighting); + +/// A duration in the largest of ms, s, min or h that keeps the value at 1 or more. +struct ScaledDuration { + float value; + const LogString *unit; + uint8_t decimals; +}; + +ScaledDuration scale_duration(uint32_t ms); + +class ExponentialMovingAverageSensor : public sensor::Sensor, public Component { + public: + explicit ExponentialMovingAverageSensor(sensor::Sensor *source) : source_(source) {} + + void setup() override; + void dump_config() override; + + void set_alpha(float alpha) { this->alpha_ = alpha; } + /// When non-zero, each sample is weighted by the time since the previous one instead of by a fixed alpha. + void set_time_constant(uint32_t time_constant_ms) { this->time_constant_ms_ = time_constant_ms; } + void set_time_weighting(TimeWeighting weighting) { this->time_weighting_ = weighting; } + void set_restore(bool restore) { this->restore_ = restore; } + /// Clear the average; the next sample starts it again. + void reset(); + + protected: + void process_(float value, uint32_t now); + void publish_and_save_(float value); + + sensor::Sensor *source_; + ESPPreferenceObject pref_; + float alpha_{0.1f}; + float accumulator_{NAN}; + float previous_value_{NAN}; + uint32_t time_constant_ms_{0}; + uint32_t last_update_{0}; + TimeWeighting time_weighting_{TIME_WEIGHTING_NEW}; + bool restore_{true}; +}; + +} // namespace esphome::exponential_moving_average diff --git a/esphome/components/exponential_moving_average/sensor.py b/esphome/components/exponential_moving_average/sensor.py new file mode 100644 index 00000000000..20c8135636d --- /dev/null +++ b/esphome/components/exponential_moving_average/sensor.py @@ -0,0 +1,103 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_ALPHA, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_ID, + CONF_RESTORE, + CONF_SENSOR, + CONF_STATE_CLASS, + CONF_TIME_CONSTANT, + CONF_UNIT_OF_MEASUREMENT, +) +from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType + +exponential_moving_average_ns = cg.esphome_ns.namespace("exponential_moving_average") +ExponentialMovingAverageSensor = exponential_moving_average_ns.class_( + "ExponentialMovingAverageSensor", sensor.Sensor, cg.Component +) + +TimeWeighting = exponential_moving_average_ns.enum("TimeWeighting") +TIME_WEIGHTINGS: dict[str, cg.MockObj] = { + "new": TimeWeighting.TIME_WEIGHTING_NEW, + "previous": TimeWeighting.TIME_WEIGHTING_PREVIOUS, + "linear": TimeWeighting.TIME_WEIGHTING_LINEAR, +} + +CONF_TIME_WEIGHTING: str = "time_weighting" + +DEFAULT_ALPHA: float = 0.1 + + +def inherit_accuracy_decimals(decimals: int, config: ConfigType) -> int: + # An average carries more precision than the individual readings. + return decimals + 1 + + +def validate_time_weighting(config: ConfigType) -> ConfigType: + if CONF_TIME_WEIGHTING in config and CONF_TIME_CONSTANT not in config: + raise cv.Invalid( + f"'{CONF_TIME_WEIGHTING}' can only be used with '{CONF_TIME_CONSTANT}'", + path=[CONF_TIME_WEIGHTING], + ) + return config + + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema(ExponentialMovingAverageSensor) + .extend( + { + cv.Required(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_ALPHA): cv.All( + cv.float_, cv.Range(min=0, min_included=False, max=1) + ), + cv.Optional(CONF_TIME_CONSTANT): cv.positive_time_period_milliseconds, + cv.Optional(CONF_TIME_WEIGHTING): cv.enum(TIME_WEIGHTINGS, lower=True), + cv.Optional(CONF_RESTORE, default=True): cv.boolean, + } + ) + .extend(cv.COMPONENT_SCHEMA), + cv.has_at_most_one_key(CONF_ALPHA, CONF_TIME_CONSTANT), + validate_time_weighting, +) + +FINAL_VALIDATE_SCHEMA = cv.All( + inherit_property_from(CONF_ICON, CONF_SENSOR), + inherit_property_from(CONF_UNIT_OF_MEASUREMENT, CONF_SENSOR), + inherit_property_from( + CONF_ACCURACY_DECIMALS, CONF_SENSOR, transform=inherit_accuracy_decimals + ), + inherit_property_from(CONF_DEVICE_CLASS, CONF_SENSOR), + inherit_property_from(CONF_STATE_CLASS, CONF_SENSOR), +) + + +async def to_code(config: ConfigType) -> None: + source = await cg.get_variable(config[CONF_SENSOR]) + var = cg.new_Pvariable(config[CONF_ID], source) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + + if (time_constant := config.get(CONF_TIME_CONSTANT)) is not None: + cg.add(var.set_time_constant(time_constant)) + if (weighting := config.get(CONF_TIME_WEIGHTING)) is not None: + cg.add(var.set_time_weighting(weighting)) + else: + cg.add(var.set_alpha(config.get(CONF_ALPHA, DEFAULT_ALPHA))) + cg.add(var.set_restore(config[CONF_RESTORE])) + + +automation.register_apply_action( + "sensor.exponential_moving_average.reset", + automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(ExponentialMovingAverageSensor), + } + ), + automation.ApplyCall("reset()"), +) diff --git a/tests/component_tests/exponential_moving_average/__init__.py b/tests/component_tests/exponential_moving_average/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/exponential_moving_average/config/exponential_moving_average_test.yaml b/tests/component_tests/exponential_moving_average/config/exponential_moving_average_test.yaml new file mode 100644 index 00000000000..88735777d2e --- /dev/null +++ b/tests/component_tests/exponential_moving_average/config/exponential_moving_average_test.yaml @@ -0,0 +1,40 @@ +esphome: + name: test + on_boot: + then: + - sensor.exponential_moving_average.reset: ema_default + +esp32: + board: esp32dev + +sensor: + - platform: template + id: source_sensor + unit_of_measurement: "°C" + accuracy_decimals: 1 + device_class: temperature + state_class: measurement + lambda: return 1.0; + - platform: exponential_moving_average + id: ema_default + name: EMA Default + sensor: source_sensor + - platform: exponential_moving_average + id: ema_alpha + name: EMA Alpha + sensor: source_sensor + alpha: 0.25 + unit_of_measurement: "K" + accuracy_decimals: 3 + - platform: exponential_moving_average + id: ema_time_constant + name: EMA Time Constant + sensor: source_sensor + time_constant: 5min + restore: false + - platform: exponential_moving_average + id: ema_linear + name: EMA Linear + sensor: source_sensor + time_constant: 30s + time_weighting: linear diff --git a/tests/component_tests/exponential_moving_average/test_exponential_moving_average.py b/tests/component_tests/exponential_moving_average/test_exponential_moving_average.py new file mode 100644 index 00000000000..fea5dddb00c --- /dev/null +++ b/tests/component_tests/exponential_moving_average/test_exponential_moving_average.py @@ -0,0 +1,166 @@ +"""Tests for the exponential_moving_average sensor.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.exponential_moving_average.sensor import CONFIG_SCHEMA + + +def test_default_alpha_and_restore( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Without alpha or time_constant, alpha defaults to 0.1 and restore is on.""" + main_cpp = generate_main( + component_config_path("exponential_moving_average_test.yaml") + ) + + assert ( + "new(ema_default) exponential_moving_average::ExponentialMovingAverageSensor(source_sensor);" + in main_cpp + ) + assert "ema_default->set_alpha(0.1f);" in main_cpp + assert "ema_default->set_restore(true);" in main_cpp + + +def test_alpha( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main( + component_config_path("exponential_moving_average_test.yaml") + ) + + assert "ema_alpha->set_alpha(0.25f);" in main_cpp + assert "ema_alpha->set_time_constant" not in main_cpp + + +def test_time_constant_replaces_alpha( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main( + component_config_path("exponential_moving_average_test.yaml") + ) + + assert "ema_time_constant->set_time_constant(300000);" in main_cpp + assert "ema_time_constant->set_alpha" not in main_cpp + assert "ema_time_constant->set_restore(false);" in main_cpp + assert "ema_time_constant->set_time_weighting" not in main_cpp + + +def test_time_weighting( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main( + component_config_path("exponential_moving_average_test.yaml") + ) + + assert "ema_linear->set_time_constant(30000);" in main_cpp + assert ( + "ema_linear->set_time_weighting(exponential_moving_average::TIME_WEIGHTING_LINEAR);" + in main_cpp + ) + + +def test_properties_inherited_from_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Unset properties come from the source sensor, with one extra decimal; set ones are kept.""" + main_cpp = generate_main( + component_config_path("exponential_moving_average_test.yaml") + ) + + assert "ema_default->set_accuracy_decimals(2);" in main_cpp + assert "ema_alpha->set_accuracy_decimals(3);" in main_cpp + default_line = next( + line for line in main_cpp.splitlines() if '"EMA Default"' in line + ) + alpha_line = next(line for line in main_cpp.splitlines() if '"EMA Alpha"' in line) + assert "°C" in default_line + assert "temperature" in default_line + assert "K" in alpha_line + + +def test_reset_action( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main( + component_config_path("exponential_moving_average_test.yaml") + ) + + assert "::ema_default->reset();" in main_cpp + + +def test_alpha_and_time_constant_are_exclusive() -> None: + with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): + CONFIG_SCHEMA( + { + "id": "ema", + "name": "EMA", + "sensor": "source", + "alpha": 0.5, + "time_constant": "1min", + } + ) + + +@pytest.mark.parametrize("alpha", [0, -0.1, 1.5]) +def test_alpha_out_of_range(alpha: float) -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"id": "ema", "name": "EMA", "sensor": "source", "alpha": alpha}) + + +@pytest.mark.parametrize("alpha", [0.01, 1]) +def test_alpha_in_range(alpha: float) -> None: + config = CONFIG_SCHEMA( + {"id": "ema", "name": "EMA", "sensor": "source", "alpha": alpha} + ) + + assert config["alpha"] == alpha + + +def test_time_weighting_requires_time_constant() -> None: + with pytest.raises(cv.Invalid, match="can only be used with 'time_constant'"): + CONFIG_SCHEMA( + { + "id": "ema", + "name": "EMA", + "sensor": "source", + "time_weighting": "previous", + } + ) + + +@pytest.mark.parametrize("weighting", ["new", "previous", "linear", "LINEAR"]) +def test_time_weighting_values(weighting: str) -> None: + config = CONFIG_SCHEMA( + { + "id": "ema", + "name": "EMA", + "sensor": "source", + "time_constant": "1min", + "time_weighting": weighting, + } + ) + + assert config["time_weighting"] == weighting.lower() + + +def test_time_weighting_rejects_unknown_value() -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA( + { + "id": "ema", + "name": "EMA", + "sensor": "source", + "time_constant": "1min", + "time_weighting": "trapezoid", + } + ) diff --git a/tests/components/exponential_moving_average/common.h b/tests/components/exponential_moving_average/common.h new file mode 100644 index 00000000000..599c36bb0d5 --- /dev/null +++ b/tests/components/exponential_moving_average/common.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include "esphome/components/exponential_moving_average/exponential_moving_average_sensor.h" +#include "esphome/core/preferences.h" +#ifdef USE_HOST +#include "esphome/components/host/preferences.h" +#endif + +namespace esphome::exponential_moving_average::testing { + +class TestableExponentialMovingAverageSensor : public ExponentialMovingAverageSensor { + public: + using ExponentialMovingAverageSensor::ExponentialMovingAverageSensor; + using ExponentialMovingAverageSensor::process_; +}; + +// Unnamed sensors share one preference key, so a second instance created after +// the first one behaves like the same sensor after a reboot. +class ExponentialMovingAverageTest : public ::testing::Test { + protected: + void SetUp() override { + if (const char *prefdir = getenv("ESPHOME_PREFDIR"); prefdir != nullptr) + this->saved_prefdir_ = prefdir; + // Keep preferences away from the user's home directory. + setenv("ESPHOME_PREFDIR", std::filesystem::temp_directory_path().c_str(), 1); +#ifdef USE_HOST + host::setup_preferences(); +#endif + global_preferences->reset(); + } + + void TearDown() override { + global_preferences->reset(); + if (this->saved_prefdir_.has_value()) { + setenv("ESPHOME_PREFDIR", this->saved_prefdir_->c_str(), 1); + } else { + unsetenv("ESPHOME_PREFDIR"); + } + } + + std::optional saved_prefdir_; + sensor::Sensor source_; +}; + +} // namespace esphome::exponential_moving_average::testing diff --git a/tests/components/exponential_moving_average/common.yaml b/tests/components/exponential_moving_average/common.yaml new file mode 100644 index 00000000000..c3cf9da5f97 --- /dev/null +++ b/tests/components/exponential_moving_average/common.yaml @@ -0,0 +1,25 @@ +sensor: + - platform: template + id: ema_source + name: EMA Source + unit_of_measurement: "°C" + accuracy_decimals: 1 + lambda: return 21.5; + update_interval: 10s + - platform: exponential_moving_average + id: ema_alpha + name: EMA Alpha + sensor: ema_source + alpha: 0.2 + - platform: exponential_moving_average + name: EMA Time Constant + sensor: ema_source + time_constant: 5min + time_weighting: previous + restore: false + +button: + - platform: template + name: EMA Reset + on_press: + - sensor.exponential_moving_average.reset: ema_alpha diff --git a/tests/components/exponential_moving_average/sensor/test_exponential_moving_average.cpp b/tests/components/exponential_moving_average/sensor/test_exponential_moving_average.cpp new file mode 100644 index 00000000000..d6013749905 --- /dev/null +++ b/tests/components/exponential_moving_average/sensor/test_exponential_moving_average.cpp @@ -0,0 +1,356 @@ +#include + +#include "../common.h" + +namespace esphome::exponential_moving_average::testing { + +TEST_F(ExponentialMovingAverageTest, FirstValueStartsTheAverage) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.setup(); + EXPECT_FALSE(ema.has_state()); + + ema.process_(10.0f, 0); + EXPECT_FLOAT_EQ(ema.state, 10.0f); +} + +TEST_F(ExponentialMovingAverageTest, AlphaWeightsEachValue) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_alpha(0.5f); + ema.setup(); + + ema.process_(10.0f, 0); + ema.process_(20.0f, 0); + EXPECT_FLOAT_EQ(ema.state, 15.0f); + ema.process_(20.0f, 0); + EXPECT_FLOAT_EQ(ema.state, 17.5f); +} + +TEST_F(ExponentialMovingAverageTest, NanValuesAreIgnored) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_alpha(0.5f); + ema.setup(); + + ema.process_(10.0f, 0); + ema.process_(NAN, 0); + EXPECT_FLOAT_EQ(ema.state, 10.0f); + ema.process_(20.0f, 0); + EXPECT_FLOAT_EQ(ema.state, 15.0f); +} + +TEST_F(ExponentialMovingAverageTest, FollowsSourceSensor) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_alpha(0.25f); + ema.setup(); + + this->source_.publish_state(8.0f); + this->source_.publish_state(0.0f); + EXPECT_FLOAT_EQ(ema.state, 6.0f); +} + +TEST_F(ExponentialMovingAverageTest, TimeConstantWeightsByElapsedTime) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_time_constant(1000); + ema.setup(); + + ema.process_(0.0f, 0); + ema.process_(1.0f, 1000); + EXPECT_NEAR(ema.state, 1.0f - std::exp(-1.0f), 1e-5f); +} + +TEST_F(ExponentialMovingAverageTest, TimeConstantResultDoesNotDependOnSampleRate) { + TestableExponentialMovingAverageSensor fast(&this->source_); + fast.set_time_constant(1000); + fast.set_restore(false); + fast.setup(); + fast.process_(0.0f, 0); + for (uint32_t t = 100; t <= 1000; t += 100) + fast.process_(1.0f, t); + + TestableExponentialMovingAverageSensor slow(&this->source_); + slow.set_time_constant(1000); + slow.set_restore(false); + slow.setup(); + slow.process_(0.0f, 0); + slow.process_(1.0f, 1000); + + EXPECT_NEAR(fast.state, slow.state, 1e-5f); +} + +TEST_F(ExponentialMovingAverageTest, TimeConstantIgnoresRepeatAtSameTime) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_time_constant(1000); + ema.setup(); + + ema.process_(5.0f, 0); + ema.process_(100.0f, 0); + EXPECT_FLOAT_EQ(ema.state, 5.0f); +} + +TEST_F(ExponentialMovingAverageTest, TimeConstantHandlesTimerWraparound) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_time_constant(1000); + ema.setup(); + + ema.process_(0.0f, UINT32_MAX - 499); + ema.process_(1.0f, 500); + EXPECT_NEAR(ema.state, 1.0f - std::exp(-1.0f), 1e-5f); +} + +TEST_F(ExponentialMovingAverageTest, ResetStartsANewAverage) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_alpha(0.5f); + ema.setup(); + + ema.process_(10.0f, 0); + ema.reset(); + EXPECT_TRUE(std::isnan(ema.state)); + ema.process_(40.0f, 0); + EXPECT_FLOAT_EQ(ema.state, 40.0f); +} + +TEST_F(ExponentialMovingAverageTest, AverageIsRestoredAfterReboot) { + { + TestableExponentialMovingAverageSensor before(&this->source_); + before.set_alpha(0.5f); + before.setup(); + before.process_(10.0f, 0); + before.process_(20.0f, 0); + } + + TestableExponentialMovingAverageSensor after(&this->source_); + after.set_alpha(0.5f); + after.setup(); + ASSERT_TRUE(after.has_state()); + EXPECT_FLOAT_EQ(after.state, 15.0f); + + // Continues from the restored value rather than starting again. + after.process_(25.0f, 0); + EXPECT_FLOAT_EQ(after.state, 20.0f); +} + +TEST_F(ExponentialMovingAverageTest, NothingRestoredWhenRestoreIsOff) { + { + TestableExponentialMovingAverageSensor before(&this->source_); + before.setup(); + before.process_(10.0f, 0); + } + + TestableExponentialMovingAverageSensor after(&this->source_); + after.set_restore(false); + after.setup(); + EXPECT_FALSE(after.has_state()); + after.process_(30.0f, 0); + EXPECT_FLOAT_EQ(after.state, 30.0f); +} + +TEST_F(ExponentialMovingAverageTest, ResetClearsTheSavedAverage) { + { + TestableExponentialMovingAverageSensor before(&this->source_); + before.setup(); + before.process_(10.0f, 0); + before.reset(); + } + + TestableExponentialMovingAverageSensor after(&this->source_); + after.setup(); + EXPECT_FALSE(after.has_state()); +} + +TEST_F(ExponentialMovingAverageTest, PreviousWeightingCountsGapAtPreviousValue) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_time_constant(1000); + ema.set_time_weighting(TIME_WEIGHTING_PREVIOUS); + ema.setup(); + + // The value stayed at 20 for an hour before changing to 25. + ema.process_(20.0f, 0); + ema.process_(25.0f, 3600000); + EXPECT_FLOAT_EQ(ema.state, 20.0f); + + // The 25 is counted over the following interval. + ema.process_(25.0f, 3601000); + EXPECT_NEAR(ema.state, 20.0f + 5.0f * (1.0f - std::exp(-1.0f)), 1e-4f); +} + +TEST_F(ExponentialMovingAverageTest, NewWeightingCountsGapAtNewValue) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_time_constant(1000); + ema.setup(); + + ema.process_(20.0f, 0); + ema.process_(25.0f, 3600000); + EXPECT_FLOAT_EQ(ema.state, 25.0f); +} + +TEST_F(ExponentialMovingAverageTest, LinearWeightingFollowsStraightLine) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_time_constant(1000); + ema.set_time_weighting(TIME_WEIGHTING_LINEAR); + ema.setup(); + + // An average of a value rising steadily from 0 to 1 over one time constant ends at exp(-1). + ema.process_(0.0f, 0); + ema.process_(1.0f, 1000); + EXPECT_NEAR(ema.state, std::exp(-1.0f), 1e-5f); +} + +TEST_F(ExponentialMovingAverageTest, LinearWeightingMatchesManySmallSteps) { + TestableExponentialMovingAverageSensor coarse(&this->source_); + coarse.set_time_constant(1000); + coarse.set_time_weighting(TIME_WEIGHTING_LINEAR); + coarse.set_restore(false); + coarse.setup(); + coarse.process_(0.0f, 0); + coarse.process_(10.0f, 2000); + + TestableExponentialMovingAverageSensor fine(&this->source_); + fine.set_time_constant(1000); + fine.set_time_weighting(TIME_WEIGHTING_LINEAR); + fine.set_restore(false); + fine.setup(); + fine.process_(0.0f, 0); + for (uint32_t t = 10; t <= 2000; t += 10) + fine.process_(t / 200.0f, t); + + EXPECT_NEAR(coarse.state, fine.state, 1e-3f); +} + +TEST_F(ExponentialMovingAverageTest, LinearWeightingIgnoresRepeatAtSameTime) { + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_time_constant(1000); + ema.set_time_weighting(TIME_WEIGHTING_LINEAR); + ema.setup(); + + ema.process_(5.0f, 0); + ema.process_(100.0f, 0); + EXPECT_FLOAT_EQ(ema.state, 5.0f); +} + +TEST_F(ExponentialMovingAverageTest, FirstValueAfterRebootUsesNewValue) { + { + TestableExponentialMovingAverageSensor before(&this->source_); + before.setup(); + before.process_(10.0f, 0); + } + + // No reading from before the reboot is known, so the new value is used for the first interval. + TestableExponentialMovingAverageSensor after(&this->source_); + after.set_time_constant(1000); + after.set_time_weighting(TIME_WEIGHTING_PREVIOUS); + after.setup(); + after.process_(20.0f, 1000); + EXPECT_NEAR(after.state, 10.0f + 10.0f * (1.0f - std::exp(-1.0f)), 1e-4f); +} + +TEST_F(ExponentialMovingAverageTest, StartsFromSourceThatAlreadyHasAValue) { + sensor::Sensor source; + source.publish_state(12.0f); + + TestableExponentialMovingAverageSensor ema(&source); + ema.set_alpha(0.5f); + ema.set_restore(false); + ema.setup(); + ASSERT_TRUE(ema.has_state()); + EXPECT_FLOAT_EQ(ema.state, 12.0f); + + // The value read at setup is only counted once. + source.publish_state(20.0f); + EXPECT_FLOAT_EQ(ema.state, 16.0f); +} + +TEST_F(ExponentialMovingAverageTest, SourceValueAtSetupBlendsWithRestoredAverage) { + { + TestableExponentialMovingAverageSensor before(&this->source_); + before.setup(); + before.process_(10.0f, 0); + } + + sensor::Sensor source; + source.publish_state(20.0f); + TestableExponentialMovingAverageSensor after(&source); + after.set_alpha(0.5f); + after.setup(); + EXPECT_FLOAT_EQ(after.state, 15.0f); +} + +TEST_F(ExponentialMovingAverageTest, SourceNanAtSetupIsIgnored) { + sensor::Sensor source; + source.publish_state(NAN); + + TestableExponentialMovingAverageSensor ema(&source); + ema.set_restore(false); + ema.setup(); + EXPECT_FALSE(ema.has_state()); +} + +// Reference weights from the Taylor series, accurate for the small ratios used below. +static double series_gain(double x) { return x - x * x / 2 + x * x * x / 6; } +static double series_weight_new(double x) { return x / 2 - x * x / 6 + x * x * x / 24; } + +TEST_F(ExponentialMovingAverageTest, LinearWeightingAccurateWithLongTimeConstant) { + constexpr uint32_t time_constant = 43200000; // 12 hours + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_time_constant(time_constant); + ema.set_time_weighting(TIME_WEIGHTING_LINEAR); + ema.set_restore(false); + ema.setup(); + + const double x = 1000.0 / time_constant; + const double weight_new = series_weight_new(x); + const double weight_previous = series_gain(x) - weight_new; + + ema.process_(0.0f, 0); + ema.process_(10.0f, 1000); + const double expected = weight_new * 10.0; + EXPECT_NEAR(ema.state, expected, expected * 1e-4); + + ema.process_(20.0f, 2000); + const double expected2 = expected + weight_previous * (10.0 - expected) + weight_new * (20.0 - expected); + EXPECT_NEAR(ema.state, expected2, expected2 * 1e-4); +} + +TEST_F(ExponentialMovingAverageTest, VeryShortIntervalStillMovesAverage) { + constexpr uint32_t time_constant = 4 * 24 * 3600000; // 4 days, with a reading on every 16 ms loop + TestableExponentialMovingAverageSensor ema(&this->source_); + ema.set_time_constant(time_constant); + ema.set_restore(false); + ema.setup(); + + ema.process_(0.0f, 0); + ema.process_(1000.0f, 16); + const double expected = series_gain(16.0 / time_constant) * 1000.0; + EXPECT_NEAR(ema.state, expected, expected * 1e-4); +} + +TEST(TimeWeightingTest, Names) { + EXPECT_STREQ(LOG_STR_ARG(time_weighting_to_string(TIME_WEIGHTING_NEW)), "new"); + EXPECT_STREQ(LOG_STR_ARG(time_weighting_to_string(TIME_WEIGHTING_PREVIOUS)), "previous"); + EXPECT_STREQ(LOG_STR_ARG(time_weighting_to_string(TIME_WEIGHTING_LINEAR)), "linear"); +} + +struct ScaleDurationCase { + uint32_t ms; + float value; + const char *unit; + uint8_t decimals; +}; + +class ScaleDurationTest : public ::testing::TestWithParam {}; + +TEST_P(ScaleDurationTest, PicksLargestUnitOfAtLeastOne) { + const ScaleDurationCase &c = GetParam(); + const ScaledDuration scaled = scale_duration(c.ms); + EXPECT_FLOAT_EQ(scaled.value, c.value); + EXPECT_STREQ(LOG_STR_ARG(scaled.unit), c.unit); + EXPECT_EQ(scaled.decimals, c.decimals); +} + +INSTANTIATE_TEST_SUITE_P( + Units, ScaleDurationTest, + ::testing::Values(ScaleDurationCase{1, 1.0f, "ms", 0}, ScaleDurationCase{999, 999.0f, "ms", 0}, + ScaleDurationCase{1000, 1.0f, "s", 1}, ScaleDurationCase{95000, 1.5833334f, "min", 1}, + ScaleDurationCase{59999, 59.999f, "s", 1}, ScaleDurationCase{60000, 1.0f, "min", 1}, + ScaleDurationCase{300000, 5.0f, "min", 1}, ScaleDurationCase{3599999, 59.999983f, "min", 1}, + ScaleDurationCase{3600000, 1.0f, "h", 1}, ScaleDurationCase{86400000, 24.0f, "h", 1})); + +} // namespace esphome::exponential_moving_average::testing diff --git a/tests/components/exponential_moving_average/test.esp32-idf.yaml b/tests/components/exponential_moving_average/test.esp32-idf.yaml new file mode 100644 index 00000000000..2f4b6a0f579 --- /dev/null +++ b/tests/components/exponential_moving_average/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + exponential_moving_average: !include common.yaml From 3b319240c6d12afca15e4bedf5bfb2cb128c15a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 21:20:32 -0500 Subject: [PATCH 017/128] [mipi_rgb] Keep the init sequence in flash (#20090) --- esphome/components/mipi_rgb/display.py | 3 ++- esphome/components/mipi_rgb/mipi_rgb.cpp | 16 ++++++++-------- esphome/components/mipi_rgb/mipi_rgb.h | 9 +++++++-- .../mipi_rgb/test_reset_sequence.py | 18 +++++++++++++++--- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 9cba56a9763..71234c2f25b 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -286,7 +286,8 @@ async def to_code(config: ConfigType) -> None: if CONF_SPI_ID in config: await spi.register_spi_device(var, config, write_only=True) sequence = model.get_sequence(config, add_reset=True) - cg.add(var.set_init_sequence(sequence)) + table = cg.shared_progmem_array("mipi_rgb_init_sequence", cg.uint8, sequence) + cg.add(var.set_init_sequence(table, len(sequence))) cg.add(var.set_color_mode(COLOR_ORDERS[config[CONF_COLOR_ORDER]])) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 034efb9c92a..4928b6f6672 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -78,27 +78,28 @@ void MipiRgbSpi::write_data_(uint8_t value) { void MipiRgbSpi::write_init_sequence_() { size_t index = 0; - auto &vec = this->init_sequence_; - while (index != vec.size()) { - if (vec.size() - index < 2) { + const uint8_t *seq = this->init_sequence_; + const size_t len = this->init_sequence_len_; + while (index != len) { + if (len - index < 2) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } - uint8_t cmd = vec[index++]; - uint8_t x = vec[index++]; + uint8_t cmd = seq[index++]; + uint8_t x = seq[index++]; if (x == DELAY_FLAG) { ESP_LOGD(TAG, "Delay %dms", cmd); delay(cmd); } else { uint8_t num_args = x & 0x7F; - if (vec.size() - index < num_args) { + if (len - index < num_args) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } if (cmd == SLEEP_OUT) { delay(120); // NOLINT } - const auto *ptr = vec.data() + index; + const auto *ptr = seq + index; char hex_buf[format_hex_pretty_size(MIPI_RGB_MAX_CMD_LOG_BYTES)]; ESP_LOGD(TAG, "Write command %02X, length %d, byte(s) %s", cmd, num_args, format_hex_pretty_to(hex_buf, ptr, num_args, '.')); @@ -111,7 +112,6 @@ void MipiRgbSpi::write_init_sequence_() { } } // this->spi_teardown(); // SPI not needed after this - this->init_sequence_.clear(); delay(10); } diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index f528943c1b5..8b29e273abd 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -109,7 +109,10 @@ class MipiRgbSpi final : public MipiRgb, public: MipiRgbSpi(int width, int height) : MipiRgb(width, height) {} - void set_init_sequence(const std::vector &init_sequence) { this->init_sequence_ = init_sequence; } + void set_init_sequence(const uint8_t *init_sequence, size_t len) { + this->init_sequence_ = init_sequence; + this->init_sequence_len_ = len; + } void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } void setup() override; @@ -120,7 +123,9 @@ class MipiRgbSpi final : public MipiRgb, void dump_config() override; GPIOPin *dc_pin_{nullptr}; - std::vector init_sequence_; + // Shared PROGMEM table + const uint8_t *init_sequence_{nullptr}; + size_t init_sequence_len_{0}; }; #endif diff --git a/tests/component_tests/mipi_rgb/test_reset_sequence.py b/tests/component_tests/mipi_rgb/test_reset_sequence.py index 50478361b14..b0fd3ed5732 100644 --- a/tests/component_tests/mipi_rgb/test_reset_sequence.py +++ b/tests/component_tests/mipi_rgb/test_reset_sequence.py @@ -9,6 +9,7 @@ here. from collections.abc import Callable from pathlib import Path +import re # A model with no reset_pin default: SWRESET ({1, 0}) is prepended ahead of the # inherited ST7701S reset_delay ({50, 255}). @@ -54,6 +55,18 @@ display: """ +def _init_sequence(main_cpp: str, display_id: str) -> str: + """Return the shared PROGMEM init sequence table passed to a display, as rendered.""" + match = re.search(rf"{display_id}->set_init_sequence\((\w+), \d+\);", main_cpp) + assert match is not None + table = re.search( + rf"static constexpr uint8_t {match.group(1)}\[\] PROGMEM = (\{{[^;]*\}});", + main_cpp, + ) + assert table is not None + return table.group(1) + + def test_swreset_and_reset_delay_without_reset_pin( generate_main: Callable[[str | Path], str], tmp_path: Path, @@ -64,7 +77,7 @@ def test_swreset_and_reset_delay_without_reset_pin( main_cpp = generate_main(yaml_file) - assert "no_reset_display->set_init_sequence({1, 0, 50, 255," in main_cpp + assert _init_sequence(main_cpp, "no_reset_display").startswith("{1, 0, 50, 255,") def test_reset_delay_only_with_reset_pin( @@ -77,5 +90,4 @@ def test_reset_delay_only_with_reset_pin( main_cpp = generate_main(yaml_file) - assert "has_reset_display->set_init_sequence({50, 255," in main_cpp - assert "has_reset_display->set_init_sequence({1, 0," not in main_cpp + assert _init_sequence(main_cpp, "has_reset_display").startswith("{50, 255,") From f91ac5ed1d3226113957d912b265ca0915ae5c15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 21:20:56 -0500 Subject: [PATCH 018/128] [mipi_dsi] Keep the init sequence in flash (#20091) --- esphome/components/mipi_dsi/display.py | 3 ++- esphome/components/mipi_dsi/mipi_dsi.cpp | 15 ++++++++------- esphome/components/mipi_dsi/mipi_dsi.h | 9 +++++++-- .../mipi_dsi/test_mipi_dsi_config.py | 8 +++++++- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index b23982655a3..6bf94bf364b 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -201,7 +201,8 @@ async def to_code(config: ConfigType) -> None: sequence = model.get_sequence(config) cg.add(var.set_model(config[CONF_MODEL])) - cg.add(var.set_init_sequence(sequence)) + table = cg.shared_progmem_array("mipi_dsi_init_sequence", cg.uint8, sequence) + cg.add(var.set_init_sequence(table, len(sequence))) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) cg.add(var.set_hsync_pulse_width(config[CONF_HSYNC_PULSE_WIDTH])) cg.add(var.set_hsync_back_porch(config[CONF_HSYNC_BACK_PORCH])) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index b6612038b6d..a27be568f25 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -121,20 +121,21 @@ void MipiDsi::setup() { return; } size_t index = 0; - auto &vec = this->init_sequence_; - while (index != vec.size()) { - if (vec.size() - index < 2) { + const uint8_t *seq = this->init_sequence_; + const size_t len = this->init_sequence_len_; + while (index != len) { + if (len - index < 2) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } - uint8_t cmd = vec[index++]; - uint8_t x = vec[index++]; + uint8_t cmd = seq[index++]; + uint8_t x = seq[index++]; if (x == DELAY_FLAG) { ESP_LOGD(TAG, "Delay %dms", cmd); delay(cmd); } else { uint8_t num_args = x & 0x7F; - if (vec.size() - index < num_args) { + if (len - index < num_args) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } @@ -145,7 +146,7 @@ void MipiDsi::setup() { delay(duration); } } - const auto *ptr = vec.data() + index; + const auto *ptr = seq + index; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE char hex_buf[format_hex_pretty_size(MIPI_DSI_MAX_CMD_LOG_BYTES)]; #endif diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 7bf2feb73c5..4243d3116cf 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -55,7 +55,10 @@ class MipiDsi final : public display::Display { void set_vsync_pulse_width(uint16_t vsync_pulse_width) { this->vsync_pulse_width_ = vsync_pulse_width; } void set_vsync_back_porch(uint16_t vsync_back_porch) { this->vsync_back_porch_ = vsync_back_porch; } void set_vsync_front_porch(uint16_t vsync_front_porch) { this->vsync_front_porch_ = vsync_front_porch; } - void set_init_sequence(const std::vector &init_sequence) { this->init_sequence_ = init_sequence; } + void set_init_sequence(const uint8_t *init_sequence, size_t len) { + this->init_sequence_ = init_sequence; + this->init_sequence_len_ = len; + } void set_model(const char *model) { this->model_ = model; } void set_lane_bit_rate(float lane_bit_rate) { this->lane_bit_rate_ = lane_bit_rate; } void set_lanes(uint8_t lanes) { this->lanes_ = lanes; } @@ -91,7 +94,9 @@ class MipiDsi final : public display::Display { uint16_t vsync_back_porch_ = 10; uint16_t vsync_front_porch_ = 10; const char *model_{"Unknown"}; - std::vector init_sequence_{}; + // Shared PROGMEM table + const uint8_t *init_sequence_{nullptr}; + size_t init_sequence_len_{0}; float pclk_frequency_ = 16; // in MHz float lane_bit_rate_{1500}; // in Mbps uint8_t lanes_{2}; // 1, 2, 3 or 4 lanes diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 6259d85184c..ac385cc2d81 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -3,6 +3,7 @@ from collections.abc import Callable import logging from pathlib import Path +import re import pytest @@ -201,7 +202,12 @@ def test_code_generation( "new(p4_nano) mipi_dsi::MipiDsi(800, 1280, display::COLOR_BITNESS_565, 16);" in main_cpp ) - assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp + seq = re.search(r"p4_nano->set_init_sequence\((\w+), \d+\);", main_cpp) + assert seq is not None + assert ( + f"static constexpr uint8_t {seq.group(1)}[] PROGMEM = " + "{224, 1, 0, 225, 1, 147, 226, 1," in main_cpp + ) assert "p4_nano->set_lane_bit_rate(1500.0f);" in main_cpp assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" not in main_cpp From 88378ab354301687c0aa5a93a0ed72e442bacec5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 21:21:28 -0500 Subject: [PATCH 019/128] [mipi_spi] Keep the init sequence in flash (#20089) --- esphome/components/mipi_spi/display.py | 3 +- esphome/components/mipi_spi/mipi_spi.h | 35 +++++++++------ tests/component_tests/mipi_spi/test_init.py | 49 ++++++++++++++++++--- 3 files changed, 68 insertions(+), 19 deletions(-) diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index e8b54da5c79..b49675ca4c1 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -401,7 +401,8 @@ async def to_code(config: ConfigType) -> None: init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True) var_id.type, templateargs = get_instance(config) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) - cg.add(var.set_init_sequence(init_sequence)) + table = cg.shared_progmem_array("mipi_spi_init_sequence", cg.uint8, init_sequence) + cg.add(var.set_init_sequence(table, len(init_sequence))) cg.add(var.set_model(config[CONF_MODEL])) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index e97b0e3a310..e428a1f96e5 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -5,6 +5,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" namespace esphome::mipi_spi { @@ -133,7 +134,10 @@ class MipiSpi : public display::Display, return HEIGHT; } - void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } + void set_init_sequence(const uint8_t *sequence, size_t len) { + this->init_sequence_ = sequence; + this->init_sequence_len_ = len; + } // reset the display, and write the init sequence void setup() override { @@ -159,15 +163,16 @@ class MipiSpi : public display::Display, // need to know when the display is ready for SLPOUT command - will be 120ms after reset auto when = millis() + 120; size_t index = 0; - auto &vec = this->init_sequence_; - while (index != vec.size()) { - if (vec.size() - index < 2) { + const uint8_t *seq = this->init_sequence_; + const size_t len = this->init_sequence_len_; + while (index != len) { + if (len - index < 2) { esph_log_e(TAG, "Malformed init sequence"); this->mark_failed(); return; } - uint8_t cmd = vec[index++]; - uint8_t x = vec[index++]; + uint8_t cmd = progmem_read_byte(seq + index++); + uint8_t x = progmem_read_byte(seq + index++); if (x == DELAY_FLAG) { if (cmd == 0) { cmd = clamp_at_least((int) (when - millis()), 0); @@ -176,19 +181,21 @@ class MipiSpi : public display::Display, delay(cmd); } else { uint8_t num_args = x & 0x7F; - if (vec.size() - index < num_args) { + if (len - index < num_args) { esph_log_e(TAG, "Malformed init sequence"); this->mark_failed(); return; } - const auto *ptr = vec.data() + index; - this->write_command_(cmd, ptr, num_args); + // The sequence is in flash, which SPI DMA (and ESP8266 byte loads) cannot read + uint8_t args[0x80]; + progmem_memcpy(args, seq + index, num_args); + this->write_command_(cmd, args, num_args); index += num_args; } } this->reset_params_(); - // init sequence no longer needed - this->init_sequence_.clear(); + // Marks init as done, so later commands log at verbose level instead of debug + this->init_sequence_len_ = 0; } // Drawing operations @@ -236,7 +243,7 @@ class MipiSpi : public display::Display, void write_command_(uint8_t cmd, const uint8_t *bytes, size_t len) { char hex_buf[format_hex_pretty_size(MIPI_SPI_MAX_CMD_LOG_BYTES)]; // Don't spam the log after setup - if (this->init_sequence_.empty()) { + if (this->init_sequence_len_ == 0) { esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); } else { esph_log_d(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); @@ -486,7 +493,9 @@ class MipiSpi : public display::Display, bool invert_colors_{}; optional brightness_{}; const char *model_{"Unknown"}; - std::vector init_sequence_{}; + // Shared PROGMEM table + const uint8_t *init_sequence_{nullptr}; + size_t init_sequence_len_{0}; }; /** diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index f29883684c8..cacdc0c1068 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -2,6 +2,7 @@ from collections.abc import Callable from pathlib import Path +import re from typing import Any import pytest @@ -33,6 +34,18 @@ from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable +def _init_sequence(main_cpp: str, display_id: str = r"\w+") -> str: + """Return the shared PROGMEM init sequence table passed to a display, as rendered.""" + match = re.search(rf"{display_id}->set_init_sequence\((\w+), \d+\);", main_cpp) + assert match is not None + table = re.search( + rf"static constexpr uint8_t {match.group(1)}\[\] PROGMEM = (\{{[^;]*\}});", + main_cpp, + ) + assert table is not None + return table.group(1) + + def run_schema_validation(config: ConfigType) -> None: """Run schema validation on a configuration.""" FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) @@ -362,7 +375,7 @@ def test_native_generation( in main_cpp ) # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. - assert "set_init_sequence({10, 255, 240, 1, 8, 242" in main_cpp + assert _init_sequence(main_cpp).startswith("{10, 255, 240, 1, 8, 242") assert "show_test_card();" in main_cpp assert "set_write_only(true);" in main_cpp @@ -379,7 +392,7 @@ def test_lvgl_generation( in main_cpp ) # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. - assert "set_init_sequence({10, 255, 177, 3, 1, 44, 45, 178" in main_cpp + assert _init_sequence(main_cpp).startswith("{10, 255, 177, 3, 1, 44, 45, 178") assert "show_test_card();" not in main_cpp assert "set_auto_clear(false);" in main_cpp @@ -428,7 +441,9 @@ def test_swreset_prepended_without_reset_pin( # SWRESET ({1, 0}) followed by a 10ms delay ({10, 255}) is inserted ahead of # the model's own commands. - assert "swreset_display->set_init_sequence({1, 0, 10, 255, 160, 1, 1," in main_cpp + assert _init_sequence(main_cpp, "swreset_display").startswith( + "{1, 0, 10, 255, 160, 1, 1," + ) def test_swreset_not_prepended_with_reset_pin( @@ -449,5 +464,29 @@ def test_swreset_not_prepended_with_reset_pin( main_cpp = generate_main(yaml_file) # The delay ({10, 255}) is still present, but no leading SWRESET ({1, 0}). - assert "hwreset_display->set_init_sequence({10, 255, 160, 1, 1," in main_cpp - assert "hwreset_display->set_init_sequence({1, 0," not in main_cpp + assert _init_sequence(main_cpp, "hwreset_display").startswith( + "{10, 255, 160, 1, 1," + ) + + +def test_identical_init_sequences_share_one_table( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """Two displays with the same init sequence point at one PROGMEM table.""" + yaml_file = tmp_path / "shared.yaml" + display = _SWRESET_YAML.split("display:\n", 1)[1] + yaml_file.write_text( + _SWRESET_YAML.format(display_id="first", reset_line=" reset_pin: 5") + + display.format(display_id="second", reset_line=" reset_pin: 6") + .replace("cs_pin: 8", "cs_pin: 9") + .replace("dc_pin: 4", "dc_pin: 7") + ) + + main_cpp = generate_main(yaml_file) + + first = re.search(r"first->set_init_sequence\((\w+), \d+\);", main_cpp) + second = re.search(r"second->set_init_sequence\((\w+), \d+\);", main_cpp) + assert first is not None and second is not None + assert first.group(1) == second.group(1) + assert main_cpp.count("mipi_spi_init_sequence[] PROGMEM") == 1 From 645144ad4d4a6ca4c1b1b2c9292eb8a759fc9200 Mon Sep 17 00:00:00 2001 From: Max Slotov Date: Mon, 5 Oct 2026 05:21:48 +0300 Subject: [PATCH 020/128] [uart] Validate parity and stop bits correctly (#20161) --- esphome/components/uart/__init__.py | 3 +- esphome/components/usb_uart/usb_uart.h | 17 ++++++- .../uart/test_final_validate.py | 46 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/uart/test_final_validate.py diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 24a854ec392..25fbfb75994 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -489,7 +489,8 @@ def final_validate_device_schema( return value def validate_stop_bits(value): - if value != stop_bits: + # usb_uart channels store stop bits as strings ("1", "1.5", "2"). + if float(value) != stop_bits: raise cv.Invalid( f"Component {name} requires {stop_bits} stop bits for the uart referenced by {uart_bus}" ) diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 22563209da3..c99992d8785 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -155,7 +155,22 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedparity_ = parity; } + void set_parity(UARTParityOptions parity) { + this->parity_ = parity; + // Keep the base-class parity in sync so uart::UARTComponent::get_parity() reports the configured value. + // MARK/SPACE have no uart:: equivalent and report as NONE. + switch (parity) { + case UART_CONFIG_PARITY_EVEN: + uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_EVEN); + break; + case UART_CONFIG_PARITY_ODD: + uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_ODD); + break; + default: + uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_NONE); + break; + } + } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); } diff --git a/tests/component_tests/uart/test_final_validate.py b/tests/component_tests/uart/test_final_validate.py new file mode 100644 index 00000000000..b2ad5f43903 --- /dev/null +++ b/tests/component_tests/uart/test_final_validate.py @@ -0,0 +1,46 @@ +"""Tests for final validation of UART device requirements against usb_uart channels.""" + +from pathlib import Path + +import pytest + +from esphome import config, yaml_util +from esphome.core import CORE + +USB_UART_CONFIG = """ +esphome: + name: usb-uart-final-validate +esp32: + variant: esp32s3 + framework: + type: esp-idf +usb_uart: + - type: ch340 + channels: + - id: meter_uart + baud_rate: 1200 + stop_bits: {stop_bits} +sensor: + - platform: kamstrup_kmp + uart_id: meter_uart + heat_energy: + name: Heat energy +""" + + +@pytest.mark.parametrize( + ("stop_bits", "valid"), [("2", True), ("1", False), ("1.5", False)] +) +def test_stop_bits_requirement_on_usb_uart_channel( + stop_bits: str, valid: bool, tmp_path: Path +) -> None: + """usb_uart stores stop bits as strings; they must still satisfy a numeric requirement.""" + path = tmp_path / "usb_uart.yaml" + path.write_text(USB_UART_CONFIG.format(stop_bits=stop_bits), encoding="utf-8") + CORE.config_path = path + result = config.validate_config(yaml_util.load_yaml(path), {}) + errors = [str(err) for err in result.errors] + if valid: + assert errors == [] + else: + assert any("requires 2 stop bits" in err for err in errors), errors From 2d73ea24ca2877307d0359f04cd7f76f1d6125a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Oct 2026 21:23:09 -0500 Subject: [PATCH 021/128] [libretiny] Keep a scan that finds no networks from resetting the LN882x (#20169) --- esphome/components/libretiny/__init__.py | 5 +++++ .../components/libretiny/ln882x_zero_malloc.c | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 esphome/components/libretiny/ln882x_zero_malloc.c diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 8cd7f3e7b62..5d87dee07a3 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -32,6 +32,7 @@ from esphome.storage_json import StorageJSON from . import gpio # noqa: F401 from .const import ( COMPONENT_BK72XX, + COMPONENT_LN882X, CONF_GPIO_RECOVER, CONF_LOGLEVEL, CONF_SDK_SILENT, @@ -490,6 +491,10 @@ async def component_to_code(config): # Not enabled on RTL87xx/LN882x — costs more heap than it saves there. cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1") + # LN882x: a zero-size allocation must not trip the SDK's assert (see ln882x_zero_malloc.c). + if config[CONF_COMPONENT_ID] == COMPONENT_LN882X: + cg.add_build_flag("-Wl,--wrap=pvPortMalloc") + # RTL8710B needs FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake # required by AsyncTCP 3.4.3+ (https://github.com/esphome/esphome/issues/10220) # RTL8720C (ambz2) requires FreeRTOS 10.x so this only applies to RTL8710B diff --git a/esphome/components/libretiny/ln882x_zero_malloc.c b/esphome/components/libretiny/ln882x_zero_malloc.c new file mode 100644 index 00000000000..77e30b3a7dd --- /dev/null +++ b/esphome/components/libretiny/ln882x_zero_malloc.c @@ -0,0 +1,19 @@ +/* + * LN882x: heap_5 returns NULL for a zero-size request, and the SDK's + * malloc-failed hook ends in LN_ASSERT(0), which spins until the watchdog + * resets the chip. LibreTiny's scan handler asks for zero entries when a scan + * finds no networks. Linked with -Wl,--wrap=pvPortMalloc so every allocation + * comes through here; drop it once LibreTiny no longer allocates zero entries. + */ + +#ifdef USE_LN882X + +#include + +void *__real_pvPortMalloc(size_t size); // NOLINT(readability-identifier-naming) + +void *__wrap_pvPortMalloc(size_t size) { // NOLINT(readability-identifier-naming) + return __real_pvPortMalloc(size == 0 ? 1 : size); +} + +#endif // USE_LN882X From 99fd1bca3e0f68ae052949dfe4fb8522e8074f87 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:58:43 +0000 Subject: [PATCH 022/128] [ci] Refresh integration test durations --- .../integration_test_durations.json | 329 +++++++++--------- 1 file changed, 167 insertions(+), 162 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index 4f2e245e129..060799862f7 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,164 +1,169 @@ { - "tests/integration/test_action_concurrent_reentry.py": 14.22, - "tests/integration/test_addressable_light_transition.py": 12.87, - "tests/integration/test_alarm_control_panel_state_transitions.py": 10.89, - "tests/integration/test_api_action_metadata.py": 8.48, - "tests/integration/test_api_action_responses.py": 14.99, - "tests/integration/test_api_action_timeout.py": 17.11, - "tests/integration/test_api_conditional_memory.py": 12.13, - "tests/integration/test_api_custom_services.py": 13.35, - "tests/integration/test_api_decode_wire_types.py": 11.09, - "tests/integration/test_api_empty_message_roundtrip.py": 11.44, - "tests/integration/test_api_encode_boundaries.py": 14.78, - "tests/integration/test_api_get_time_response_timezone.py": 13.95, - "tests/integration/test_api_homeassistant.py": 16.39, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 10.59, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 7.64, - "tests/integration/test_api_list_entities_backpressure.py": 13.25, - "tests/integration/test_api_message_size_batching.py": 9.9, - "tests/integration/test_api_reboot_timeout.py": 12.0, - "tests/integration/test_api_string_lambda.py": 9.04, - "tests/integration/test_api_vv_logging.py": 12.89, - "tests/integration/test_api_zero_psk_provisioning.py": 17.56, - "tests/integration/test_areas_and_devices.py": 9.95, - "tests/integration/test_automation_wait_actions.py": 11.31, - "tests/integration/test_automations.py": 26.82, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 10.76, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 11.71, - "tests/integration/test_binary_sensor_invalidate_state.py": 9.11, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 12.55, - "tests/integration/test_build_info.py": 8.14, - "tests/integration/test_camera_mock.py": 9.88, - "tests/integration/test_climate_control_action.py": 12.5, - "tests/integration/test_climate_custom_modes.py": 10.59, - "tests/integration/test_continuation_actions.py": 10.74, - "tests/integration/test_cover_control_action.py": 11.06, - "tests/integration/test_crc8_helper.py": 9.21, - "tests/integration/test_device_id_in_state.py": 20.43, - "tests/integration/test_duplicate_entities.py": 12.01, - "tests/integration/test_entity_icon.py": 14.29, - "tests/integration/test_entity_missing_state.py": 11.84, - "tests/integration/test_fan_turn_on_action.py": 13.41, - "tests/integration/test_fnv1_hash_object_id.py": 6.02, - "tests/integration/test_fnv1a_hash.py": 9.59, - "tests/integration/test_gpio_expander_cache.py": 7.68, - "tests/integration/test_host_logger_thread_safety.py": 12.31, - "tests/integration/test_host_mode_basic.py": 9.92, - "tests/integration/test_host_mode_batch_delay.py": 8.99, - "tests/integration/test_host_mode_climate_basic_state.py": 13.73, - "tests/integration/test_host_mode_climate_control.py": 10.2, - "tests/integration/test_host_mode_empty_string_options.py": 11.81, - "tests/integration/test_host_mode_entity_fields.py": 14.54, - "tests/integration/test_host_mode_fan_preset.py": 12.02, - "tests/integration/test_host_mode_many_entities.py": 22.44, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 12.55, - "tests/integration/test_host_mode_noise_encryption.py": 25.74, - "tests/integration/test_host_mode_reconnect.py": 6.67, - "tests/integration/test_host_mode_sensor.py": 11.49, - "tests/integration/test_host_ota.py": 112.15, - "tests/integration/test_host_preferences.py": 12.61, - "tests/integration/test_host_preferences_suspend_resume.py": 11.62, - "tests/integration/test_improv_serial_uart.py": 8.08, - "tests/integration/test_large_message_batching.py": 10.44, - "tests/integration/test_legacy_area.py": 7.85, - "tests/integration/test_legacy_climate_compat.py": 13.36, - "tests/integration/test_legacy_fan_compat.py": 10.6, - "tests/integration/test_light_automations.py": 10.63, - "tests/integration/test_light_binary_effect_off_phase.py": 20.09, - "tests/integration/test_light_calls.py": 15.97, - "tests/integration/test_light_constant_brightness.py": 15.27, - "tests/integration/test_light_control_action.py": 13.14, - "tests/integration/test_light_dim_relative_action.py": 10.89, - "tests/integration/test_light_effect_zero_brightness.py": 11.44, - "tests/integration/test_light_initial_state.py": 8.19, - "tests/integration/test_light_restore_state.py": 10.78, - "tests/integration/test_light_resume_effect.py": 12.26, - "tests/integration/test_light_toggle_action.py": 10.53, - "tests/integration/test_light_transition_state_publish_interval.py": 43.37, - "tests/integration/test_lock_automations.py": 10.88, - "tests/integration/test_logger_buffered_recursion_guard.py": 8.93, - "tests/integration/test_loop_disable_enable.py": 11.81, - "tests/integration/test_loop_interval_decoupling.py": 14.96, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 15.07, - "tests/integration/test_lvgl_headless_render.py": 55.09, - "tests/integration/test_micros_to_millis.py": 7.96, - "tests/integration/test_multi_click_trigger.py": 14.25, - "tests/integration/test_multi_device_preferences.py": 11.2, - "tests/integration/test_noise_encryption_key_protection.py": 20.23, - "tests/integration/test_object_id_api_verification.py": 15.03, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 9.16, - "tests/integration/test_object_id_no_friendly_name.py": 13.66, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 22.28, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 28.84, - "tests/integration/test_online_image_bmp.py": 14.85, - "tests/integration/test_oversized_payloads.py": 31.19, - "tests/integration/test_preference_key_stability.py": 8.3, - "tests/integration/test_runtime_stats.py": 14.29, - "tests/integration/test_safe_mode_loop_runs.py": 7.53, - "tests/integration/test_scheduler_blocking_warning.py": 26.59, - "tests/integration/test_scheduler_bulk_cleanup.py": 9.11, - "tests/integration/test_scheduler_defer_cancel.py": 7.84, - "tests/integration/test_scheduler_defer_cancel_regular.py": 8.05, - "tests/integration/test_scheduler_defer_fifo_simple.py": 12.32, - "tests/integration/test_scheduler_defer_stress.py": 12.6, - "tests/integration/test_scheduler_heap_stress.py": 7.94, - "tests/integration/test_scheduler_internal_id_no_collision.py": 13.8, - "tests/integration/test_scheduler_interval_reschedule.py": 8.2, - "tests/integration/test_scheduler_interval_zero_coerced.py": 8.34, - "tests/integration/test_scheduler_null_name.py": 11.88, - "tests/integration/test_scheduler_numeric_id_test.py": 13.83, - "tests/integration/test_scheduler_pool.py": 13.71, - "tests/integration/test_scheduler_rapid_cancellation.py": 4.5, - "tests/integration/test_scheduler_recursive_timeout.py": 2.26, - "tests/integration/test_scheduler_removed_item_race.py": 10.73, - "tests/integration/test_scheduler_self_keyed.py": 9.9, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 11.04, - "tests/integration/test_scheduler_string_test.py": 13.88, - "tests/integration/test_script_array_params.py": 12.34, - "tests/integration/test_script_delay_params.py": 13.26, - "tests/integration/test_script_queued.py": 15.09, - "tests/integration/test_script_queued_idle_loop.py": 9.25, - "tests/integration/test_script_wait_on_boot.py": 12.55, - "tests/integration/test_sdl_headless_screenshot.py": 15.02, - "tests/integration/test_select_stringref_trigger.py": 13.35, - "tests/integration/test_sensor_filters_delta.py": 10.95, - "tests/integration/test_sensor_filters_ring_buffer.py": 12.24, - "tests/integration/test_sensor_filters_sliding_window.py": 36.89, - "tests/integration/test_sensor_filters_value_list.py": 13.19, - "tests/integration/test_sensor_raw_state.py": 22.36, - "tests/integration/test_sensor_timeout_filter.py": 7.08, - "tests/integration/test_set_internal_at_boot.py": 10.04, - "tests/integration/test_snapshot_display.py": 14.16, - "tests/integration/test_socket_wake_gate_tcp.py": 11.87, - "tests/integration/test_status_flags.py": 16.85, - "tests/integration/test_strftime_to.py": 9.78, - "tests/integration/test_syslog.py": 19.32, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 10.29, - "tests/integration/test_template_climate_basic.py": 8.97, - "tests/integration/test_template_climate_custom_modes.py": 12.47, - "tests/integration/test_template_climate_nonoptimistic.py": 13.76, - "tests/integration/test_template_climate_on_control_ordering.py": 4.05, - "tests/integration/test_template_climate_publish_all_fields.py": 11.22, - "tests/integration/test_template_climate_sensor_push.py": 9.25, - "tests/integration/test_template_climate_set_actions.py": 9.75, - "tests/integration/test_template_climate_two_point_temperature.py": 8.42, - "tests/integration/test_template_text_save.py": 13.08, - "tests/integration/test_template_time.py": 11.6, - "tests/integration/test_text_command.py": 8.74, - "tests/integration/test_text_sensor_raw_state.py": 8.84, - "tests/integration/test_uart_mock_ld2410.py": 24.25, - "tests/integration/test_uart_mock_ld2412.py": 25.28, - "tests/integration/test_uart_mock_ld2420.py": 19.0, - "tests/integration/test_uart_mock_ld2450.py": 13.48, - "tests/integration/test_uart_mock_modbus.py": 207.43, - "tests/integration/test_udp.py": 12.28, - "tests/integration/test_use_address_runtime.py": 15.24, - "tests/integration/test_valve_control_action.py": 13.21, - "tests/integration/test_varint_five_byte_device_id.py": 11.19, - "tests/integration/test_wait_until_mid_loop_timing.py": 6.22, - "tests/integration/test_wait_until_on_boot.py": 8.33, - "tests/integration/test_wait_until_ordering.py": 10.08, - "tests/integration/test_wait_until_reentrant_restart.py": 12.43, - "tests/integration/test_wake_loop_forces_phase_b.py": 10.22, - "tests/integration/test_water_heater_template.py": 24.1 + "tests/integration/test_action_concurrent_reentry.py": 12.49, + "tests/integration/test_addressable_light_transition.py": 17.27, + "tests/integration/test_alarm_control_panel_state_transitions.py": 15.05, + "tests/integration/test_api_action_metadata.py": 10.78, + "tests/integration/test_api_action_responses.py": 17.68, + "tests/integration/test_api_action_timeout.py": 17.48, + "tests/integration/test_api_conditional_memory.py": 11.82, + "tests/integration/test_api_custom_services.py": 8.17, + "tests/integration/test_api_decode_wire_types.py": 17.82, + "tests/integration/test_api_empty_message_roundtrip.py": 12.6, + "tests/integration/test_api_encode_boundaries.py": 14.83, + "tests/integration/test_api_get_time_response_timezone.py": 12.93, + "tests/integration/test_api_homeassistant.py": 19.79, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 12.45, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 7.67, + "tests/integration/test_api_list_entities_backpressure.py": 12.69, + "tests/integration/test_api_message_size_batching.py": 15.39, + "tests/integration/test_api_reboot_timeout.py": 12.25, + "tests/integration/test_api_string_lambda.py": 13.84, + "tests/integration/test_api_vv_logging.py": 12.98, + "tests/integration/test_api_zero_psk_provisioning.py": 19.36, + "tests/integration/test_areas_and_devices.py": 8.09, + "tests/integration/test_automation_wait_actions.py": 14.26, + "tests/integration/test_automations.py": 20.27, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 15.02, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 14.18, + "tests/integration/test_binary_sensor_invalidate_state.py": 13.04, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 10.44, + "tests/integration/test_build_info.py": 12.48, + "tests/integration/test_camera_mock.py": 12.53, + "tests/integration/test_climate_control_action.py": 9.7, + "tests/integration/test_climate_custom_modes.py": 14.68, + "tests/integration/test_continuation_actions.py": 12.16, + "tests/integration/test_cover_control_action.py": 11.76, + "tests/integration/test_crc8_helper.py": 8.21, + "tests/integration/test_device_id_in_state.py": 26.48, + "tests/integration/test_duplicate_entities.py": 14.89, + "tests/integration/test_entity_icon.py": 15.75, + "tests/integration/test_entity_missing_state.py": 9.8, + "tests/integration/test_fan_turn_on_action.py": 12.96, + "tests/integration/test_fnv1_hash_object_id.py": 2.55, + "tests/integration/test_fnv1a_hash.py": 9.37, + "tests/integration/test_gpio_expander_cache.py": 12.31, + "tests/integration/test_host_logger_thread_safety.py": 7.8, + "tests/integration/test_host_mode_basic.py": 11.59, + "tests/integration/test_host_mode_batch_delay.py": 8.18, + "tests/integration/test_host_mode_climate_basic_state.py": 12.53, + "tests/integration/test_host_mode_climate_control.py": 14.3, + "tests/integration/test_host_mode_empty_string_options.py": 13.74, + "tests/integration/test_host_mode_entity_fields.py": 15.51, + "tests/integration/test_host_mode_fan_preset.py": 9.63, + "tests/integration/test_host_mode_many_entities.py": 16.22, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 13.26, + "tests/integration/test_host_mode_noise_encryption.py": 29.86, + "tests/integration/test_host_mode_reconnect.py": 11.7, + "tests/integration/test_host_mode_sensor.py": 11.74, + "tests/integration/test_host_ota.py": 129.75, + "tests/integration/test_host_preferences.py": 8.46, + "tests/integration/test_host_preferences_suspend_resume.py": 14.3, + "tests/integration/test_improv_serial_uart.py": 15.6, + "tests/integration/test_large_message_batching.py": 13.6, + "tests/integration/test_legacy_area.py": 11.44, + "tests/integration/test_legacy_climate_compat.py": 12.99, + "tests/integration/test_legacy_fan_compat.py": 8.75, + "tests/integration/test_light_automations.py": 15.38, + "tests/integration/test_light_binary_effect_off_phase.py": 23.85, + "tests/integration/test_light_calls.py": 16.45, + "tests/integration/test_light_constant_brightness.py": 16.87, + "tests/integration/test_light_control_action.py": 16.25, + "tests/integration/test_light_dim_relative_action.py": 14.03, + "tests/integration/test_light_effect_zero_brightness.py": 16.53, + "tests/integration/test_light_initial_state.py": 15.16, + "tests/integration/test_light_repeat_effect.py": 22.35, + "tests/integration/test_light_restore_state.py": 11.68, + "tests/integration/test_light_resume_effect.py": 17.07, + "tests/integration/test_light_toggle_action.py": 14.35, + "tests/integration/test_light_transition_state_publish_interval.py": 47.18, + "tests/integration/test_lock_automations.py": 12.65, + "tests/integration/test_logger_buffered_recursion_guard.py": 12.2, + "tests/integration/test_loop_disable_enable.py": 11.31, + "tests/integration/test_loop_interval_decoupling.py": 13.71, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 11.78, + "tests/integration/test_lvgl_headless_render.py": 49.4, + "tests/integration/test_micros_to_millis.py": 6.71, + "tests/integration/test_multi_click_trigger.py": 10.38, + "tests/integration/test_multi_device_preferences.py": 16.06, + "tests/integration/test_noise_encryption_key_protection.py": 13.43, + "tests/integration/test_object_id_api_verification.py": 14.32, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 12.57, + "tests/integration/test_object_id_no_friendly_name.py": 15.59, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 24.62, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 29.88, + "tests/integration/test_online_image_bmp.py": 23.67, + "tests/integration/test_oversized_payloads.py": 51.21, + "tests/integration/test_preference_key_stability.py": 14.85, + "tests/integration/test_runtime_stats.py": 11.2, + "tests/integration/test_safe_mode_loop_runs.py": 8.22, + "tests/integration/test_scheduler_blocking_warning.py": 28.18, + "tests/integration/test_scheduler_bulk_cleanup.py": 12.49, + "tests/integration/test_scheduler_defer_cancel.py": 9.95, + "tests/integration/test_scheduler_defer_cancel_regular.py": 13.32, + "tests/integration/test_scheduler_defer_fifo_simple.py": 13.18, + "tests/integration/test_scheduler_defer_stress.py": 14.29, + "tests/integration/test_scheduler_heap_stress.py": 4.73, + "tests/integration/test_scheduler_internal_id_no_collision.py": 14.05, + "tests/integration/test_scheduler_interval_reschedule.py": 9.0, + "tests/integration/test_scheduler_interval_zero_coerced.py": 3.27, + "tests/integration/test_scheduler_null_name.py": 12.94, + "tests/integration/test_scheduler_numeric_id_test.py": 11.83, + "tests/integration/test_scheduler_pool.py": 9.66, + "tests/integration/test_scheduler_rapid_cancellation.py": 3.95, + "tests/integration/test_scheduler_recursive_timeout.py": 12.91, + "tests/integration/test_scheduler_removed_item_race.py": 11.9, + "tests/integration/test_scheduler_self_keyed.py": 13.42, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 8.01, + "tests/integration/test_scheduler_string_test.py": 10.2, + "tests/integration/test_script_array_params.py": 12.99, + "tests/integration/test_script_delay_params.py": 14.73, + "tests/integration/test_script_queued.py": 16.62, + "tests/integration/test_script_queued_idle_loop.py": 11.13, + "tests/integration/test_script_wait_on_boot.py": 13.31, + "tests/integration/test_sdl_headless_screenshot.py": 14.76, + "tests/integration/test_select_stringref_trigger.py": 10.98, + "tests/integration/test_sensor_filters_delta.py": 12.63, + "tests/integration/test_sensor_filters_ring_buffer.py": 11.13, + "tests/integration/test_sensor_filters_sliding_window.py": 40.2, + "tests/integration/test_sensor_filters_value_list.py": 10.25, + "tests/integration/test_sensor_raw_state.py": 26.04, + "tests/integration/test_sensor_timeout_filter.py": 13.81, + "tests/integration/test_set_internal_at_boot.py": 14.13, + "tests/integration/test_snapshot_display.py": 16.36, + "tests/integration/test_socket_ipv4_resolve.py": 11.75, + "tests/integration/test_socket_tcp_client_link.py": 9.79, + "tests/integration/test_socket_wake_gate_tcp.py": 15.29, + "tests/integration/test_status_flags.py": 20.87, + "tests/integration/test_strftime_to.py": 10.06, + "tests/integration/test_syslog.py": 18.51, + "tests/integration/test_tcp_uart_bridge.py": 14.71, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 8.4, + "tests/integration/test_template_climate_basic.py": 14.22, + "tests/integration/test_template_climate_custom_modes.py": 11.61, + "tests/integration/test_template_climate_nonoptimistic.py": 13.47, + "tests/integration/test_template_climate_on_control_ordering.py": 13.99, + "tests/integration/test_template_climate_publish_all_fields.py": 10.98, + "tests/integration/test_template_climate_sensor_push.py": 14.86, + "tests/integration/test_template_climate_set_actions.py": 11.79, + "tests/integration/test_template_climate_two_point_temperature.py": 14.92, + "tests/integration/test_template_text_save.py": 13.35, + "tests/integration/test_template_time.py": 10.07, + "tests/integration/test_text_command.py": 12.11, + "tests/integration/test_text_sensor_raw_state.py": 13.96, + "tests/integration/test_uart_mock_ld2410.py": 30.03, + "tests/integration/test_uart_mock_ld2412.py": 35.57, + "tests/integration/test_uart_mock_ld2420.py": 26.6, + "tests/integration/test_uart_mock_ld2450.py": 13.03, + "tests/integration/test_uart_mock_modbus.py": 309.49, + "tests/integration/test_uart_tcp_bridge.py": 12.67, + "tests/integration/test_udp.py": 11.64, + "tests/integration/test_use_address_runtime.py": 16.79, + "tests/integration/test_valve_control_action.py": 12.76, + "tests/integration/test_varint_five_byte_device_id.py": 14.05, + "tests/integration/test_wait_until_mid_loop_timing.py": 12.48, + "tests/integration/test_wait_until_on_boot.py": 12.54, + "tests/integration/test_wait_until_ordering.py": 13.32, + "tests/integration/test_wait_until_reentrant_restart.py": 5.89, + "tests/integration/test_wake_loop_forces_phase_b.py": 13.8, + "tests/integration/test_water_heater_template.py": 24.77 } From bbd05e4811d6cb2b44f37f1de8f971669b728a75 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:50:18 +1100 Subject: [PATCH 023/128] [esp32] Work around ESP-IDF stack bug on P4 (#20181) --- esphome/components/esp32/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 6a693eff89d..1166afe4ec3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2873,6 +2873,8 @@ async def to_code(config): "CONFIG_ESP32P4_SELECTS_REV_LESS_V3", config.get(CONF_ENGINEERING_SAMPLE, False), ) + # Work around ESP-IDF bug: see https://github.com/espressif/esp-idf/issues/19020 + add_idf_sdkconfig_option("CONFIG_ESP_MAIN_TASK_STACK_SIZE", 8192) # ESP32-C2 defaults to the ROM's newlib "nano" printf, which does not # understand %zu or %lld and crashes on any %s that follows one. From 862047386c1b691972bbee0c334d8a4d870be4ef Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:46:36 +0200 Subject: [PATCH 024/128] [modbus] Reject send_wait_time and turnaround_time above 65535 ms (#20160) --- esphome/components/modbus/__init__.py | 14 ++++++++------ tests/component_tests/modbus/test_modbus.py | 16 +++++++++++++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index db7484e306b..cad2781cc17 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -52,6 +52,12 @@ CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] +# The client hub takes these times as 16-bit milliseconds; a Modbus timeout is far shorter anyway. +_HUB_TIME_PERIOD = cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(milliseconds=65535)), +) + # The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 # (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. @@ -273,12 +279,8 @@ CONFIG_SCHEMA = cv.typed_schema( { cv.GenerateID(): cv.declare_id(ModbusClient), cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - cv.Optional( - CONF_SEND_WAIT_TIME, default="2000ms" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_TURNAROUND_TIME, default="600ms" - ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_SEND_WAIT_TIME, default="2000ms"): _HUB_TIME_PERIOD, + cv.Optional(CONF_TURNAROUND_TIME, default="600ms"): _HUB_TIME_PERIOD, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/tests/component_tests/modbus/test_modbus.py b/tests/component_tests/modbus/test_modbus.py index 1eafb131664..d7e2c9ba2f7 100644 --- a/tests/component_tests/modbus/test_modbus.py +++ b/tests/component_tests/modbus/test_modbus.py @@ -4,7 +4,11 @@ import pytest from esphome import config_validation as cv from esphome.components import modbus -from esphome.components.modbus import CONF_MODBUS_ID, _validate_server_address +from esphome.components.modbus import ( + _HUB_TIME_PERIOD, + CONF_MODBUS_ID, + _validate_server_address, +) from esphome.const import CONF_ADDRESS @@ -36,3 +40,13 @@ def test_client_schema_still_accepts_address_zero() -> None: # A client may address 0: writes are broadcast, and reads are allowed with allow_broadcast_read. schema = modbus.modbus_device_schema(0x01) assert schema({CONF_MODBUS_ID: "hub", CONF_ADDRESS: 0})[CONF_ADDRESS] == 0 + + +def test_hub_time_accepts_up_to_65535_ms() -> None: + assert _HUB_TIME_PERIOD("65535ms").total_milliseconds == 65535 + + +def test_hub_time_rejects_values_the_hub_would_truncate() -> None: + # The setters take 16-bit milliseconds: 70 s would silently become 4464 ms. + with pytest.raises(cv.Invalid): + _HUB_TIME_PERIOD("70s") From 59b49d34cc963ff97e5c92ed8ed92a2387b2502a Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:47:18 +0200 Subject: [PATCH 025/128] [tcp_uart][uart_tcp] Expose the no-arg load_settings() and mark UartTcp final (#20163) --- esphome/components/tcp_uart/tcp_uart.h | 1 + esphome/components/uart_tcp/uart_tcp.h | 2 +- tests/components/tcp_uart/test.esp32-idf.yaml | 8 ++++++++ tests/components/tcp_uart/test.esp8266-ard.yaml | 8 ++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h index 29f7af564a6..e973c7a1891 100644 --- a/esphome/components/tcp_uart/tcp_uart.h +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -44,6 +44,7 @@ class TcpUart : public uart::UARTComponent, public Component { bool is_connected() override { return this->link_.connected(); } #if defined(USE_ESP8266) || defined(USE_ESP32) void load_settings(bool dump_config) override {} + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience #endif protected: diff --git a/esphome/components/uart_tcp/uart_tcp.h b/esphome/components/uart_tcp/uart_tcp.h index 1e19166f879..f557cca33f9 100644 --- a/esphome/components/uart_tcp/uart_tcp.h +++ b/esphome/components/uart_tcp/uart_tcp.h @@ -14,7 +14,7 @@ namespace esphome::uart_tcp { /// Copies raw bytes between one hardware UART and one TCP socket. -class UartTcp : public Component, public uart::UARTDevice { +class UartTcp final : public Component, public uart::UARTDevice { public: void set_host(const char *host) { this->link_.set_host(host); } void set_port(uint16_t port) { this->link_.set_port(port); } diff --git a/tests/components/tcp_uart/test.esp32-idf.yaml b/tests/components/tcp_uart/test.esp32-idf.yaml index 2d42c282f75..54bdfbb390c 100644 --- a/tests/components/tcp_uart/test.esp32-idf.yaml +++ b/tests/components/tcp_uart/test.esp32-idf.yaml @@ -1,2 +1,10 @@ packages: tcp_uart: !include common.yaml + +# load_settings() exists on ESP8266 and ESP32 only; both overloads must resolve. +interval: + - interval: 60s + then: + - lambda: |- + id(tcp_uart_server).load_settings(); + id(tcp_uart_server).load_settings(false); diff --git a/tests/components/tcp_uart/test.esp8266-ard.yaml b/tests/components/tcp_uart/test.esp8266-ard.yaml index 2d42c282f75..54bdfbb390c 100644 --- a/tests/components/tcp_uart/test.esp8266-ard.yaml +++ b/tests/components/tcp_uart/test.esp8266-ard.yaml @@ -1,2 +1,10 @@ packages: tcp_uart: !include common.yaml + +# load_settings() exists on ESP8266 and ESP32 only; both overloads must resolve. +interval: + - interval: 60s + then: + - lambda: |- + id(tcp_uart_server).load_settings(); + id(tcp_uart_server).load_settings(false); From aa02a0996ab6fbdcddd8f19a6e8fd76e96484c89 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:48:33 +0200 Subject: [PATCH 026/128] [socket][tcp_uart][uart_tcp] Reject an IPv6 host at config time (#20165) --- esphome/components/socket/__init__.py | 15 ++++++++++++ esphome/components/tcp_uart/__init__.py | 2 +- esphome/components/uart_tcp/__init__.py | 2 +- .../socket/test_socket_ipv4_host.py | 23 +++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/components/socket/test_socket_ipv4_host.py diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 269791f5e7a..3d2cd8ef50d 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -149,6 +149,21 @@ def require_wake_loop_threadsafe() -> None: IPV4_ALLOW_SCHEMA = cv.All(cv.ensure_list(cv.ipv4network), cv.Length(max=255)) +_HOST = cv.Any(cv.domain, cv.hostname) + + +def ipv4_host(value: object) -> str: + """Validate an IPv4 address or a hostname; the resolver behind it is IPv4 only.""" + value = cv.string(value) + try: + cv.ipv6address(value) + except cv.Invalid: + return _HOST(value) + raise cv.Invalid( + "IPv6 addresses are not supported, use an IPv4 address or a hostname" + ) + + def _network_order(addr: IPv4Address) -> int: """The s_addr value for addr on the little endian targets.""" return int.from_bytes(addr.packed, "little") diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index 8e14e690f9e..5481c8b3159 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -54,7 +54,7 @@ CONFIG_SCHEMA = cv.All( { "client": BASE_SCHEMA.extend( { - cv.Required(CONF_HOST): cv.string, + cv.Required(CONF_HOST): socket.ipv4_host, } ), "server": BASE_SCHEMA.extend( diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py index 3829f046ca6..6e09ecac23d 100644 --- a/esphome/components/uart_tcp/__init__.py +++ b/esphome/components/uart_tcp/__init__.py @@ -44,7 +44,7 @@ BASE_SCHEMA = cv.Schema( CONFIG_SCHEMA = cv.All( cv.typed_schema( { - "client": BASE_SCHEMA.extend({cv.Required(CONF_HOST): cv.string}), + "client": BASE_SCHEMA.extend({cv.Required(CONF_HOST): socket.ipv4_host}), "server": BASE_SCHEMA.extend( {cv.Optional(CONF_ALLOWED_IPS): socket.IPV4_ALLOW_SCHEMA} ), diff --git a/tests/unit_tests/components/socket/test_socket_ipv4_host.py b/tests/unit_tests/components/socket/test_socket_ipv4_host.py new file mode 100644 index 00000000000..7953447b955 --- /dev/null +++ b/tests/unit_tests/components/socket/test_socket_ipv4_host.py @@ -0,0 +1,23 @@ +"""Tests for the socket component's IPv4 host validator.""" + +import pytest + +from esphome.components import socket +import esphome.config_validation as cv + + +@pytest.mark.parametrize("value", ["192.0.2.10", "bridge.local", "bridge"]) +def test_ipv4_host_accepts_ipv4_address_and_hostname(value: str) -> None: + assert socket.ipv4_host(value) == value + + +@pytest.mark.parametrize("value", ["2001:db8::10", "::1", "::ffff:192.0.2.10"]) +def test_ipv4_host_rejects_ipv6_address(value: str) -> None: + with pytest.raises(cv.Invalid, match="IPv6 addresses are not supported"): + socket.ipv4_host(value) + + +@pytest.mark.parametrize("value", ["bridge host", ""]) +def test_ipv4_host_rejects_invalid_host(value: str) -> None: + with pytest.raises(cv.Invalid): + socket.ipv4_host(value) From 00303fb72b47aded066914e997cc767e63cc99dc Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:18 +0100 Subject: [PATCH 027/128] [esp32][api][noise] Resolve arduino's libsodium requirement to the converted esphome/libsodium (#20185) --- esphome/components/esp32/__init__.py | 61 ++++++++++++------- esphome/espidf/clang_tidy.py | 31 ++++++++-- tests/components/api/test.esp32-ard.yaml | 6 ++ tests/components/noise/test.esp32-ard.yaml | 2 + .../test_esp32_write_idf_component_yml.py | 46 ++++++++++++++ tests/unit_tests/test_espidf_clang_tidy.py | 48 +++++++++++++++ 6 files changed, 166 insertions(+), 28 deletions(-) create mode 100644 tests/components/api/test.esp32-ard.yaml create mode 100644 tests/components/noise/test.esp32-ard.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1166afe4ec3..be6b2c32663 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -57,7 +57,7 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError, HexInt from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.espidf.component import generate_idf_components +from esphome.espidf.component import IDFComponent, generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -3539,6 +3539,30 @@ def _write_idf_component_yml(): yml_path = CORE.relative_build_path("src/idf_component.yml") dependencies: dict[str, dict] = {} + converted: list[IDFComponent] = [] + if CORE.using_toolchain_esp_idf: + # Convert the PlatformIO libraries to ESP-IDF components as a batch so + # PlatformIO resolves the whole dependency tree at once -- deduplicating + # shared transitive deps (e.g. esphome/libsodium pulled by both noise-c + # and esp_wireguard) to a single version instead of clashing + # override_path entries. + libraries = [ + library + for name, library in CORE.platformio_libraries.items() + # Don't process arduino libraries + if name not in ARDUINO_DISABLED_LIBRARIES + ] + # A library also declared as a managed component is not converted too, or + # IDF sees the same requirement twice; converted components reach it through + # ${ESPHOME_PROJECT_MANAGED_COMPONENTS}. + managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {})) + converted = generate_idf_components(libraries, managed=managed) + # IDF names a component after its directory and a later registration of the + # same name replaces the earlier one, so a stub beside a converted library of + # the same name (espressif/libsodium vs esphome/libsodium) would win or lose + # on path order. Such a stub points at the converted library instead. + converted_by_name = {component.path.name: component for component in converted} + # For Arduino builds, override unused managed components from the Arduino framework # by pointing them to empty stub directories using override_path # This prevents the IDF component manager from downloading the real components @@ -3562,8 +3586,17 @@ def _write_idf_component_yml(): # always writes, and ninja keeps triggering CMake re-runs on # otherwise-cached rebuilds. for component_name in sorted(components_to_stub): + stub_name = _idf_component_stub_name(component_name) + stub_path = stubs_dir / stub_name + if (component := converted_by_name.get(stub_name)) is not None: + if stub_path.exists(): + rmtree(stub_path) + dependencies[_idf_component_dep_name(component_name)] = { + "version": "*", + "override_path": str(component.path), + } + continue # Create stub directory with minimal CMakeLists.txt - stub_path = stubs_dir / _idf_component_stub_name(component_name) stub_path.mkdir(exist_ok=True) stub_cmake = stub_path / "CMakeLists.txt" if not stub_cmake.exists(): @@ -3605,26 +3638,10 @@ def _write_idf_component_yml(): ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), ) - if CORE.using_toolchain_esp_idf: - # Convert the PlatformIO libraries to ESP-IDF components as a batch so - # PlatformIO resolves the whole dependency tree at once -- deduplicating - # shared transitive deps (e.g. esphome/libsodium pulled by both noise-c - # and esp_wireguard) to a single version instead of clashing - # override_path entries. - libraries = [ - library - for name, library in CORE.platformio_libraries.items() - # Don't process arduino libraries - if name not in ARDUINO_DISABLED_LIBRARIES - ] - # A library also declared as a managed component is not converted too, or - # IDF sees the same requirement twice; converted components reach it through - # ${ESPHOME_PROJECT_MANAGED_COMPONENTS}. - managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {})) - for component in generate_idf_components(libraries, managed=managed): - dependencies[component.get_sanitized_name()] = { - "override_path": str(component.path) - } + for component in converted: + dependencies[component.get_sanitized_name()] = { + "override_path": str(component.path) + } if CORE.data[KEY_ESP32][KEY_COMPONENTS]: components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS] diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index 623b352e97b..e109363d212 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -278,18 +278,25 @@ def _convert_pio_libs( return deps -def _arduino_excluded_stubs(work_dir: Path) -> dict[str, dict]: +def _arduino_excluded_stubs( + work_dir: Path, converted: dict[str, dict[str, str]] | None = None +) -> dict[str, dict]: """Stub the arduino-bundled IDF components ESPHome doesn't use. arduino-esp32 declares deps (libsodium, RainMaker, modbus, ...) that ESPHome replaces with its own library (noise-c) or doesn't use; point each at an empty override_path component so the IDF manager doesn't resolve/download - them -- notably so ``espressif/libsodium`` doesn't clash with the converted - noise-c's ``libsodium``. Mirrors esp32's ``_write_idf_component_yml``. + them. Mirrors esp32's ``_write_idf_component_yml``. Components ESPHome's own idf_component.yml provides (e.g. lan867x for ethernet) are NOT stubbed -- those are real deps we need, and arduino-esp32 resolves to the same component rather than conflicting. + + ``converted`` is the manifest block from ``_convert_pio_libs``. IDF names a + component after its directory and a later registration of the same name + replaces the earlier one, so a stub beside a converted library of the same + name (``espressif/libsodium`` vs ``esphome/libsodium``) would win or lose on + path order; such a stub points at the converted library instead. """ from esphome.components.esp32 import ( _idf_component_dep_name, @@ -298,6 +305,10 @@ def _arduino_excluded_stubs(work_dir: Path) -> dict[str, dict]: ) esphome_deps = _esphome_manifest_deps() + converted_paths = { + Path(dep["override_path"]).name: dep["override_path"] + for dep in (converted or {}).values() + } stubs_dir = work_dir / "component_stubs" stubs_dir.mkdir(parents=True, exist_ok=True) @@ -305,7 +316,14 @@ def _arduino_excluded_stubs(work_dir: Path) -> dict[str, dict]: for component in sorted(arduino_excluded_idf_components()): if _idf_component_dep_name(component) in esphome_deps: continue # ESPHome needs this one for real (don't stub it away) - stub_path = stubs_dir / _idf_component_stub_name(component) + stub_name = _idf_component_stub_name(component) + if (path := converted_paths.get(stub_name)) is not None: + deps[_idf_component_dep_name(component)] = { + "version": "*", + "override_path": path, + } + continue + stub_path = stubs_dir / stub_name stub_path.mkdir(exist_ok=True) (stub_path / "CMakeLists.txt").write_text( "idf_component_register()\n", encoding="utf-8" @@ -401,11 +419,12 @@ def _generate_compile_commands( # Framework deps (e.g. arduino-esp32) + PlatformIO libs converted to local # IDF components, all added to the manifest as deps. extra_deps = dict(settings.framework_deps) - extra_deps.update(_convert_pio_libs(platformio_ini, settings.target_framework)) + converted = _convert_pio_libs(platformio_ini, settings.target_framework) + extra_deps.update(converted) if settings.target_framework == "arduino": # Stub the arduino-bundled components ESPHome doesn't use (avoids the # libsodium clash with noise-c and ~26 unused heavy downloads). - extra_deps.update(_arduino_excluded_stubs(work_dir)) + extra_deps.update(_arduino_excluded_stubs(work_dir, converted)) # Phase 1: discover the components available for this target. _write_tidy_project(work_dir, [], extra_deps, settings) diff --git a/tests/components/api/test.esp32-ard.yaml b/tests/components/api/test.esp32-ard.yaml new file mode 100644 index 00000000000..bd0915f0ef0 --- /dev/null +++ b/tests/components/api/test.esp32-ard.yaml @@ -0,0 +1,6 @@ +packages: + api: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/noise/test.esp32-ard.yaml b/tests/components/noise/test.esp32-ard.yaml new file mode 100644 index 00000000000..550ffd1f886 --- /dev/null +++ b/tests/components/noise/test.esp32-ard.yaml @@ -0,0 +1,2 @@ +packages: + noise: !include common.yaml diff --git a/tests/unit_tests/components/test_esp32_write_idf_component_yml.py b/tests/unit_tests/components/test_esp32_write_idf_component_yml.py index 931de627a1a..878780cba83 100644 --- a/tests/unit_tests/components/test_esp32_write_idf_component_yml.py +++ b/tests/unit_tests/components/test_esp32_write_idf_component_yml.py @@ -10,11 +10,13 @@ from pathlib import Path from unittest.mock import MagicMock import pytest +import yaml from esphome.components import esp32 import esphome.config_validation as cv from esphome.const import ( KEY_CORE, + KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, Framework, @@ -173,3 +175,47 @@ def test_write_idf_component_yml_arduino_stubs_follow_idf_version( / esp32._idf_component_stub_name("espressif__libsodium") ) assert stub_dir.is_dir() is libsodium_stubbed + + +def test_write_idf_component_yml_stub_yields_to_converted_library_of_same_name( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """IDF names a component after its directory and the later registration wins, + so the espressif/libsodium stub and the converted esphome/libsodium raced on + path order (#20102). The stub entry points at the converted library instead.""" + _setup_core(tmp_path) + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = str(Framework.ARDUINO) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(3, 3, 11) + CORE.data[esp32.KEY_ESP32] = { + esp32.KEY_COMPONENTS: {}, + esp32.KEY_IDF_VERSION: cv.Version(5, 5, 5), + esp32.KEY_ARDUINO_LIBRARIES: set(), + } + # A stale stub from an earlier build must not linger beside the real library + stale_stub = tmp_path / "component_stubs" / "libsodium" + stale_stub.mkdir(parents=True) + + converted = MagicMock() + converted.get_sanitized_name.return_value = "esphome/libsodium" + converted.path = tmp_path / "pio_components" / "esphome" / "libsodium" + monkeypatch.setattr( + esp32, "generate_idf_components", lambda libraries, managed=None: [converted] + ) + + esp32._write_idf_component_yml() + + manifest = yaml.safe_load( + (tmp_path / "src" / "idf_component.yml").read_text(encoding="utf-8") + )["dependencies"] + assert manifest["espressif/libsodium"] == { + "version": "*", + "override_path": str(converted.path), + } + assert manifest["esphome/libsodium"] == {"override_path": str(converted.path)} + assert not stale_stub.exists() + # Stubs without a converted namesake are unaffected + assert (tmp_path / "component_stubs" / "cbor").is_dir() + assert manifest["espressif/cbor"]["override_path"] == str( + tmp_path / "component_stubs" / "cbor" + ) diff --git a/tests/unit_tests/test_espidf_clang_tidy.py b/tests/unit_tests/test_espidf_clang_tidy.py index b0d016b1b43..2516a9012f1 100644 --- a/tests/unit_tests/test_espidf_clang_tidy.py +++ b/tests/unit_tests/test_espidf_clang_tidy.py @@ -153,6 +153,33 @@ def test_generate_compile_commands_configures_twice( assert mock_write.call_args_list[1].args[1] == ["esp_timer", "lwip"] +def test_generate_compile_commands_hands_converted_libs_to_arduino_stubs( + tmp_path: Path, +) -> None: + """On Arduino the stub generator sees the converted libraries, so a stub + sharing a converted library's directory name can point at it instead.""" + converted = {"esphome/libsodium": {"override_path": str(tmp_path / "libsodium")}} + stubs = {"espressif/libsodium": {"version": "*", "override_path": "x"}} + with ( + patch.object(clang_tidy, "_setup_core"), + patch.object(clang_tidy, "_convert_pio_libs", return_value=converted), + patch.object( + clang_tidy, "_arduino_excluded_stubs", return_value=stubs + ) as mock_stubs, + patch.object(clang_tidy, "_write_tidy_project") as mock_write, + patch("esphome.espidf.toolchain.run_reconfigure", return_value=0), + patch("esphome.build_gen.espidf.get_available_components", return_value=[]), + ): + clang_tidy._generate_compile_commands( + tmp_path, _settings(target_framework="arduino"), tmp_path / "platformio.ini" + ) + + mock_stubs.assert_called_once_with(tmp_path, converted) + extra_deps = mock_write.call_args_list[0].args[2] + assert extra_deps["esphome/libsodium"] == converted["esphome/libsodium"] + assert extra_deps["espressif/libsodium"] == stubs["espressif/libsodium"] + + def test_esphome_manifest_deps_reads_repo_manifest() -> None: """Top-level dependency names, independent of the per-dependency rules.""" manifest = yaml.safe_load( @@ -284,6 +311,27 @@ def test_arduino_excluded_stubs_skips_components_esphome_manifest_provides( assert (stub_path / "CMakeLists.txt").is_file() +def test_arduino_excluded_stubs_points_libsodium_at_converted_library( + tmp_path: Path, +) -> None: + """Below IDF 6.0 the converted esphome/libsodium shares its directory name + with the espressif/libsodium stub; IDF would keep whichever registers last, + so the stub entry points at the converted library instead (#20102).""" + _set_idf_version(cv.Version(5, 5, 4)) + converted_path = str(tmp_path / "pio" / "esphome" / "libsodium") + converted = {"esphome/libsodium": {"override_path": converted_path}} + + deps = _arduino_excluded_stubs(tmp_path, converted) + + assert deps["espressif/libsodium"] == { + "version": "*", + "override_path": converted_path, + } + assert not (tmp_path / "component_stubs" / "libsodium").exists() + # Stubs without a converted namesake are unaffected. + assert (tmp_path / "component_stubs" / "cbor" / "CMakeLists.txt").is_file() + + def test_arduino_excluded_stubs_skips_libsodium_from_idf_6(tmp_path: Path) -> None: """From IDF 6.0 arduino-esp32 drops espressif/libsodium; a stub would clash with esphome/libsodium.""" From ca74e71348eec007e2ed820fa7848f3e14a53722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:16:21 +0200 Subject: [PATCH 028/128] [emontx] Use total state class for energy sensors (#20128) Co-authored-by: Claude Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/emontx/sensor/__init__.py | 3 ++- tests/component_tests/emontx/test_sensor_defaults.py | 11 +++++------ tests/components/emontx/validate.esp32-idf.yaml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/emontx/sensor/__init__.py b/esphome/components/emontx/sensor/__init__.py index 56a7fb8b559..eadba1ea3f8 100644 --- a/esphome/components/emontx/sensor/__init__.py +++ b/esphome/components/emontx/sensor/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, @@ -70,7 +71,7 @@ SENSOR_CONFIGS = { "E": { CONF_UNIT_OF_MEASUREMENT: UNIT_WATT_HOURS, CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + CONF_STATE_CLASS: STATE_CLASS_TOTAL, CONF_ACCURACY_DECIMALS: 0, }, "V": { diff --git a/tests/component_tests/emontx/test_sensor_defaults.py b/tests/component_tests/emontx/test_sensor_defaults.py index 5c6a8e41082..2d64f92be67 100644 --- a/tests/component_tests/emontx/test_sensor_defaults.py +++ b/tests/component_tests/emontx/test_sensor_defaults.py @@ -18,6 +18,7 @@ from esphome.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, @@ -42,14 +43,12 @@ def _resolve_via_config_schema(tag: str) -> dict: def test_config_schema_applies_tag_default_state_class(): """If sensor_schema(state_class=...) is reintroduced, the schema-level default wins over apply_tag_defaults' per-prefix value, and E1 would - resolve to measurement instead of total_increasing. Driving the real + resolve to measurement instead of total. Driving the real CONFIG_SCHEMA (not just apply_tag_defaults) catches that, since sensor_schema() runs before apply_tag_defaults in the cv.All() chain. """ result = _resolve_via_config_schema("E1") - assert result[CONF_STATE_CLASS] == sensor.validate_state_class( - STATE_CLASS_TOTAL_INCREASING - ) + assert result[CONF_STATE_CLASS] == sensor.validate_state_class(STATE_CLASS_TOTAL) def test_config_schema_applies_tag_default_accuracy_decimals(): @@ -70,8 +69,8 @@ def _make_config(tag: str) -> dict: ("tag", "expected_state_class", "expected_decimals"), [ # Known numeric-index prefixes - ("E1", STATE_CLASS_TOTAL_INCREASING, 0), - ("E12", STATE_CLASS_TOTAL_INCREASING, 0), + ("E1", STATE_CLASS_TOTAL, 0), + ("E12", STATE_CLASS_TOTAL, 0), ("P1", STATE_CLASS_MEASUREMENT, 0), ("V1", STATE_CLASS_MEASUREMENT, 2), ("I1", STATE_CLASS_MEASUREMENT, 2), diff --git a/tests/components/emontx/validate.esp32-idf.yaml b/tests/components/emontx/validate.esp32-idf.yaml index 882ee26fcbc..548a3c9c410 100644 --- a/tests/components/emontx/validate.esp32-idf.yaml +++ b/tests/components/emontx/validate.esp32-idf.yaml @@ -8,7 +8,7 @@ packages: # exercised, not the user-override path. sensor: - # Energy sensor (E prefix): expects state_class=total_increasing, unit=Wh, + # Energy sensor (E prefix): expects state_class=total, unit=Wh, # device_class=energy, accuracy_decimals=0 - platform: emontx tag_name: E1 From 9a97419cfc06265e636a65f76944125adc4276d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 10:21:23 -0500 Subject: [PATCH 029/128] [ethernet] Store PHY registers in a StaticVector sized from the config (#20131) --- esphome/components/ethernet/__init__.py | 29 ++++++++++--------- .../components/ethernet/ethernet_component.h | 10 +++++-- .../ethernet/ethernet_component_esp32.cpp | 8 ++--- esphome/core/defines.h | 1 + tests/components/ethernet/common-rtl8201.yaml | 6 ++++ 5 files changed, 34 insertions(+), 20 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index ce7206bf57a..ff05caca83e 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -636,6 +636,19 @@ def phy_register(address: int, value: int, page: int) -> cg.StructInitializer: ) +def _add_phy_registers(var: cg.MockObj, config: ConfigType) -> None: + if not (registers := config.get(CONF_PHY_REGISTERS)): + return + cg.add_define("ESPHOME_ETHERNET_PHY_REGISTER_COUNT", len(registers)) + for register_value in registers: + reg = phy_register( + register_value.get(CONF_ADDRESS), + register_value.get(CONF_VALUE), + register_value.get(CONF_PAGE_ID), + ) + cg.add(var.add_phy_register(reg)) + + @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) @@ -741,13 +754,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) if CONF_POWER_PIN in config: cg.add(var.set_power_pin(config[CONF_POWER_PIN])) - for register_value in config.get(CONF_PHY_REGISTERS, []): - reg = phy_register( - register_value.get(CONF_ADDRESS), - register_value.get(CONF_VALUE), - register_value.get(CONF_PAGE_ID), - ) - cg.add(var.add_phy_register(reg)) + _add_phy_registers(var, config) else: cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) @@ -756,13 +763,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_clk_pin(config[CONF_CLK][CONF_PIN])) if CONF_POWER_PIN in config: cg.add(var.set_power_pin(config[CONF_POWER_PIN])) - for register_value in config.get(CONF_PHY_REGISTERS, []): - reg = phy_register( - register_value.get(CONF_ADDRESS), - register_value.get(CONF_VALUE), - register_value.get(CONF_PAGE_ID), - ) - cg.add(var.add_phy_register(reg)) + _add_phy_registers(var, config) # Register Ethernet with the esp32 sdkconfig reconciler. It disables the # WiFi stack and WiFi/BT coexistence only when Ethernet runs without WiFi, diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 71fa9d40b67..44c4d6733d3 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -193,7 +193,9 @@ class EthernetComponent final : public Component { void set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } void set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } - void add_phy_register(PHYRegister register_value); +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT + void add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } +#endif #endif // USE_ETHERNET_SPI #endif // USE_ESP32 @@ -255,8 +257,10 @@ class EthernetComponent final : public Component { /// reset) and set the RGMII Tx/Rx clock delays needed for reliable data sampling. void yt8531_phy_init_(); #endif +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT /// @brief Set arbitratry PHY registers from config. void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data); +#endif #ifdef USE_ETHERNET_SPI uint8_t clk_pin_; @@ -280,7 +284,9 @@ class EthernetComponent final : public Component { // Group all 32-bit members first int power_pin_{-1}; emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN}; - std::vector phy_registers_{}; +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT + StaticVector phy_registers_{}; +#endif // Group all 8-bit members together uint8_t clk_pin_{0}; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 2c3c5f1ef98..e9281453c90 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -462,9 +462,11 @@ void EthernetComponent::ethernet_lazy_init_() { } #endif // USE_ETHERNET_KSZ8081 +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT for (const auto &phy_register : this->phy_registers_) { this->write_phy_register_(mac, phy_register); } +#endif #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) #ifdef USE_ETHERNET_GENERIC @@ -1043,10 +1045,6 @@ void EthernetComponent::dump_connect_params_() { #endif /* USE_NETWORK_IPV6 */ } -#ifndef USE_ETHERNET_SPI -void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } -#endif - void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { if (!this->ethernet_initialized_) { // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless @@ -1144,6 +1142,7 @@ void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { } #endif // USE_ETHERNET_KSZ8081 +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) { esp_err_t err; @@ -1168,6 +1167,7 @@ void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister regi } #endif } +#endif // ESPHOME_ETHERNET_PHY_REGISTER_COUNT #ifdef USE_ETHERNET_YT8531 void EthernetComponent::yt8531_phy_init_() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 97b13955537..be8d89c5572 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -491,6 +491,7 @@ #define USE_ETHERNET_CONNECT_TRIGGER #define USE_ETHERNET_DISCONNECT_TRIGGER #define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 +#define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 #endif #ifdef USE_ESP32 diff --git a/tests/components/ethernet/common-rtl8201.yaml b/tests/components/ethernet/common-rtl8201.yaml index d5a60f6e983..5e24017ff30 100644 --- a/tests/components/ethernet/common-rtl8201.yaml +++ b/tests/components/ethernet/common-rtl8201.yaml @@ -7,6 +7,12 @@ ethernet: mode: CLK_EXT_IN phy_addr: 0 power_pin: 33 + phy_registers: + - address: 0x10 + value: 0x1FFA + page_id: 0x07 + - address: 0x19 + value: 0x0006 manual_ip: static_ip: 192.168.178.56 gateway: 192.168.178.1 From 1110857ffaa6123b3135d57e9936c9a47dbec653 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 10:21:52 -0500 Subject: [PATCH 030/128] [wifi] Keep ESP8266's report of a replaced attempt from failing the next one (#20183) --- esphome/components/wifi/wifi_component.cpp | 7 ++++--- esphome/components/wifi/wifi_component_esp8266.cpp | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 125139ad163..86e13ef84b0 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -762,7 +762,7 @@ void WiFiComponent::restart_adapter() { // and check_connecting_finished() is called after cooldown without going // through start_connecting() first. Without this clear, stale errors would // trigger spurious "failed (callback)" logs. The canonical clear location - // is in start_connecting(); this is the only exception to that pattern. + // is in start_connecting() (ESP8266 also clears after wifi_station_connect()). this->error_from_callback_ = false; } @@ -1218,8 +1218,9 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { // Clear any stale error from previous connection attempt. // This is the canonical location for clearing the flag since all connection - // attempts go through start_connecting(). The only other clear is in - // restart_adapter() which enters COOLDOWN without calling start_connecting(). + // attempts go through start_connecting(). restart_adapter() also clears it, as it + // enters COOLDOWN without calling start_connecting(), and ESP8266 clears it again + // after wifi_station_connect(), whose callbacks fire synchronously. this->error_from_callback_ = false; if (!this->wifi_sta_connect_(ap)) { diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 60ec3f9a4d5..aa1fa67b7bc 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -376,17 +376,17 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { this->wifi_apply_hostname_(); - // Reset flags, do this _before_ wifi_station_connect as the callback method - // may be called from wifi_station_connect - this->sta_state_ = static_cast(ESP8266WiFiSTAState::CONNECTING); - ETS_UART_INTR_DISABLE(); ret = wifi_station_connect(); ETS_UART_INTR_ENABLE(); if (!ret) { ESP_LOGV(TAG, "wifi_station_connect failed"); + this->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); return false; } + // Reset after, not before: wifi_station_connect() synchronously reports the attempt it replaces as failed + this->sta_state_ = static_cast(ESP8266WiFiSTAState::CONNECTING); + this->error_from_callback_ = false; #if USE_NETWORK_IPV6 bool connected = false; From 5be0aca6d2396a708a191b75536770b8e4f2b0d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 10:24:19 -0500 Subject: [PATCH 031/128] [libretiny] Build LN882x with 1-byte enums to match the SDK's WiFi library (#20173) --- esphome/components/libretiny/__init__.py | 4 +++ esphome/components/libretiny/hal.cpp | 6 +++++ tests/component_tests/libretiny/__init__.py | 0 .../libretiny/config/bk72xx.yaml | 5 ++++ .../libretiny/config/ln882x.yaml | 5 ++++ .../libretiny/test_short_enums.py | 27 +++++++++++++++++++ 6 files changed, 47 insertions(+) create mode 100644 tests/component_tests/libretiny/__init__.py create mode 100644 tests/component_tests/libretiny/config/bk72xx.yaml create mode 100644 tests/component_tests/libretiny/config/ln882x.yaml create mode 100644 tests/component_tests/libretiny/test_short_enums.py diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 5d87dee07a3..5d630fec6cc 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -526,6 +526,10 @@ async def component_to_code(config): # RAM-executable output section and prints a post-link placement summary. if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX: cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) + # Match the 1-byte enums of the LN882H SDK's prebuilt WiFi library. + if FAMILY_COMPONENT[config[CONF_FAMILY]] == COMPONENT_LN882X: + cg.add_build_unflag("-fno-short-enums") + cg.add_build_flag("-fshort-enums") # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) # decrease web server stack size (16k words -> 4k words) diff --git a/esphome/components/libretiny/hal.cpp b/esphome/components/libretiny/hal.cpp index 01b276005dd..f97a5a19a7a 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -12,6 +12,12 @@ // libretiny component's API. namespace esphome::libretiny {} // namespace esphome::libretiny +#if defined(USE_LN882X) && !defined(CLANG_TIDY) // clang-tidy runs without the PlatformIO flags +// Fail the build if the -fshort-enums swap in __init__.py stops working. +enum Ln882xEnumSizeCheck { LN882X_ENUM_SIZE_CHECK }; +static_assert(sizeof(Ln882xEnumSizeCheck) == 1, "LN882x must build with -fshort-enums to match the SDK"); +#endif + namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), diff --git a/tests/component_tests/libretiny/__init__.py b/tests/component_tests/libretiny/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/libretiny/config/bk72xx.yaml b/tests/component_tests/libretiny/config/bk72xx.yaml new file mode 100644 index 00000000000..35450f1039b --- /dev/null +++ b/tests/component_tests/libretiny/config/bk72xx.yaml @@ -0,0 +1,5 @@ +esphome: + name: test + +bk72xx: + board: generic-bk7231n-qfn32-tuya diff --git a/tests/component_tests/libretiny/config/ln882x.yaml b/tests/component_tests/libretiny/config/ln882x.yaml new file mode 100644 index 00000000000..4d0381737a6 --- /dev/null +++ b/tests/component_tests/libretiny/config/ln882x.yaml @@ -0,0 +1,5 @@ +esphome: + name: test + +ln882x: + board: generic-ln882h diff --git a/tests/component_tests/libretiny/test_short_enums.py b/tests/component_tests/libretiny/test_short_enums.py new file mode 100644 index 00000000000..185c47c506a --- /dev/null +++ b/tests/component_tests/libretiny/test_short_enums.py @@ -0,0 +1,27 @@ +"""LN882x builds with 1-byte enums to match the SDK's prebuilt libraries; +the other LibreTiny families keep LibreTiny's own enum size.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + + +def test_ln882x_swaps_in_short_enums( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("ln882x.yaml")) + assert "-fno-short-enums" in CORE.build_unflags + assert "-fshort-enums" in CORE.build_flags + + +def test_bk72xx_keeps_libretiny_enums( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("bk72xx.yaml")) + assert "-fno-short-enums" not in CORE.build_unflags + assert "-fshort-enums" not in CORE.build_flags From 4258b226a429f60c1bce39e3af9da89dd12ac7f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 10:26:22 -0500 Subject: [PATCH 032/128] [wifi] Default LN882x power save to none, as on BK72xx and RTL87xx (#20170) --- esphome/components/wifi/__init__.py | 2 +- tests/component_tests/wifi/config/bare_ln882x.yaml | 12 ++++++++++++ tests/component_tests/wifi/test_default_setters.py | 4 +++- 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/wifi/config/bare_ln882x.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index fa552da9692..553c737dbcf 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -514,7 +514,7 @@ CONFIG_SCHEMA = cv.All( rp2="light", bk72xx="none", rtl87xx="none", - ln882x="light", + ln882x="none", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), cv.Optional(CONF_FAST_CONNECT, default=False): _fast_connect_schema, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, diff --git a/tests/component_tests/wifi/config/bare_ln882x.yaml b/tests/component_tests/wifi/config/bare_ln882x.yaml new file mode 100644 index 00000000000..885a469b8df --- /dev/null +++ b/tests/component_tests/wifi/config/bare_ln882x.yaml @@ -0,0 +1,12 @@ +--- +esphome: + name: test + +ln882x: + board: generic-ln882h + +wifi: + ssid: test + password: testtest + ap: + ssid: fallback diff --git a/tests/component_tests/wifi/test_default_setters.py b/tests/component_tests/wifi/test_default_setters.py index b326f3eaeeb..dfac443060b 100644 --- a/tests/component_tests/wifi/test_default_setters.py +++ b/tests/component_tests/wifi/test_default_setters.py @@ -6,7 +6,9 @@ from pathlib import Path import pytest -@pytest.mark.parametrize("config_file", ["bare.yaml", "defaults.yaml"]) +@pytest.mark.parametrize( + "config_file", ["bare.yaml", "defaults.yaml", "bare_ln882x.yaml"] +) def test_default_values_are_not_emitted( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From 9820896430a7814af8725405446011cb09c184f1 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:34:07 +0100 Subject: [PATCH 033/128] [wireguard] Bump the library to esphome/wireguard 0.4.7 (#20188) --- esphome/components/wireguard/__init__.py | 4 ++-- esphome/idf_component.yml | 2 +- platformio.ini | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index ac62812fcf9..94af5591755 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -143,12 +143,12 @@ async def to_code(config): if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_LWIP_PPP_SUPPORT", True) - # This flag is added here because the esp_wireguard library statically + # This flag is added here because the wireguard library statically # set the size of its allowed_ips list at compile time using this value; # the '+1' modifier is relative to the device's own address that will # be automatically added to the provided list. cg.add_build_flag(f"-DCONFIG_WIREGUARD_MAX_SRC_IPS={len(allowed_ips) + 1}") - cg.add_library("droscy/esp_wireguard", "0.4.5") + cg.add_library("esphome/wireguard", "0.4.7") await cg.register_component(var, config) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index fc8090a322e..58027cb33f8 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -112,7 +112,7 @@ dependencies: version: 0.1.30 rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0" - # Declared alongside noise-c so the library converter skips esp_wireguard's copy + # Declared alongside noise-c so the library converter skips wireguard's copy esphome/libsodium: version: 1.10021.11 rules: diff --git a/platformio.ini b/platformio.ini index 00fad1ff660..deb4bf8ea0b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -130,7 +130,7 @@ lib_deps = ESP8266HTTPClient ; http_request (Arduino built-in) ESP8266mDNS ; mdns (Arduino built-in) DNSServer ; captive_portal (Arduino built-in) - droscy/esp_wireguard@0.4.5 ; wireguard + esphome/wireguard@0.4.7 ; wireguard build_flags = ${common:arduino.build_flags} @@ -165,7 +165,7 @@ lib_deps = DNSServer ; captive_portal (Arduino built-in) makuna/NeoPixelBus@2.8.0 ; neopixelbus esphome/ESP32-audioI2S@2.3.0 ; i2s_audio - droscy/esp_wireguard@0.4.5 ; wireguard + esphome/wireguard@0.4.7 ; wireguard build_flags = ${common:arduino.build_flags} @@ -188,7 +188,7 @@ platform_packages = framework = espidf lib_deps = ${common:idf.lib_deps} - droscy/esp_wireguard@0.4.5 ; wireguard + esphome/wireguard@0.4.7 ; wireguard tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = ${common:idf.build_flags} @@ -246,7 +246,7 @@ lib_deps = ${common.lib_deps_base} ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base - droscy/esp_wireguard@0.4.5 ; wireguard + esphome/wireguard@0.4.7 ; wireguard esphome/noise-c@0.1.30 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal From bc388b1de529e96909d947faf56a5cdd8562b570 Mon Sep 17 00:00:00 2001 From: Sebastian Noack Date: Mon, 5 Oct 2026 11:51:49 -0400 Subject: [PATCH 034/128] [epaper_spi] Add SSD1681 and the Waveshare 1.54in V2 panel (#18073) --- .../components/epaper_spi/models/ssd1683.py | 12 ++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/esphome/components/epaper_spi/models/ssd1683.py b/esphome/components/epaper_spi/models/ssd1683.py index b43168a336e..eb51ba1f2af 100644 --- a/esphome/components/epaper_spi/models/ssd1683.py +++ b/esphome/components/epaper_spi/models/ssd1683.py @@ -26,3 +26,15 @@ goodisplay_gdey042t81 = ssd1683.extend( width=400, height=300, ) + +# The SSD1681 shares the SSD1683 command set for everything this driver uses: +# the same 0x01/0x11/0x18 initialisation, the same 0x44/0x45 window and +# 0x4E/0x4F cursor registers with a byte-addressed X and a 16-bit Y, and the +# same 0x22/0x20 update. It only differs in the panel sizes it drives. +ssd1681 = SSD1683("ssd1681") + +waveshare_1_54in_v2 = ssd1681.extend( + "waveshare-1.54in-v2", + width=200, + height=200, +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 1678f463311..7a4ce3a5ff1 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -190,6 +190,24 @@ display: allow_other_uses: true number: GPIO4 + # Waveshare 1.54" V2 mono e-paper (200x200, SSD1681) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-1.54in-v2 + full_update_every: 30 + 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 + # Waveshare 2.13" V4 B series 3-color e-paper (122x250, BWR, SSD1680) - platform: epaper_spi spi_id: spi_bus From 417fdd9dc76ef0a5a29e151cbf32a43e10d068c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 10:53:35 -0500 Subject: [PATCH 035/128] [nrf52] Share the ccache between devices (#20028) --- esphome/components/nrf52/__init__.py | 19 +++-- esphome/components/nrf52/framework.py | 78 ++++++++++++++++--- tests/unit_tests/components/nrf52/test_pch.py | 75 ++++++++++++++---- tests/unit_tests/test_nrf52_framework.py | 49 +++++++++++- 4 files changed, 183 insertions(+), 38 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 1e841fffcfc..86d485777ca 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -27,6 +27,7 @@ from esphome.components.zephyr.const import ( CONF_CDC_ACM, KEY_BOARD, KEY_BOOTLOADER, + KEY_SYSBUILD, KEY_ZEPHYR, CdcAcm, ) @@ -77,6 +78,7 @@ from .framework import ( get_build_paths, setup_platformio_python_env, toolchain_tool, + wanted_west_projects, ) # force import gpio to register pin schema @@ -822,19 +824,22 @@ _PCH_SUM_PATH = "CMakeFiles/app.dir/cmake_pch.hxx.gch.sum" def _write_pch_checksum(build_dir: Path, source_dir: Path) -> None: - """Write the checksum ccache reads in place of the .gch. The app binary - dir only exists after the first configure; sysbuild nests it.""" - app_dir = build_dir / "zephyr" - if not (app_dir / "CMakeCache.txt").is_file(): - app_dir = build_dir - if not (app_dir / "CMakeCache.txt").is_file(): - return + """Write the checksum ccache reads in place of the .gch; before the + first build too, or its compiles hash the path laden .gch instead. + The app image dir follows the SDK version, like get_elf_path; + 2.9.2+ always wraps the build in sysbuild.""" + app_dir = build_dir + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 9, 2): + app_dir = build_dir / "zephyr" checksum = pch.pch_checksum( CORE.relative_src_path(), pch.PCH_DEFAULT_HEADERS, ( str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), zephyr_data()[KEY_BOARD], + # Kconfig inputs that reach autoconf.h without a .conf line + ",".join(sorted(wanted_west_projects())), + str(zephyr_data().get(KEY_SYSBUILD)), # What the Zephyr configuration is generated from *( path.read_text(encoding="utf-8") diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index e6ccde6a2d0..00796562f42 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -225,6 +225,19 @@ def get_build_env(ccache: str | None) -> dict: env.setdefault("CCACHE_DISABLE", "1") else: env.update(ccache_env(ccache, SDK_NRF_TOOLS_CACHE)) + # Drop only the per build map entry (posix, CMake's spelling); + # its from side covers no compiled sources. A spaced path cannot + # survive ccache's space split list, so it stays hashed. + source_dir = CORE.relative_build_path("zephyr").as_posix() + if any(ch.isspace() for ch in source_dir): + _LOGGER.debug( + "Whitespace in %s; the per build map stays hashed", source_dir + ) + else: + device_map = f"-fmacro-prefix-map={source_dir}=CMAKE_SOURCE_DIR" + env["CCACHE_IGNOREOPTIONS"] = ( + f"{env.get('CCACHE_IGNOREOPTIONS', '')} {device_map}".strip() + ) return env @@ -301,21 +314,62 @@ def setup_platformio_python_env() -> None: _prepend_env_path("PATH", str(env_python_path.parent)) +def _patch_framework_file(path: Path, old: str, new: str) -> bool: + """Replace ``old`` with ``new`` in a framework script, atomically and + keeping the file mode (helpers.write_file would flatten it to 0o644). + Returns False when nothing matched.""" + import tempfile + + content = path.read_text(encoding="utf-8") + patched = content.replace(old, new) + if patched == content: + return False + # Unique sibling tmp: the install lock is best effort, so two builds + # may patch at once and a shared tmp name could rename a half + # written file into place. + fd, tmp_name = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(patched) + shutil.copymode(path, tmp) + tmp.replace(path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + return True + + def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: # SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that # Python 3.12+ flags with SyntaxWarning (a future version will reject it). uf2conv = framework_path / "zephyr" / "scripts" / "build" / "uf2conv.py" - if not uf2conv.exists(): + if uf2conv.exists(): + _patch_framework_file( + uf2conv, "re.split('\\s+', line)", "re.split('\\\\s+', line)" + ) + + +def _patch_gen_defines_dts_path(framework_path: Path) -> None: + # The absolute zephyr.dts.pre path in the header's top comment is + # its only per device byte and blocks ccache sharing; emit the + # basename. Upstream candidate. + gen_defines = framework_path / "zephyr" / "scripts" / "dts" / "gen_defines.py" + if not gen_defines.exists(): return - content = uf2conv.read_text(encoding="utf-8") - patched = content.replace("re.split('\\s+', line)", "re.split('\\\\s+', line)") - if patched == content: + if _patch_framework_file( + gen_defines, " {edt.dts_path}", " {os.path.basename(edt.dts_path)}" + ): return - # Write atomically so a concurrent build never sees a truncated file - tmp = uf2conv.with_suffix(".py.tmp") - tmp.write_text(patched, encoding="utf-8") - shutil.copymode(uf2conv, tmp) - tmp.replace(uf2conv) + if "{os.path.basename(edt.dts_path)}" not in gen_defines.read_text( + encoding="utf-8" + ): + # Upstream reformatted the comment; sharing silently degrading + # would be invisible, so say it out loud. + _LOGGER.warning( + "gen_defines.py no longer matches; the devicetree header " + "stays per device and ccache sharing between devices degrades" + ) # West projects every build needs; components add others with include_west_project() @@ -349,7 +403,7 @@ def bluetooth_west_projects() -> tuple[str, ...]: return ("tinycrypt",) -def _wanted_west_projects() -> set[str]: +def wanted_west_projects() -> set[str]: projects = set(_get_data().west_projects) # Zephyr 4.1 moved the Cortex-M core headers to cmsis_6 if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(3, 1, 0): @@ -587,7 +641,7 @@ def _check_and_install(version: str) -> None: framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt" - projects = _wanted_west_projects() + projects = wanted_west_projects() if not sentinel.exists() or not zephyr_reqs.exists(): _install_framework(env_python_path, framework_path, version, projects) framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] @@ -596,6 +650,8 @@ def _check_and_install(version: str) -> None: sentinel.touch() else: _fetch_missing_west_projects(env_python_path, framework_path, version, projects) + # Every run: existing installs need it too, and it is a no-op once applied + _patch_gen_defines_dts_path(framework_path) zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" if ( diff --git a/tests/unit_tests/components/nrf52/test_pch.py b/tests/unit_tests/components/nrf52/test_pch.py index 41635c35808..7eb2fe10f3e 100644 --- a/tests/unit_tests/components/nrf52/test_pch.py +++ b/tests/unit_tests/components/nrf52/test_pch.py @@ -5,9 +5,11 @@ from unittest.mock import Mock, patch import pytest +from esphome.build_helpers import pch from esphome.components import nrf52 from esphome.components.nrf52 import framework -from esphome.components.zephyr.const import KEY_BOARD +from esphome.components.nrf52.toolchain import get_elf_path +from esphome.components.zephyr.const import KEY_BOARD, KEY_SYSBUILD import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain from esphome.core import CORE, EsphomeError @@ -55,8 +57,6 @@ def test_cmake_lists_pch_block_disabled(tmp_path: Path) -> None: def test_the_zephyr_compiler_decides_on_windows( windows_gcc_rule: None, version: tuple[int, ...], on: bool ) -> None: - from esphome.build_helpers import pch - # platformdirs would pick its Windows backend from the patched sys.platform with ( patch.object(nrf52, "toolchain_tool", lambda name: Path(f"/sdk/{name}.exe")), @@ -66,8 +66,16 @@ def test_the_zephyr_compiler_decides_on_windows( assert asked.call_args.args[0] == (Path("/sdk/g++.exe"),) -def _write_checksum(tmp_path: Path, app: str, conf: str = "CONFIG_X=y\n") -> Path: +def _write_checksum( + tmp_path: Path, + app: str, + conf: str = "CONFIG_X=y\n", + configured: bool = True, + sysbuild: bool = False, + version: cv.Version | None = None, +) -> Path: """Write the checksum for a build dir whose app image sits in ``app``.""" + version = version or cv.Version(2, 9, 2) CORE.build_path = tmp_path header = tmp_path / "src" / "esphome" / "core" / "pch_prefix.h" header.parent.mkdir(parents=True, exist_ok=True) @@ -77,33 +85,65 @@ def _write_checksum(tmp_path: Path, app: str, conf: str = "CONFIG_X=y\n") -> Pat (source_dir / "prj.conf").write_text(conf) (source_dir / "CMakeLists.txt").write_text("not part of the checksum\n") build_dir = tmp_path / ".pioenvs" / "livingroom" - (build_dir / app).mkdir(parents=True, exist_ok=True) - (build_dir / app / "CMakeCache.txt").write_text("") + if configured: + (build_dir / app).mkdir(parents=True, exist_ok=True) + (build_dir / app / "CMakeCache.txt").write_text("") with ( - patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: "2.9.2"}}), - patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}), + patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: version}}), + patch.object( + nrf52, + "zephyr_data", + return_value={KEY_BOARD: "board", KEY_SYSBUILD: sysbuild}, + ), ): nrf52._write_pch_checksum(build_dir, source_dir) return build_dir / app / SUM -@pytest.mark.parametrize("app", ["zephyr", "."]) -def test_pch_checksum_is_written_next_to_the_gch(tmp_path: Path, app: str) -> None: - """Sysbuild nests the app image; without it the build dir is the app.""" - sum_path = _write_checksum(tmp_path, app) +@pytest.mark.parametrize( + ("app", "version"), [("zephyr", cv.Version(2, 9, 2)), (".", cv.Version(2, 9, 1))] +) +def test_pch_checksum_is_written_next_to_the_gch( + tmp_path: Path, app: str, version: cv.Version +) -> None: + """The SDK version decides the layout, like get_elf_path.""" + sum_path = _write_checksum(tmp_path, app, version=version) assert len(sum_path.read_text().strip()) == 64 +def test_pch_checksum_tracks_the_kconfig_side_inputs(tmp_path: Path) -> None: + """West projects and the sysbuild flag reach autoconf without a .conf + line; the sum must move with them or stale objects get served.""" + first = _write_checksum(tmp_path, "zephyr").read_text() + with patch.object(nrf52, "wanted_west_projects", return_value={"extra"}): + second = _write_checksum(tmp_path, "zephyr").read_text() + assert first != second + third = _write_checksum(tmp_path, "zephyr", sysbuild=True).read_text() + assert first != third + + def test_pch_checksum_tracks_the_zephyr_configuration(tmp_path: Path) -> None: first = _write_checksum(tmp_path, "zephyr").read_text() assert _write_checksum(tmp_path, "zephyr", "CONFIG_X=n\n").read_text() != first -def test_pch_checksum_waits_for_the_first_configure(tmp_path: Path) -> None: - CORE.build_path = tmp_path - build_dir = tmp_path / ".pioenvs" / "livingroom" - nrf52._write_pch_checksum(build_dir, tmp_path / "zephyr") - assert not build_dir.exists() +@pytest.mark.parametrize("version", [cv.Version(2, 9, 2), cv.Version(2, 9, 1)]) +def test_pch_checksum_lands_where_the_build_writes_the_image( + tmp_path: Path, version: cv.Version +) -> None: + """One layout rule: the sum must sit in get_elf_path's app dir, or a + layout drift silently costs the first build's sharing.""" + app = "zephyr" if version >= cv.Version(2, 9, 2) else "." + sum_path = _write_checksum(tmp_path, app, version=version) + CORE.name = "livingroom" + with patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: version}}): + expected = get_elf_path().parent.parent / SUM + assert sum_path.resolve() == expected.resolve() + + +def test_pch_checksum_written_before_the_first_configure(tmp_path: Path) -> None: + """The first build's compiles hash the sum in place of the .gch.""" + assert _write_checksum(tmp_path, "zephyr", configured=False).is_file() def _fake_build_env(ccache: str | None) -> dict[str, str]: @@ -130,6 +170,7 @@ def run_cmd(tmp_path: Path) -> Mock: CORE.name = "livingroom" CORE.toolchain = Toolchain.SDK_NRF CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: cv.Version(3, 2, 0)} + (tmp_path / "build" / "zephyr").mkdir(parents=True) with ( patch.dict("os.environ", {}, clear=True), patch.object(nrf52, "check_and_install"), diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index d9cf2d37b39..522061dec2e 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -11,7 +11,7 @@ from unittest.mock import ANY, call, patch import platformdirs import pytest -from esphome.components.nrf52 import _resolve_toolchain +from esphome.components.nrf52 import _resolve_toolchain, framework from esphome.components.nrf52.framework import ( _PLATFORMIO_PENV_REQUIREMENTS, _REQUIREMENTS, @@ -22,12 +22,12 @@ from esphome.components.nrf52.framework import ( _get_toolchain_platform_info, _install_toolchain, _needs_venv_rebuild, - _wanted_west_projects, check_and_install, get_build_env, get_sdk_nrf_tools_path, include_west_project, setup_platformio_python_env, + wanted_west_projects, ) from esphome.components.zephyr.const import KEY_SYSBUILD, KEY_ZEPHYR import esphome.config_validation as cv @@ -539,7 +539,7 @@ class TestCheckAndInstall: """Zephyr 4.1 moved the Cortex-M core headers to the cmsis_6 module.""" CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(sdk_version)} - assert ("cmsis_6" in _wanted_west_projects()) is has_cmsis_6 + assert ("cmsis_6" in wanted_west_projects()) is has_cmsis_6 def test_default_projects_never_read_the_stamp( self, @@ -1121,6 +1121,22 @@ def test_get_build_env_with_ccache( assert env["CCACHE_DEPEND"] == "1" assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) assert "CCACHE_DISABLE" not in env + # Only the per build map entry leaves the hash; user maps stay in + assert env["CCACHE_IGNOREOPTIONS"] == ( + f"-fmacro-prefix-map={(tmp_path / 'build' / 'zephyr').as_posix()}" + "=CMAKE_SOURCE_DIR" + ) + + +def test_get_build_env_skips_the_map_entry_on_whitespace( + nrf52_dirs: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The ignore list splits on spaces; a spaced path cannot be + expressed, so the entry is left hashed rather than emitted broken.""" + monkeypatch.delenv("CCACHE_IGNOREOPTIONS", raising=False) + CORE.build_path = tmp_path / "with space" / "build" + env = get_build_env("/usr/bin/ccache") + assert "CCACHE_IGNOREOPTIONS" not in env def test_get_build_env_sdk_3_4_0_uses_toolchain_root( @@ -1250,3 +1266,30 @@ def test_resolve_toolchain_rejects_unsupported() -> None: CORE.toolchain = Toolchain.ARDUINO with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"): _resolve_toolchain({}) + + +def test_patch_gen_defines_relativizes_the_dts_path(tmp_path: Path) -> None: + """The absolute dts.pre path is the only per device byte in the + devicetree header; the patch makes gen_defines emit the basename.""" + gen = tmp_path / "zephyr" / "scripts" / "dts" / "gen_defines.py" + gen.parent.mkdir(parents=True) + gen.write_text("s = f'DTS input file:\\n {edt.dts_path}\\n'\n") + framework._patch_gen_defines_dts_path(tmp_path) + assert "{os.path.basename(edt.dts_path)}" in gen.read_text() + assert not list(gen.parent.glob("*.tmp")) # no leftovers + before = gen.read_text() + framework._patch_gen_defines_dts_path(tmp_path) # idempotent + assert gen.read_text() == before + framework._patch_gen_defines_dts_path(tmp_path / "absent") # tolerant + + +def test_patch_gen_defines_warns_when_the_anchor_is_gone( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A reformatted upstream must not silently cost the sharing.""" + gen = tmp_path / "zephyr" / "scripts" / "dts" / "gen_defines.py" + gen.parent.mkdir(parents=True) + gen.write_text("s = 'something else entirely'\n") + with caplog.at_level("WARNING"): + framework._patch_gen_defines_dts_path(tmp_path) + assert "gen_defines.py no longer matches" in caplog.text From a058937fe002be00176e98c4bfccf3c8519b84fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 10:53:47 -0500 Subject: [PATCH 036/128] [api] Count packed varints a machine word at a time (#20122) --- esphome/components/api/proto.h | 31 +++---- .../api/test_count_packed_varints.cpp | 84 +++++++++++++++++++ 2 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 tests/components/api/test_count_packed_varints.cpp diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index c112b0f4159..214d9f417fe 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -56,22 +56,25 @@ inline constexpr int64_t decode_zigzag64(uint64_t value) { return (value & 1) ? static_cast(~(value >> 1)) : static_cast(value >> 1); } -/// Count number of varints in a packed buffer -inline uint16_t count_packed_varints(const uint8_t *data, size_t len) { - uint16_t count = 0; - while (len > 0) { - // Skip varint bytes until we find one without continuation bit - while (len > 0 && (*data & 0x80)) { - data++; - len--; - } - if (len > 0) { - data++; - len--; - count++; +/// Count varints in a packed buffer: len minus bytes with the continuation bit, summed a word at a time. +/// Word is a template parameter so tests can cover the 32-bit path on a 64-bit host. +template inline uint16_t count_packed_varints(const uint8_t *data, size_t len) { + constexpr size_t word_size = sizeof(Word); + constexpr Word lane_ones = ~Word{0} / 0xFF; // 0x01..01 + const uint8_t *end = data + len; + size_t continuations = 0; + while (data != end) { + // Unaligned word loads fault on Xtensa + if ((reinterpret_cast(data) & (word_size - 1)) == 0 && static_cast(end - data) >= word_size) { + Word word; + memcpy(&word, __builtin_assume_aligned(data, word_size), word_size); + continuations += (((word >> 7) & lane_ones) * lane_ones) >> (word_size * 8 - 8); + data += word_size; + } else { + continuations += *data++ >> 7; } } - return count; + return static_cast(len - continuations); } /// Encode a varint directly into a pre-allocated buffer. diff --git a/tests/components/api/test_count_packed_varints.cpp b/tests/components/api/test_count_packed_varints.cpp new file mode 100644 index 00000000000..5745403a9f9 --- /dev/null +++ b/tests/components/api/test_count_packed_varints.cpp @@ -0,0 +1,84 @@ +#include + +#include +#include +#include + +#include "esphome/components/api/proto.h" + +namespace esphome::api::testing { + +// The original byte at a time implementation. +static uint16_t reference_count(const uint8_t *data, size_t len) { + uint16_t count = 0; + while (len > 0) { + while (len > 0 && (*data & 0x80)) { + data++; + len--; + } + if (len > 0) { + data++; + len--; + count++; + } + } + return count; +} + +TEST(CountPackedVarints, EmptyBuffer) { + const uint8_t data[1] = {0x00}; + EXPECT_EQ(count_packed_varints(data, 0), 0); +} + +TEST(CountPackedVarints, SingleByteVarints) { + const uint8_t data[] = {0x00, 0x01, 0x7F}; + EXPECT_EQ(count_packed_varints(data, sizeof(data)), 3); +} + +TEST(CountPackedVarints, MultiByteVarints) { + // 3 varints: 2 bytes, 3 bytes, 1 byte + const uint8_t data[] = {0x80, 0x01, 0x80, 0x80, 0x01, 0x05}; + EXPECT_EQ(count_packed_varints(data, sizeof(data)), 3); +} + +TEST(CountPackedVarints, TruncatedTrailingVarintIsNotCounted) { + const uint8_t data[] = {0x05, 0x80, 0x80, 0x80}; + EXPECT_EQ(count_packed_varints(data, sizeof(data)), 1); +} + +TEST(CountPackedVarints, AllContinuationBytes) { + std::vector data(5000, 0x80); + EXPECT_EQ(count_packed_varints(data.data(), data.size()), 0); +} + +TEST(CountPackedVarints, EveryStartOffsetAndLength) { + // Cover every alignment and length around word boundaries. + std::mt19937 rng(42); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible + std::vector buf(300); + for (auto &byte : buf) + byte = static_cast(rng() & 0xFF); + for (size_t offset = 0; offset < 16; offset++) { + for (size_t len = 0; len + offset <= buf.size(); len++) { + const uint16_t expected = reference_count(buf.data() + offset, len); + EXPECT_EQ(count_packed_varints(buf.data() + offset, len), expected) + << "offset=" << offset << " len=" << len; + EXPECT_EQ(count_packed_varints(buf.data() + offset, len), expected) + << "offset=" << offset << " len=" << len; + } + } +} + +TEST(CountPackedVarints, LongBuffer) { + std::mt19937 rng(7); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible + std::vector buf(5000); + for (auto &byte : buf) + byte = static_cast((rng() % 4 == 0) ? (0x80 | (rng() & 0x7F)) : (rng() & 0x7F)); + for (size_t offset = 0; offset < 8; offset++) { + const size_t len = buf.size() - offset; + const uint16_t expected = reference_count(buf.data() + offset, len); + EXPECT_EQ(count_packed_varints(buf.data() + offset, len), expected) << "offset=" << offset; + EXPECT_EQ(count_packed_varints(buf.data() + offset, len), expected) << "offset=" << offset; + } +} + +} // namespace esphome::api::testing From 666cf169b9ebb6266476af0b14a2853f54409c1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 10:54:03 -0500 Subject: [PATCH 037/128] [remote_base] Keep RC switch protocols in flash (#20081) --- esphome/components/remote_base/__init__.py | 44 +++++++++----- .../remote_base/rc_switch_protocol.cpp | 12 ++-- .../remote_base/rc_switch_protocol.h | 13 ++++- .../config/rc_switch_protocol.yaml | 58 +++++++++++++++++++ .../test_rc_switch_protocol.py | 25 ++++++++ 5 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/remote_receiver/config/rc_switch_protocol.yaml create mode 100644 tests/component_tests/remote_receiver/test_rc_switch_protocol.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index befef872f0a..5d615f0edf8 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1538,9 +1538,7 @@ def validate_rc_switch_raw_code(value): return value -def build_rc_switch_protocol(config): - if isinstance(config, int): - return rc_switch_protocol(config) +def build_custom_rc_switch_protocol(config: ConfigType) -> MockObj: pl = config[CONF_PULSE_LENGTH] return RCSwitchBase( config[CONF_SYNC][0] * pl, @@ -1553,6 +1551,24 @@ def build_rc_switch_protocol(config): ) +def rc_switch_protocol_in_flash(config: int | ConfigType) -> MockObj: + """Pointer to the protocol in flash: a built-in table entry or a shared custom table.""" + if isinstance(config, int): + return cg.RawExpression(f"&{RC_SWITCH_PROTOCOLS}[{config}]") + return cg.shared_progmem_array( + "rc_switch_custom_protocol", + RCSwitchBase, + [build_custom_rc_switch_protocol(config)], + ) + + +def rc_switch_protocol_value(config: int | ConfigType) -> MockObj: + """RAM copy of a constant protocol for the transmit actions, read from its flash table.""" + if isinstance(config, int): + return rc_switch_protocol(config) + return rc_switch_protocol_copy(rc_switch_protocol_in_flash(config)) + + RC_SWITCH_RAW_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): validate_rc_switch_raw_code, @@ -1628,6 +1644,8 @@ 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 RCSwitchData = ns.struct("RCSwitchData") RCSwitchBase = ns.class_("RCSwitchBase") RCSwitchTrigger = ns.class_("RCSwitchTrigger", RemoteReceiverTrigger) @@ -1642,7 +1660,7 @@ RCSwitchRawReceiver = ns.class_("RCSwitchRawReceiver", RemoteReceiverBinarySenso @register_binary_sensor("rc_switch_raw", RCSwitchRawReceiver, RC_SWITCH_RAW_SCHEMA) def rc_switch_raw_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_code(config[CONF_CODE])) @@ -1653,7 +1671,7 @@ def rc_switch_raw_binary_sensor(var, config): ) async def rc_switch_raw_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + 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))) @@ -1663,7 +1681,7 @@ async def rc_switch_raw_action(var, config, args): "rc_switch_type_a", RCSwitchRawReceiver, RC_SWITCH_TYPE_A_SCHEMA ) def rc_switch_type_a_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_type_a(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) @@ -1674,7 +1692,7 @@ def rc_switch_type_a_binary_sensor(var, config): ) async def rc_switch_type_a_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + 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))) @@ -1688,7 +1706,7 @@ async def rc_switch_type_a_action(var, config, args): "rc_switch_type_b", RCSwitchRawReceiver, RC_SWITCH_TYPE_B_SCHEMA ) def rc_switch_type_b_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add( var.set_type_b(config[CONF_ADDRESS], config[CONF_CHANNEL], config[CONF_STATE]) ) @@ -1701,7 +1719,7 @@ def rc_switch_type_b_binary_sensor(var, config): ) async def rc_switch_type_b_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + 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))) @@ -1713,7 +1731,7 @@ async def rc_switch_type_b_action(var, config, args): "rc_switch_type_c", RCSwitchRawReceiver, RC_SWITCH_TYPE_C_SCHEMA ) def rc_switch_type_c_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add( var.set_type_c( config[CONF_FAMILY], @@ -1731,7 +1749,7 @@ def rc_switch_type_c_binary_sensor(var, config): ) async def rc_switch_type_c_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add( @@ -1748,7 +1766,7 @@ async def rc_switch_type_c_action(var, config, args): RC_SWITCH_TYPE_D_SCHEMA.extend(RC_SWITCH_TRANSMITTER), ) def rc_switch_type_d_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_type_d(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) @@ -1759,7 +1777,7 @@ def rc_switch_type_d_binary_sensor(var, config): ) async def rc_switch_type_d_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + 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))) diff --git a/esphome/components/remote_base/rc_switch_protocol.cpp b/esphome/components/remote_base/rc_switch_protocol.cpp index de16c55cb02..bdd6615d432 100644 --- a/esphome/components/remote_base/rc_switch_protocol.cpp +++ b/esphome/components/remote_base/rc_switch_protocol.cpp @@ -9,12 +9,16 @@ namespace esphome::remote_base { static const char *const TAG = "remote.rc_switch"; RCSwitchBase rc_switch_protocol(uint8_t index) { - RCSwitchBase protocol; // entry 0 is the all-zero protocol, so an out of range index from a lambda transmits nothing if (index >= std::size(RC_SWITCH_PROTOCOLS)) index = 0; - progmem_memcpy(&protocol, &RC_SWITCH_PROTOCOLS[index], sizeof(protocol)); - return protocol; + return rc_switch_protocol_copy(&RC_SWITCH_PROTOCOLS[index]); +} + +RCSwitchBase rc_switch_protocol_copy(const RCSwitchBase *protocol) { + RCSwitchBase copy; + progmem_memcpy(©, protocol, sizeof(copy)); + return copy; } void RCSwitchBase::one(RemoteTransmitData *dst) const { @@ -232,7 +236,7 @@ uint64_t decode_binary_string_mask(const std::string &data) { bool RCSwitchRawReceiver::matches(RemoteReceiveData src) { uint64_t decoded_code; uint8_t decoded_nbits; - if (!this->protocol_.decode(src, &decoded_code, &decoded_nbits)) + if (!this->protocol_->decode(src, &decoded_code, &decoded_nbits)) return false; return decoded_nbits == this->nbits_ && (decoded_code & this->mask_) == (this->code_ & this->mask_); diff --git a/esphome/components/remote_base/rc_switch_protocol.h b/esphome/components/remote_base/rc_switch_protocol.h index 9ccea4d15a5..a102da3ff67 100644 --- a/esphome/components/remote_base/rc_switch_protocol.h +++ b/esphome/components/remote_base/rc_switch_protocol.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/core/component.h" #include "remote_base.h" @@ -66,6 +68,9 @@ class RCSwitchBase { uint32_t one_high_{}; uint32_t one_low_{}; uint32_t inverted_{}; // bool widened so every field is a word: the table is read from flash + + // A bool here would still pad to 28 bytes, so the size check below alone would not catch it. + static_assert(std::is_same_v, "inverted_ must stay a word for flash reads"); }; // Constant-initialized and kept in flash on every platform. The decoder reads entries in place @@ -87,6 +92,9 @@ inline constexpr RCSwitchBase RC_SWITCH_PROTOCOLS[] PROGMEM = { /// RAM copy of RC_SWITCH_PROTOCOLS[index] (0 when out of range) for the transmit actions and the dumper, made with /// progmem_memcpy so no byte load ever touches the flash table on ESP8266 RCSwitchBase rc_switch_protocol(uint8_t index); +/// RAM copy of a protocol stored in flash, made with progmem_memcpy (own name: `rc_switch_protocol(0)` stays +/// unambiguous) +RCSwitchBase rc_switch_protocol_copy(const RCSwitchBase *protocol); uint64_t decode_binary_string(const std::string &data); @@ -200,7 +208,8 @@ template class RCSwitchTypeDAction : public RemoteTransmitterAct class RCSwitchRawReceiver : public RemoteReceiverBinarySensorBase { public: - void set_protocol(const RCSwitchBase &a_protocol) { this->protocol_ = a_protocol; } + /// `protocol` must outlive the receiver: a RC_SWITCH_PROTOCOLS entry or a codegen flash table. + void set_protocol(const RCSwitchBase *protocol) { this->protocol_ = protocol; } void set_code(uint64_t code) { this->code_ = code; } void set_code(const std::string &code) { this->code_ = decode_binary_string(code); @@ -228,7 +237,7 @@ class RCSwitchRawReceiver : public RemoteReceiverBinarySensorBase { protected: bool matches(RemoteReceiveData src) override; - RCSwitchBase protocol_; + const RCSwitchBase *protocol_{nullptr}; // in flash; decoded in place (word-only fields) uint64_t code_; uint64_t mask_{0xFFFFFFFFFFFFFFFF}; uint8_t nbits_; diff --git a/tests/component_tests/remote_receiver/config/rc_switch_protocol.yaml b/tests/component_tests/remote_receiver/config/rc_switch_protocol.yaml new file mode 100644 index 00000000000..d79080cedfa --- /dev/null +++ b/tests/component_tests/remote_receiver/config/rc_switch_protocol.yaml @@ -0,0 +1,58 @@ +--- +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 100% + +remote_receiver: + pin: GPIO5 + +button: + - platform: template + name: Numbered + on_press: + - remote_transmitter.transmit_rc_switch_raw: + code: '1000100000000000' + protocol: 1 + - platform: template + name: Custom A + on_press: + - remote_transmitter.transmit_rc_switch_raw: + code: '1010101010101010' + protocol: + pulse_length: 320 + - platform: template + name: Custom B + on_press: + - remote_transmitter.transmit_rc_switch_type_b: + address: 4 + channel: 2 + state: true + protocol: + pulse_length: 320 + - platform: template + name: Lambda + on_press: + - remote_transmitter.transmit_rc_switch_raw: + code: '1010101010101011' + protocol: !lambda return remote_base::rc_switch_protocol(2); + +binary_sensor: + - platform: remote_receiver + name: Numbered RX + rc_switch_raw: + code: '1000100000000000' + protocol: 1 + - platform: remote_receiver + name: Custom RX + rc_switch_raw: + code: '1010101010101010' + protocol: + pulse_length: 320 + - platform: template + id: rc_switch_custom_protocol diff --git a/tests/component_tests/remote_receiver/test_rc_switch_protocol.py b/tests/component_tests/remote_receiver/test_rc_switch_protocol.py new file mode 100644 index 00000000000..5c0d14c7945 --- /dev/null +++ b/tests/component_tests/remote_receiver/test_rc_switch_protocol.py @@ -0,0 +1,25 @@ +"""RC switch protocols are read from flash tables instead of being copied per entity.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_rc_switch_protocols_share_flash_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("rc_switch_protocol.yaml")) + + # One shared table for the identical custom protocols; the user id keeps its name. + assert main_cpp.count("static constexpr remote_base::RCSwitchBase") == 1 + assert ( + "remote_base::RCSwitchBase rc_switch_custom_protocol_2[] PROGMEM = " + "{remote_base::RCSwitchBase(320, 9920, 320, 960, 960, 320, false)};" + ) in main_cpp + assert main_cpp.count("rc_switch_protocol_copy(rc_switch_custom_protocol_2)") == 2 + # 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 + assert "return remote_base::rc_switch_protocol(2);" in main_cpp From f334b9ea1106a289b44439dc1c139db24e4e21fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 10:54:17 -0500 Subject: [PATCH 038/128] [core] Decode base64 in a single pass (#20123) --- esphome/core/helpers.cpp | 84 +++++++------------------- tests/components/core/test_helpers.cpp | 21 +++++++ 2 files changed, 43 insertions(+), 62 deletions(-) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 313daacbd0f..a11b07808d7 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -8,7 +8,6 @@ #include #include -#include #include #include #include @@ -621,15 +620,11 @@ int8_t step_to_accuracy_decimals(float step) { return decimals; } -// Map a base64/base64url character to its 6-bit value (0-63) arithmetically. -// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there). -// Supports both standard base64 (+/) and base64url (-_) alphabets. -// NOTE: This returns 0 for both 'A' (valid base64 char at index 0) and invalid characters. -// This is safe because is_base64() is ALWAYS checked before calling this function, -// preventing invalid characters from ever reaching here. The base64_decode function -// stops processing at the first invalid character due to the is_base64() check in its -// while loop condition, making this edge case harmless in practice. -static inline uint8_t base64_find_char(char c) { +static constexpr uint8_t INVALID_BASE64_CHAR = 0xFF; + +// 6-bit value of a base64 or base64url char, or INVALID_BASE64_CHAR. +// No lookup table: .rodata lives in DRAM on ESP8266. +static constexpr uint8_t base64_char_value(uint8_t c) { if (c >= 'A' && c <= 'Z') return c - 'A'; if (c >= 'a' && c <= 'z') @@ -641,70 +636,35 @@ static inline uint8_t base64_find_char(char c) { return 62; if (c == '/' || c == '_') return 63; - return 0; + return INVALID_BASE64_CHAR; } -// Check if character is valid base64 or base64url -static inline bool is_base64(char c) { return (isalnum(c) || (c == '+') || (c == '/') || (c == '-') || (c == '_')); } - // base64_encode (both overloads) moved to alloc_helpers.cpp size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { return base64_decode(reinterpret_cast(encoded_string.data()), encoded_string.size(), buf, buf_len); } -// Decode 4 base64 characters to up to 'count' output bytes, returns true if truncated. -static inline bool base64_decode_quad(uint8_t *char_array_4, int count, uint8_t *buf, size_t buf_len, size_t &out) { - for (int i = 0; i < 4; i++) - char_array_4[i] = base64_find_char(char_array_4[i]); - - uint8_t char_array_3[3]; - char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4); - char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); - char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - - bool truncated = false; - for (int j = 0; j < count; j++) { - if (out < buf_len) { - buf[out++] = char_array_3[j]; - } else { - truncated = true; - } - } - return truncated; -} - size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len) { - size_t in_len = encoded_len; - int i = 0; - size_t in = 0; size_t out = 0; - uint8_t char_array_4[4]; - bool truncated = false; - - // SAFETY: The loop condition checks is_base64() before processing each character. - // This ensures base64_find_char() is only called on valid base64 characters, - // preventing the edge case where invalid chars would return 0 (same as 'A'). - while (in_len-- && (encoded_data[in] != '=') && is_base64(encoded_data[in])) { - char_array_4[i++] = encoded_data[in]; - in++; - if (i == 4) { - truncated |= base64_decode_quad(char_array_4, 3, buf, buf_len, out); - i = 0; + uint32_t accum = 0; + uint32_t bits = 0; + // Stops at '=' or any non-alphabet char; leftover bits of a partial group are dropped. + for (size_t in = 0; in < encoded_len; in++) { + uint8_t value = base64_char_value(encoded_data[in]); + if (value == INVALID_BASE64_CHAR) + break; + accum = (accum << 6) | value; + bits += 6; + if (bits >= 8) { + bits -= 8; + if (out == buf_len) { + ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); + return out; + } + buf[out++] = static_cast(accum >> bits); } } - - if (i) { - for (int j = i; j < 4; j++) - char_array_4[j] = 0; - - truncated |= base64_decode_quad(char_array_4, i - 1, buf, buf_len, out); - } - - if (truncated) { - ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); - } - return out; } diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index 72af605d61f..9fee0c723b5 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -281,6 +281,27 @@ TEST(Base64, Rfc4648Vectors) { } } +TEST(Base64, DecodeTruncatesToBuffer) { + uint8_t buf[4]; + size_t len = base64_decode(std::string("Zm9vYmFy"), buf, sizeof(buf)); + EXPECT_EQ(len, 4u); + EXPECT_EQ(memcmp(buf, "foob", 4), 0); +} + +TEST(Base64, DecodeStopsAtNonAlphabetChar) { + uint8_t buf[8]; + EXPECT_EQ(base64_decode(std::string("Zm9v!Zm9v"), buf, sizeof(buf)), 3u); + EXPECT_EQ(memcmp(buf, "foo", 3), 0); + EXPECT_EQ(base64_decode(std::string("Zm9v Zm9v"), buf, sizeof(buf)), 3u); + EXPECT_EQ(base64_decode(std::string("Zm9v\xC3Zm9v"), buf, sizeof(buf)), 3u); +} + +TEST(Base64, DecodeDropsPartialGroup) { + uint8_t buf[8]; + EXPECT_EQ(base64_decode(std::string("Z"), buf, sizeof(buf)), 0u); + EXPECT_EQ(base64_decode(std::string("Zm9vY"), buf, sizeof(buf)), 3u); +} + // --- step_to_accuracy_decimals() --- TEST(StepToAccuracyDecimals, TypicalSteps) { From bb8c23805a23c2fa96b9de2c664a646b545bb60d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 10:55:11 -0500 Subject: [PATCH 039/128] [wifi] Connect LN882x to the BSSID asked for (#20174) --- esphome/components/wifi/__init__.py | 4 ++++ esphome/components/wifi/wifi_component_libretiny.cpp | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 553c737dbcf..676aa0d2942 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -734,6 +734,10 @@ async def to_code(config): if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) + # LN882x: hand the SDK the BSSID LibreTiny 1.13 drops (see wifi_component_libretiny.cpp); remove once fixed upstream. + if CORE.is_ln882x: + cg.add_build_flag("-Wl,--wrap=wifi_sta_connect") + # post_connect_roaming defaults to true in C++ - disable if user disabled it # or if 802.11k/v is enabled (driver handles roaming natively) if ( diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 1d9fda6d2f0..1d615e0a0de 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -31,6 +31,17 @@ extern "C" { #ifdef USE_LN882X #include #undef DATA // clashes with setup_priority::DATA +// LibreTiny 1.13's LN882H reconnect() gives the SDK a NULL bssid; pass on the requested one. Remove once it passes +// info.bssid. Safe without a BSSID: begin() frees sta.bssid (resetNetworkInfo), so it stays NULL. +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism. +extern "C" int __real_wifi_sta_connect(wifi_sta_connect_t *connect, wifi_scan_cfg_t *scan_cfg); +extern "C" int __wrap_wifi_sta_connect(wifi_sta_connect_t *connect, wifi_scan_cfg_t *scan_cfg) { + if (connect->bssid == nullptr) + connect->bssid = static_cast(WiFi.data)->sta.bssid; + return __real_wifi_sta_connect(connect, scan_cfg); +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) #endif #include "esphome/core/application.h" From c8eaf518baadc9a15843cacc793bc0dab24edcab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 10:56:02 -0500 Subject: [PATCH 040/128] [libretiny] Flag the LN882H SDK default MAC (#20175) --- esphome/components/libretiny/lt_component.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index 0ab064e3e19..d7d93249c51 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -2,6 +2,9 @@ #ifdef USE_LIBRETINY +#include + +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::libretiny { @@ -18,6 +21,17 @@ void LTComponent::dump_config() { ESP_LOGCONFIG(TAG, " Optimization: -Os, SDK: -O" STRINGIFY_MACRO(__OPTIMIZE_LEVEL__)); #endif +#if defined(USE_LN882X) && defined(USE_WIFI) + // The SDK falls back to this MAC when the board layout's KV store holds none. + static constexpr uint8_t SDK_DEFAULT_MAC[MAC_ADDRESS_SIZE] = {0x00, 0x50, 0xC2, 0x5E, 0x10, 0x88}; + uint8_t mac[MAC_ADDRESS_SIZE]; + get_mac_address_raw(mac); + if (memcmp(mac, SDK_DEFAULT_MAC, MAC_ADDRESS_SIZE) == 0) { + ESP_LOGE(TAG, "MAC is the LN882H SDK default 00:50:C2:5E:10:88; only one device with it can be on a network. " + "On a Tuya module, set board: to its Tuya board (e.g. wl2s, wl2h-u) and flash over serial"); + } +#endif + #ifdef USE_TEXT_SENSOR if (this->version_ != nullptr) { this->version_->publish_state(<_BANNER_STR[10]); From 8735aee847cda3c0cde1b688d46d6e656c0ac662 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 10:56:24 -0500 Subject: [PATCH 041/128] [wifi] Leave the AP before an LN882x reboots (#20176) --- esphome/components/wifi/wifi_component.h | 3 +++ esphome/components/wifi/wifi_component_libretiny.cpp | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 082c059a14a..9418165501c 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -506,6 +506,9 @@ class WiFiComponent final : public Component { // (In most use cases you won't need these) /// Setup WiFi interface. void setup() override; +#ifdef USE_LN882X + void on_powerdown() override; +#endif void start(); void dump_config() override; void restart_adapter(); diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 1d615e0a0de..1f43f37c0e2 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -800,6 +800,17 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; } #endif // USE_WIFI_AP +#ifdef USE_LN882X +void WiFiComponent::on_powerdown() { + // Leave the AP so it no longer holds the association when we come back. + if (WiFi.status() != WL_CONNECTED) // associated, even before DHCP has finished + return; + ESP_LOGD(TAG, "Disconnecting before powerdown"); + wifi_sta_disconnect(); // not WiFi.disconnect(): it frees the SSID its event handlers read + delay(100); // NOLINT: once per reboot or deep sleep, lets the deauth go out +} +#endif + bool WiFiComponent::wifi_disconnect_() { // Reset state first so disconnect events aren't ignored // and wifi_sta_connect_status_() returns IDLE instead of CONNECTING From 4a47a984a0f6bd240ad451974ec96be469ad05b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 11:41:33 -0500 Subject: [PATCH 042/128] [audio_file] Keep audio files in flash instead of building them in setup() (#20072) --- esphome/components/audio/audio_reader.cpp | 2 +- esphome/components/audio/audio_reader.h | 4 +- esphome/components/audio_file/__init__.py | 41 ++++++++++++++----- esphome/components/audio_file/audio_file.h | 4 +- .../media_source/audio_file_media_source.h | 2 +- .../speaker/media_player/__init__.py | 2 +- .../speaker/media_player/audio_pipeline.cpp | 2 +- .../speaker/media_player/audio_pipeline.h | 4 +- .../media_player/speaker_media_player.cpp | 2 +- .../media_player/speaker_media_player.h | 6 +-- tests/component_tests/audio_file/__init__.py | 0 .../audio_file/config/audio_file.yaml | 14 +++++++ .../audio_file/test_audio_file.py | 22 ++++++++++ 13 files changed, 80 insertions(+), 25 deletions(-) create mode 100644 tests/component_tests/audio_file/__init__.py create mode 100644 tests/component_tests/audio_file/config/audio_file.yaml create mode 100644 tests/component_tests/audio_file/test_audio_file.py diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index e69f33ac2d5..9f2960620e1 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -72,7 +72,7 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr &ou return ESP_ERR_INVALID_STATE; } -esp_err_t AudioReader::start(AudioFile *audio_file, AudioFileType &file_type) { +esp_err_t AudioReader::start(const AudioFile *audio_file, AudioFileType &file_type) { file_type = AudioFileType::NONE; this->current_audio_file_ = audio_file; diff --git a/esphome/components/audio/audio_reader.h b/esphome/components/audio/audio_reader.h index b1f76172b01..db85185ca6c 100644 --- a/esphome/components/audio/audio_reader.h +++ b/esphome/components/audio/audio_reader.h @@ -47,7 +47,7 @@ class AudioReader { /// @param audio_file AudioFile struct containing the file. /// @param file_type AudioFileType variable passed-by-reference indicating the type of file being read. /// @return ESP_OK - esp_err_t start(AudioFile *audio_file, AudioFileType &file_type); + esp_err_t start(const AudioFile *audio_file, AudioFileType &file_type); /// @brief Reads new file data from the source and sends to the ring buffer sink. /// @return AudioReaderState @@ -69,7 +69,7 @@ class AudioReader { esp_http_client_handle_t client_{nullptr}; - AudioFile *current_audio_file_{nullptr}; + const AudioFile *current_audio_file_{nullptr}; AudioFileType audio_file_type_{AudioFileType::NONE}; const uint8_t *file_current_{nullptr}; }; diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index d59ed7411a4..7726ef5837e 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -19,7 +19,7 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, ID, HexInt -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, ProgmemAssignmentExpression from esphome.external_files import download_web_files_in_config from esphome.types import ConfigType @@ -151,11 +151,14 @@ TYPED_FILE_SCHEMA = cv.typed_schema( ) +CONF_FILE_DATA_ID = "file_data_id" + MEDIA_FILE_TYPE_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.declare_id(audio.AudioFile), cv.Required(CONF_FILE): _file_schema, cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + cv.GenerateID(CONF_FILE_DATA_ID): cv.declare_id(audio.AudioFile), } ) @@ -217,9 +220,9 @@ def audio_files_schema() -> cv.All: def generate_audio_file_code(file_config: ConfigType) -> MockObj: - """Generate the progmem data, AudioFile struct, and Pvariable for one file. + """Generate the progmem data and a flash AudioFile for one file. - Returns the created Pvariable. Caller is responsible for any further + Returns a const pointer to the AudioFile. Caller is responsible for any further registration (the audio_file component additionally registers each file in its named C++ registry; other consumers may skip that). """ @@ -230,17 +233,33 @@ def generate_audio_file_code(file_config: ConfigType) -> MockObj: else: data, media_file_type = read_audio_file_and_type(file_config) - rhs = [HexInt(x) for x in data] - prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs) - - media_files_struct = cg.StructInitializer( + # Global constants so the AudioFile lives in flash; the id stays a plain pointer + # because actions render id arguments as ``::``. + data_id = file_config[CONF_RAW_DATA_ID] + cg.add_global( + ProgmemAssignmentExpression( + data_id.type, data_id, cg.safe_exp([HexInt(x) for x in data]) + ) + ) + media_file = cg.StructInitializer( audio.AudioFile, - ("data", prog_arr), - ("length", len(rhs)), + ("data", MockObj(data_id, ".")), + ("length", len(data)), ("file_type", media_file_type), ) - - return cg.new_Pvariable(file_config[CONF_ID], media_files_struct) + file_var_id = file_config[CONF_ID] + storage = file_config[CONF_FILE_DATA_ID] + cg.add_global( + cg.RawStatement(f"static constexpr {storage.type} {storage} = {media_file};") + ) + cg.add_global( + cg.RawStatement( + f"static const {storage.type} *const {file_var_id} = &{storage};" + ) + ) + var = MockObj(file_var_id, "->") + CORE.register_variable(file_var_id, var) + return var CONFIG_SCHEMA = cv.All( diff --git a/esphome/components/audio_file/audio_file.h b/esphome/components/audio_file/audio_file.h index 537e19fb3ca..177374cf91a 100644 --- a/esphome/components/audio_file/audio_file.h +++ b/esphome/components/audio_file/audio_file.h @@ -10,14 +10,14 @@ namespace esphome::audio_file { struct NamedAudioFile { - audio::AudioFile *file; + const audio::AudioFile *file; const char *file_id; }; inline StaticVector named_audio_files; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -inline void add_named_audio_file(audio::AudioFile *file, const char *file_id) { +inline void add_named_audio_file(const audio::AudioFile *file, const char *file_id) { named_audio_files.push_back({file, file_id}); } diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.h b/esphome/components/audio_file/media_source/audio_file_media_source.h index d269f77c357..12735eaca18 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.h +++ b/esphome/components/audio_file/media_source/audio_file_media_source.h @@ -46,7 +46,7 @@ class AudioFileMediaSource final : public Component, protected: std::unique_ptr decoder_; audio::AudioStreamInfo stream_info_; - audio::AudioFile *current_file_{nullptr}; + const audio::AudioFile *current_file_{nullptr}; // Written from the main loop in handle_command(), read from the decoder task in // on_audio_write(). Must be atomic to avoid a data race. diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 78892bff226..159af158086 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -251,7 +251,7 @@ automation.register_apply_action( # so a trigger arg with the same name cannot shadow it. ( CONF_MEDIA_FILE, - audio.AudioFile.operator("ptr"), + audio.AudioFile.operator("const").operator("ptr"), lambda _, value: f"::{value}", ), (CONF_ANNOUNCEMENT, cg.bool_), diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 509984cfa29..f150a485735 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -66,7 +66,7 @@ void AudioPipeline::start_url(const std::string &uri) { this->pending_url_ = true; } -void AudioPipeline::start_file(audio::AudioFile *audio_file) { +void AudioPipeline::start_file(const audio::AudioFile *audio_file) { if (this->is_playing_) { xEventGroupSetBits(this->event_group_, PIPELINE_COMMAND_STOP); } diff --git a/esphome/components/speaker/media_player/audio_pipeline.h b/esphome/components/speaker/media_player/audio_pipeline.h index 02dad15de97..b114a7b7a51 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.h +++ b/esphome/components/speaker/media_player/audio_pipeline.h @@ -74,7 +74,7 @@ class AudioPipeline final { /// @brief Starts an audio pipeline given a AudioFile pointer /// @param audio_file pointer to an AudioFile object /// @return ESP_OK if successful or an appropriate error if not - void start_file(audio::AudioFile *audio_file); + void start_file(const audio::AudioFile *audio_file); /// @brief Stops the pipeline. Sends a stop signal to each task (if running) and clears the ring buffers. /// @return ESP_OK if successful or ESP_ERR_TIMEOUT if the tasks did not indicate they stopped @@ -121,7 +121,7 @@ class AudioPipeline final { speaker::Speaker *speaker_{nullptr}; std::string current_uri_{}; - audio::AudioFile *current_audio_file_{nullptr}; + const audio::AudioFile *current_audio_file_{nullptr}; audio::AudioFileType current_audio_file_type_; audio::AudioStreamInfo current_audio_stream_info_; diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index cac2f6649c8..ab98935bdad 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -493,7 +493,7 @@ void SpeakerMediaPlayer::loop() { #endif } -void SpeakerMediaPlayer::play_file(audio::AudioFile *media_file, bool announcement, bool enqueue) { +void SpeakerMediaPlayer::play_file(const audio::AudioFile *media_file, bool announcement, bool enqueue) { if (!this->is_ready()) { // Ignore any commands sent before the media player is setup return; diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 889aedbc9ac..e98196785c8 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -28,13 +28,13 @@ struct MediaCallCommand { optional volume; optional announce; optional url; // Must be manually deleted after receiving this struct from a queue - optional file; + optional file; optional enqueue; }; struct PlaylistItem { optional url; - optional file; + optional file; }; struct VolumeRestoreState { @@ -87,7 +87,7 @@ class SpeakerMediaPlayer final : public Component, Trigger<> *get_unmute_trigger() { return &this->unmute_trigger_; } Trigger *get_volume_trigger() { return &this->volume_trigger_; } - void play_file(audio::AudioFile *media_file, bool announcement, bool enqueue); + void play_file(const audio::AudioFile *media_file, bool announcement, bool enqueue); void set_playlist_delay_ms(AudioPipelineType pipeline_type, uint32_t delay_ms); diff --git a/tests/component_tests/audio_file/__init__.py b/tests/component_tests/audio_file/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/audio_file/config/audio_file.yaml b/tests/component_tests/audio_file/config/audio_file.yaml new file mode 100644 index 00000000000..a25feedc36c --- /dev/null +++ b/tests/component_tests/audio_file/config/audio_file.yaml @@ -0,0 +1,14 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +audio_file: + - id: chime + file: + type: local + path: ../../../components/audio_file/test.wav diff --git a/tests/component_tests/audio_file/test_audio_file.py b/tests/component_tests/audio_file/test_audio_file.py new file mode 100644 index 00000000000..32323c2816e --- /dev/null +++ b/tests/component_tests/audio_file/test_audio_file.py @@ -0,0 +1,22 @@ +"""Tests for the audio_file component codegen.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_audio_file_is_constant_in_flash( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The AudioFile is a global constant behind a const pointer, not a placement new.""" + main_cpp = generate_main(component_config_path("audio_file.yaml")) + + assert ( + "static constexpr audio::AudioFile audio_audiofile_id = audio::AudioFile{" + in main_cpp + ) + assert ( + "static const audio::AudioFile *const chime = &audio_audiofile_id;" in main_cpp + ) + assert "new(chime)" not in main_cpp + assert 'audio_file::add_named_audio_file(chime, "chime");' in main_cpp From fc441646f459b73fa1d9e5764d3ff55833c01485 Mon Sep 17 00:00:00 2001 From: Samuel Sieb Date: Mon, 5 Oct 2026 18:40:06 +0100 Subject: [PATCH 043/128] [unicode][font] move extract_unicode_codepoint to new component (#19969) --- CODEOWNERS | 1 + esphome/components/font/__init__.py | 1 + esphome/components/font/font.cpp | 107 +------------------------ esphome/components/unicode/__init__.py | 1 + esphome/components/unicode/unicode.cpp | 97 ++++++++++++++++++++++ esphome/components/unicode/unicode.h | 20 +++++ 6 files changed, 123 insertions(+), 104 deletions(-) create mode 100644 esphome/components/unicode/__init__.py create mode 100644 esphome/components/unicode/unicode.cpp create mode 100644 esphome/components/unicode/unicode.h diff --git a/CODEOWNERS b/CODEOWNERS index 80f243e82e7..43034ce3089 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -609,6 +609,7 @@ esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli esphome/components/ufm01/* @ljungqvist esphome/components/ultrasonic/* @ssieb @swoboda1337 +esphome/components/unicode/* @esphome/core esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon esphome/components/usb_cdc_acm/* @kbx81 diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index a2fad4bc25f..732933ba480 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -41,6 +41,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "font" MULTI_CONF = True +AUTO_LOAD = ["unicode"] CODEOWNERS = ["@esphome/core", "@clydebarrow"] diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index 1ed946dadb1..2592370b6ad 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -3,6 +3,7 @@ #include "esphome/core/color.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/components/unicode/unicode.h" namespace esphome::font { static const char *const TAG = "font"; @@ -121,108 +122,6 @@ const Glyph *Font::get_glyph_data_(uint32_t unicode_letter) { } #endif -/** - * Attempt to extract a 32 bit Unicode codepoint from a UTF-8 string. - * If successful, return the codepoint and set the length to the number of bytes read. - * If the end of the string has been reached and a valid codepoint has not been found, return 0 and set the length to - * 0. - * - * @param utf8_str The input string - * @param length Pointer to length storage - * @return The extracted code point - */ -static uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) { - // Safely cast to uint8_t* for correct bitwise operations on bytes - const uint8_t *current = reinterpret_cast(utf8_str); - uint32_t code_point = 0; - uint8_t c1 = *current++; - - // check for end of string - if (c1 == 0) { - *length = 0; - return 0; - } - - // --- 1-Byte Sequence: 0xxxxxxx (ASCII) --- - if (c1 < 0x80) { - // Valid ASCII byte. - code_point = c1; - // Optimization: No need to check for continuation bytes. - } - // --- 2-Byte Sequence: 110xxxxx 10xxxxxx --- - else if ((c1 & 0xE0) == 0xC0) { - uint8_t c2 = *current++; - - // Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx) - if ((c2 & 0xC0) != 0x80) { - *length = 0; - return 0; - } - - code_point = (c1 & 0x1F) << 6; - code_point |= (c2 & 0x3F); - - // Error Check 2: Overlong check (2-byte must be > 0x7F) - if (code_point <= 0x7F) { - *length = 0; - return 0; - } - } - // --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx --- - else if ((c1 & 0xF0) == 0xE0) { - uint8_t c2 = *current++; - uint8_t c3 = *current++; - - // Error Check 1: Check continuation bytes - if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) { - *length = 0; - return 0; - } - - code_point = (c1 & 0x0F) << 12; - code_point |= (c2 & 0x3F) << 6; - code_point |= (c3 & 0x3F); - - // Error Check 2: Overlong check (3-byte must be > 0x7FF) - // Also check for surrogates (0xD800-0xDFFF) - if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) { - *length = 0; - return 0; - } - } - // --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx --- - else if ((c1 & 0xF8) == 0xF0) { - uint8_t c2 = *current++; - uint8_t c3 = *current++; - uint8_t c4 = *current++; - - // Error Check 1: Check continuation bytes - if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) { - *length = 0; - return 0; - } - - code_point = (c1 & 0x07) << 18; - code_point |= (c2 & 0x3F) << 12; - code_point |= (c3 & 0x3F) << 6; - code_point |= (c4 & 0x3F); - - // Error Check 2: Overlong check (4-byte must be > 0xFFFF) - // Also check for valid Unicode range (must be <= 0x10FFFF) - if (code_point <= 0xFFFF || code_point > 0x10FFFF) { - *length = 0; - return 0; - } - } - // --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) --- - else { - *length = 0; - return 0; - } - *length = current - reinterpret_cast(utf8_str); - return code_point; -} - Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, uint8_t bpp) : glyphs_(ConstVector(data, data_nr)), @@ -271,7 +170,7 @@ void Font::measure(const char *str, int *width, int *x_offset, int *baseline, in int x = 0; for (;;) { size_t length; - auto code_point = extract_unicode_codepoint(str, &length); + auto code_point = unicode::extract_unicode_codepoint(str, &length); if (length == 0) break; str += length; @@ -300,7 +199,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo int x_at = x_start; for (;;) { size_t length; - auto code_point = extract_unicode_codepoint(text, &length); + auto code_point = unicode::extract_unicode_codepoint(text, &length); if (length == 0) break; text += length; diff --git a/esphome/components/unicode/__init__.py b/esphome/components/unicode/__init__.py new file mode 100644 index 00000000000..f70ffa95208 --- /dev/null +++ b/esphome/components/unicode/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/core"] diff --git a/esphome/components/unicode/unicode.cpp b/esphome/components/unicode/unicode.cpp new file mode 100644 index 00000000000..f5b4084fe09 --- /dev/null +++ b/esphome/components/unicode/unicode.cpp @@ -0,0 +1,97 @@ +#include "unicode.h" + +namespace esphome::unicode { + +uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) { + // Safely cast to uint8_t* for correct bitwise operations on bytes + const uint8_t *current = reinterpret_cast(utf8_str); + uint32_t code_point = 0; + uint8_t c1 = *current++; + + // check for end of string + if (c1 == 0) { + *length = 0; + return 0; + } + + // --- 1-Byte Sequence: 0xxxxxxx (ASCII) --- + if (c1 < 0x80) { + // Valid ASCII byte. + code_point = c1; + // Optimization: No need to check for continuation bytes. + } + // --- 2-Byte Sequence: 110xxxxx 10xxxxxx --- + else if ((c1 & 0xE0) == 0xC0) { + uint8_t c2 = *current++; + + // Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx) + if ((c2 & 0xC0) != 0x80) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x1F) << 6; + code_point |= (c2 & 0x3F); + + // Error Check 2: Overlong check (2-byte must be > 0x7F) + if (code_point <= 0x7F) { + *length = 0; + return 0; + } + } + // --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx --- + else if ((c1 & 0xF0) == 0xE0) { + uint8_t c2 = *current++; + uint8_t c3 = *current++; + + // Error Check 1: Check continuation bytes + if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x0F) << 12; + code_point |= (c2 & 0x3F) << 6; + code_point |= (c3 & 0x3F); + + // Error Check 2: Overlong check (3-byte must be > 0x7FF) + // Also check for surrogates (0xD800-0xDFFF) + if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) { + *length = 0; + return 0; + } + } + // --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx --- + else if ((c1 & 0xF8) == 0xF0) { + uint8_t c2 = *current++; + uint8_t c3 = *current++; + uint8_t c4 = *current++; + + // Error Check 1: Check continuation bytes + if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x07) << 18; + code_point |= (c2 & 0x3F) << 12; + code_point |= (c3 & 0x3F) << 6; + code_point |= (c4 & 0x3F); + + // Error Check 2: Overlong check (4-byte must be > 0xFFFF) + // Also check for valid Unicode range (must be <= 0x10FFFF) + if (code_point <= 0xFFFF || code_point > 0x10FFFF) { + *length = 0; + return 0; + } + } + // --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) --- + else { + *length = 0; + return 0; + } + *length = current - reinterpret_cast(utf8_str); + return code_point; +} + +} // namespace esphome::unicode diff --git a/esphome/components/unicode/unicode.h b/esphome/components/unicode/unicode.h new file mode 100644 index 00000000000..9e69d036e0c --- /dev/null +++ b/esphome/components/unicode/unicode.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +namespace esphome::unicode { + +/** + * Attempt to extract a 32 bit Unicode codepoint from a UTF-8 string. + * If successful, return the codepoint and set the length to the number of bytes read. + * If the end of the string has been reached and a valid codepoint has not been found, return 0 and set the length to + * 0. + * + * @param utf8_str The input string + * @param length Pointer to length storage + * @return The extracted code point + */ +uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length); + +} // namespace esphome::unicode From a778baddca233ab8e6fdd4b687c982bb96a53adf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 16:56:10 -0500 Subject: [PATCH 044/128] [usb_uart] Only compile the vendor drivers that are configured (#20190) --- esphome/components/usb_uart/__init__.py | 21 +++++++++++++++++++ esphome/core/defines.h | 4 ++++ tests/component_tests/usb_uart/__init__.py | 0 .../usb_uart/test_source_filter.py | 19 +++++++++++++++++ .../usb_uart/test_usb_uart_ft232.yaml | 18 ++++++++++++++++ 5 files changed, 62 insertions(+) create mode 100644 tests/component_tests/usb_uart/__init__.py create mode 100644 tests/component_tests/usb_uart/test_source_filter.py create mode 100644 tests/component_tests/usb_uart/test_usb_uart_ft232.yaml diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 5d0f8be1655..af58c24d432 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -7,6 +7,7 @@ from esphome.components.usb_host import ( register_usb_client, usb_device_schema, ) +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, @@ -15,6 +16,7 @@ from esphome.const import ( CONF_DEBUG, CONF_DUMMY_RECEIVER, CONF_ID, + CONF_TYPE, ) from esphome.core import CORE from esphome.cpp_types import Component @@ -64,6 +66,8 @@ class Type: self.vid = vid self.pid = pid self.cls = usb_uart_ns.class_(f"USBUartType{cls}", USBUartComponent) + # CDC ACM lives in usb_uart.cpp; each vendor driver has its own .cpp + self.driver = None if cls == "CdcAcm" else cls self._max_channels = max_channels self.baud_rate_required = baud_rate_required self.max_baud = max_baud @@ -121,6 +125,21 @@ uart_types = ( ), ) +_TYPES_BY_NAME = {it.name: it for it in uart_types} + + +def _driver_define(driver: str) -> str: + return f"USE_USB_UART_{driver}" + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + f"{it.driver.lower()}.cpp": _driver_define(it.driver) + for it in uart_types + if it.driver is not None + } +) + def channel_schema(type_: "Type") -> cv.Schema: schema = cv.Schema( @@ -211,6 +230,8 @@ async def to_code(config: list[ConfigType]) -> None: cg.add_define("USB_UART_OUTPUT_CHUNK_COUNT", output_chunk_count) for device in config: + if (driver := _TYPES_BY_NAME[device[CONF_TYPE]].driver) is not None: + cg.add_define(_driver_define(driver)) var = await register_usb_client(device) # The C++ default is true; only emit the override if not device.get(CONF_CLAIM_COMM_INTERFACE, True): diff --git a/esphome/core/defines.h b/esphome/core/defines.h index be8d89c5572..f701cd29191 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -459,6 +459,10 @@ #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 +#define USE_USB_UART_CH34X +#define USE_USB_UART_CP210X +#define USE_USB_UART_FT23XX +#define USE_USB_UART_PL2303 #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7) diff --git a/tests/component_tests/usb_uart/__init__.py b/tests/component_tests/usb_uart/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/usb_uart/test_source_filter.py b/tests/component_tests/usb_uart/test_source_filter.py new file mode 100644 index 00000000000..414640f8217 --- /dev/null +++ b/tests/component_tests/usb_uart/test_source_filter.py @@ -0,0 +1,19 @@ +"""Tests for the usb_uart component's driver source filtering.""" + +from collections.abc import Callable + +from esphome.core import CORE + + +def test_only_configured_driver_compiled( + generate_main: Callable[[str], str], +) -> None: + generate_main("tests/component_tests/usb_uart/test_usb_uart_ft232.yaml") + from esphome.components.usb_uart import FILTER_SOURCE_FILES + + defines = {define.name for define in CORE.defines} + # cdc_acm is built into usb_uart.cpp and adds no driver define + assert {d for d in defines if d.startswith("USE_USB_UART_")} == { + "USE_USB_UART_FT23XX" + } + assert sorted(FILTER_SOURCE_FILES()) == ["ch34x.cpp", "cp210x.cpp", "pl2303.cpp"] diff --git a/tests/component_tests/usb_uart/test_usb_uart_ft232.yaml b/tests/component_tests/usb_uart/test_usb_uart_ft232.yaml new file mode 100644 index 00000000000..e68a6246bf6 --- /dev/null +++ b/tests/component_tests/usb_uart/test_usb_uart_ft232.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +usb_uart: + - type: ft232 + channels: + - id: ft_channel + baud_rate: 115200 + - type: cdc_acm + vid: 0x1234 + pid: 0x5678 + channels: + - id: cdc_channel From 7af96c8d360ef51b94051f3a48730bcbb43a8542 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:07:10 +0000 Subject: [PATCH 045/128] Bump CodSpeedHQ/action from 5.2.1 to 5.4.0 (#20197) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ebbe52d205..5eb96af59b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -626,7 +626,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1 + uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0 with: run: | . venv/bin/activate From 888043762ee2bd183f26529657a68cdfbcc5afc4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:07:33 +0000 Subject: [PATCH 046/128] Bump filelock from 4.0.6 to 4.0.9 (#20195) Co-authored-by: J. Nick Koston Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1f21fd8160c..ce090503e07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.2 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.0.6 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.0.9 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From cf334e875853ee7f48f6c94706e2f3bc3d5ff051 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:07:37 +0000 Subject: [PATCH 047/128] Bump pylint from 4.0.10 to 4.1.1 (#20194) 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 fa568526372..b0ebf3a4095 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.0.10 +pylint==4.1.1 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.9 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py From 9af9414dcc4370b48b280b4b485bc72c70f4e51a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:07:43 +0000 Subject: [PATCH 048/128] Bump cryptography from 48.0.1 to 50.0.2 (#20193) Co-authored-by: J. Nick Koston Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ce090503e07..744c5c1e669 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # cryptography 49+ ships no Intel macOS wheels (arm64 only); esptool caps <49 there. # Keep 48.0.1, the last universal2 release, so esphome stays installable on Intel Macs. -cryptography==50.0.1; platform_system != "Darwin" or platform_machine != "x86_64" +cryptography==50.0.2; platform_system != "Darwin" or platform_machine != "x86_64" cryptography==48.0.1; platform_system == "Darwin" and platform_machine == "x86_64" voluptuous==0.16.0 PyYAML==6.0.3 From cdcc7bbf744d237f87357d479480ccbe6770e770 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:07:49 +0000 Subject: [PATCH 049/128] Bump ruff from 0.16.9 to 0.16.10 (#20196) Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 48c0d1d6fd4..727e8e2f9aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.9 + rev: v0.16.10 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index b0ebf3a4095..e6585f246ea 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.1.1 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.9 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.4 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 039f07d3320a019b86ab05ea9286535529d8fe53 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:10:00 +0000 Subject: [PATCH 050/128] Bump bundled esphome-device-builder to 1.21.0 (#20199) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 50cb8e137d4..9f29062e093 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.20.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.21.0 RUN \ platformio settings set enable_telemetry No \ From 4097f45677ba79de580344982af1d3a23f18dd59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 19:23:39 -0500 Subject: [PATCH 051/128] [sx1509] Keep the keypad keys in flash (#20111) --- esphome/components/sx1509/__init__.py | 11 ++++++-- esphome/components/sx1509/sx1509.cpp | 6 +++-- esphome/components/sx1509/sx1509.h | 5 ++-- tests/component_tests/sx1509/__init__.py | 0 tests/component_tests/sx1509/config/keys.yaml | 23 ++++++++++++++++ tests/component_tests/sx1509/test_keys.py | 27 +++++++++++++++++++ 6 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/sx1509/__init__.py create mode 100644 tests/component_tests/sx1509/config/keys.yaml create mode 100644 tests/component_tests/sx1509/test_keys.py diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index 7694b8f7326..e4df7b443c2 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -43,6 +43,10 @@ SX1509KeyTrigger = sx1509_ns.class_( def check_keys(config: ConfigType) -> ConfigType: + for ch in config.get(CONF_KEYS, ""): + if not ch.isascii(): + # Each key is reported as one byte, so only ASCII characters can be key codes + raise cv.Invalid(f"Key code {ch!r} is not an ASCII character") if ( CONF_KEYS in config and len(config[CONF_KEYS]) != config[CONF_KEY_ROWS] * config[CONF_KEY_COLUMNS] @@ -98,8 +102,11 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_sleep_time(conf[CONF_SLEEP_TIME])) cg.add(var.set_scan_time(conf[CONF_SCAN_TIME])) cg.add(var.set_debounce_time(conf[CONF_DEBOUNCE_TIME])) - if keys := conf.get(CONF_KEYS): - cg.add(var.set_keys(keys)) + if (keys := conf.get(CONF_KEYS)) is not None: + table = cg.shared_progmem_array( + "sx1509_keys", cg.uint8, list(keys.encode()) + ) + cg.add(var.set_keys(table)) for tconf in conf.get(CONF_ON_KEY, []): trigger = cg.new_Pvariable(tconf[CONF_TRIGGER_ID]) cg.add(var.register_key_trigger(trigger)) diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 2397049000d..ee8606db96d 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -48,7 +48,7 @@ void SX1509Component::loop() { uint16_t key_data = this->read_key_data(); for (auto *binary_sensor : this->keypad_binary_sensors_) binary_sensor->process(key_data); - if (this->keys_.empty()) + if (this->keys_ == nullptr) return; if (key_data == 0) { this->last_key_ = 0; @@ -64,7 +64,9 @@ void SX1509Component::loop() { break; } col -= 8; - uint8_t key = this->keys_[row * this->cols_ + col]; + if (row >= this->rows_ || col >= this->cols_) // a partial read can leave only a row or a column bit + return; + uint8_t key = progmem_read_byte(&this->keys_[row * this->cols_ + col]); if (key == this->last_key_) return; this->last_key_ = key; diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index c7aed2cddd1..ef6ee6ec5e6 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -51,7 +51,8 @@ class SX1509Component final : public Component, this->cols_ = cols; this->has_keypad_ = true; }; - void set_keys(std::string keys) { this->keys_ = std::move(keys); }; // NOLINT(performance-unnecessary-value-param) + /// `keys` is a codegen PROGMEM table with one key code per button. + void set_keys(const uint8_t *keys) { this->keys_ = keys; } void set_sleep_time(uint16_t sleep_time) { this->sleep_time_ = sleep_time; }; void set_scan_time(uint8_t scan_time) { this->scan_time_ = scan_time; }; void set_debounce_time(uint8_t debounce_time = 1) { this->debounce_time_ = debounce_time; }; @@ -76,7 +77,7 @@ class SX1509Component final : public Component, bool has_keypad_ = false; uint8_t rows_ = 0; uint8_t cols_ = 0; - std::string keys_; + const uint8_t *keys_{nullptr}; uint16_t sleep_time_ = 128; uint8_t scan_time_ = 1; uint8_t debounce_time_ = 1; diff --git a/tests/component_tests/sx1509/__init__.py b/tests/component_tests/sx1509/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/sx1509/config/keys.yaml b/tests/component_tests/sx1509/config/keys.yaml new file mode 100644 index 00000000000..e5be4554c9a --- /dev/null +++ b/tests/component_tests/sx1509/config/keys.yaml @@ -0,0 +1,23 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +i2c: + sda: 4 + scl: 5 + +sx1509: + - id: sx_a + address: 0x3E + keypad: + key_rows: 2 + key_columns: 2 + keys: "abcd" + - id: sx_b + address: 0x3F + keypad: + key_rows: 2 + key_columns: 2 + keys: "abcd" diff --git a/tests/component_tests/sx1509/test_keys.py b/tests/component_tests/sx1509/test_keys.py new file mode 100644 index 00000000000..d7d41ae5251 --- /dev/null +++ b/tests/component_tests/sx1509/test_keys.py @@ -0,0 +1,27 @@ +"""SX1509 keypad key codes live in a shared PROGMEM table instead of a std::string.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.sx1509 import check_keys +import esphome.config_validation as cv + + +def test_keys_use_shared_progmem_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("keys.yaml")) + + assert ( + "static constexpr uint8_t sx1509_keys[] PROGMEM = {97, 98, 99, 100};" + in main_cpp + ) + assert main_cpp.count("->set_keys(sx1509_keys);") == 2 + + +def test_non_ascii_keys_are_rejected() -> None: + with pytest.raises(cv.Invalid, match="'é' is not an ASCII"): + check_keys({"keys": "1é34"}) From 40af90ad25b23b91e4f97a324375e5da2fd3de0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 19:24:13 -0500 Subject: [PATCH 052/128] [logger] Use the static ring buffer structure as its handle on ESP32 (#20129) --- esphome/components/logger/__init__.py | 10 +++++++ .../logger/task_log_buffer_esp32.cpp | 20 +++++--------- .../components/logger/task_log_buffer_esp32.h | 8 ++++-- tests/component_tests/logger/test_logger.py | 27 +++++++++++++++++++ 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index d6b9bca38ba..a966ba67ad8 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -235,6 +235,15 @@ def warn_ram_log_strings(config: ConfigType) -> ConfigType: return config +def validate_task_log_buffer_alignment(value: int) -> int: + # ESP-IDF rejects a no-split ring buffer whose size is not a multiple of 4 + if CORE.is_esp32 and value % 4: + raise cv.Invalid( + f"{CONF_TASK_LOG_BUFFER_SIZE} must be a multiple of 4 on ESP32" + ) + return value + + def validate_wait_for_cdc(config: ConfigType) -> ConfigType: if config.get(CONF_WAIT_FOR_CDC) and config.get(CONF_HARDWARE_UART) != USB_CDC: raise cv.Invalid("wait_for_cdc requires hardware_uart: USB_CDC") @@ -282,6 +291,7 @@ CONFIG_SCHEMA = cv.All( max=32768, # Max: Depends on message sizes, typically ~300 messages with default size ), ), + validate_task_log_buffer_alignment, ), cv.SplitDefault( CONF_HARDWARE_UART, diff --git a/esphome/components/logger/task_log_buffer_esp32.cpp b/esphome/components/logger/task_log_buffer_esp32.cpp index cb97f5504fa..75b567ff892 100644 --- a/esphome/components/logger/task_log_buffer_esp32.cpp +++ b/esphome/components/logger/task_log_buffer_esp32.cpp @@ -10,15 +10,7 @@ namespace esphome::logger { TaskLogBuffer::TaskLogBuffer() { // Create a static ring buffer with RINGBUF_TYPE_NOSPLIT for message integrity // Storage is a member array (embedded in Logger), no heap allocation needed - this->ring_buffer_ = - xRingbufferCreateStatic(sizeof(this->storage_), RINGBUF_TYPE_NOSPLIT, this->storage_, &this->structure_); -} - -TaskLogBuffer::~TaskLogBuffer() { - if (this->ring_buffer_ != nullptr) { - vRingbufferDelete(this->ring_buffer_); - this->ring_buffer_ = nullptr; - } + xRingbufferCreateStatic(sizeof(this->storage_), RINGBUF_TYPE_NOSPLIT, this->storage_, &this->structure_); } bool TaskLogBuffer::borrow_message_main_loop(LogMessage *&message, uint16_t &text_length) { @@ -27,7 +19,7 @@ bool TaskLogBuffer::borrow_message_main_loop(LogMessage *&message, uint16_t &tex } size_t item_size = 0; - void *received_item = xRingbufferReceive(ring_buffer_, &item_size, 0); + void *received_item = xRingbufferReceive(this->handle_(), &item_size, 0); if (received_item == nullptr) { return false; } @@ -44,7 +36,7 @@ void TaskLogBuffer::release_message_main_loop() { if (this->current_token_ == nullptr) { return; } - vRingbufferReturnItem(ring_buffer_, this->current_token_); + vRingbufferReturnItem(this->handle_(), this->current_token_); this->current_token_ = nullptr; // Update counter to mark all messages as processed last_processed_counter_ = message_counter_.load(std::memory_order_relaxed); @@ -71,7 +63,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin // Acquire memory directly from the ring buffer void *acquired_memory = nullptr; - BaseType_t result = xRingbufferSendAcquire(ring_buffer_, &acquired_memory, total_size, 0); + BaseType_t result = xRingbufferSendAcquire(this->handle_(), &acquired_memory, total_size, 0); if (result != pdTRUE || acquired_memory == nullptr) { return false; // Failed to acquire memory @@ -100,7 +92,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin // Handle unexpected formatting error if (ret <= 0) { - vRingbufferReturnItem(ring_buffer_, acquired_memory); + vRingbufferReturnItem(this->handle_(), acquired_memory); return false; } @@ -111,7 +103,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin msg->text_length = text_length; // Complete the send operation with the acquired memory - result = xRingbufferSendComplete(ring_buffer_, acquired_memory); + result = xRingbufferSendComplete(this->handle_(), acquired_memory); if (result != pdTRUE) { return false; // Failed to complete the message send diff --git a/esphome/components/logger/task_log_buffer_esp32.h b/esphome/components/logger/task_log_buffer_esp32.h index e8197667954..0ffaa044931 100644 --- a/esphome/components/logger/task_log_buffer_esp32.h +++ b/esphome/components/logger/task_log_buffer_esp32.h @@ -47,7 +47,7 @@ class TaskLogBuffer { }; TaskLogBuffer(); - ~TaskLogBuffer(); + // No destructor: Logger is never destroyed // NOT thread-safe - borrow a message from the ring buffer, only call from main loop bool borrow_message_main_loop(LogMessage *&message, uint16_t &text_length); @@ -68,7 +68,11 @@ class TaskLogBuffer { static constexpr size_t size() { return ESPHOME_TASK_LOG_BUFFER_SIZE; } private: - RingbufHandle_t ring_buffer_{nullptr}; // FreeRTOS ring buffer handle + // xRingbufferCreateStatic() returns the static structure itself as the handle; it only + // returns NULL for a no-split size that is unaligned or under two item headers + static_assert(ESPHOME_TASK_LOG_BUFFER_SIZE % 4 == 0, "task_log_buffer_size must be a multiple of 4"); + RingbufHandle_t handle_() { return &this->structure_; } + StaticRingbuffer_t structure_; // Static structure for the ring buffer uint8_t storage_[ESPHOME_TASK_LOG_BUFFER_SIZE]; // Embedded in Logger (no separate heap allocation) diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 199d67ff5ca..20b99f718f4 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -6,7 +6,11 @@ import re import pytest +from esphome.components.logger import validate_task_log_buffer_alignment +from esphome.config_validation import Invalid +from esphome.const import PlatformFramework from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable def test_logger_pre_setup_before_other_components(generate_main): @@ -111,3 +115,26 @@ def test_flash_log_strings_default_does_not_warn( generate_main("tests/component_tests/logger/test_logger.yaml") assert "esp8266_store_log_strings_in_flash" not in caplog.text + + +@pytest.mark.parametrize("value", [0, 640, 768, 32768]) +def test_task_log_buffer_size_aligned_on_esp32( + set_core_config: SetCoreConfigCallable, value: int +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + assert validate_task_log_buffer_alignment(value) == value + + +def test_task_log_buffer_size_unaligned_rejected_on_esp32( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(Invalid, match="multiple of 4"): + validate_task_log_buffer_alignment(641) + + +def test_task_log_buffer_size_unaligned_allowed_on_libretiny( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.BK72XX_ARDUINO) + assert validate_task_log_buffer_alignment(641) == 641 From 80dc93d6cea6d69317e3c4b6b7de8fad360c082b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 19:24:31 -0500 Subject: [PATCH 053/128] [core] [select] Keep select options in flash with a shared ConstVector (#20117) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_connection.cpp | 4 +- esphome/components/api/api_pb2.h | 2 +- esphome/components/api/api_pb2_includes.h | 1 + esphome/components/select/__init__.py | 4 +- esphome/components/select/select_traits.cpp | 15 ++- esphome/components/select/select_traits.h | 18 +++- esphome/core/helpers.h | 79 +++++++++++++-- .../select/config/select_options.yaml | 26 +++++ .../select/test_select_options.py | 27 ++++++ tests/components/core/const_vector_test.cpp | 96 +++++++++++++++++++ .../components/select/select_traits_test.cpp | 83 ++++++++++++++++ 12 files changed, 336 insertions(+), 21 deletions(-) create mode 100644 tests/component_tests/select/config/select_options.yaml create mode 100644 tests/component_tests/select/test_select_options.py create mode 100644 tests/components/core/const_vector_test.cpp create mode 100644 tests/components/select/select_traits_test.cpp diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 687dc1ca951..a03bf5fc1a9 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1439,7 +1439,7 @@ message ListEntitiesSelectResponse { reserved 4; // Deprecated: was string unique_id string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; - repeated string options = 6 [(container_pointer_no_template) = "FixedVector"]; + repeated string options = 6 [(container_pointer_no_template) = "std::span"]; bool disabled_by_default = 7; EntityCategory entity_category = 8; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0207bc14b9f..e50bf2f722a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -990,7 +990,9 @@ uint16_t APIConnection::try_send_select_state(EntityBase *entity, APIConnection uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *select = static_cast(entity); ListEntitiesSelectResponse msg; - msg.options = &select->traits.get_options(); + const auto &opts = select->traits.get_options(); + const std::span options(opts.data(), opts.size()); + msg.options = &options; return fill_and_encode_entity_info(select, msg, conn, remaining_size); } void APIConnection::on_select_command_request(const SelectCommandRequest &msg) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index dbf0fb49a1e..b4e3d4c55b4 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1909,7 +1909,7 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_select_response"); } #endif - const FixedVector *options{}; + const std::span *options{}; static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index 70ba579fcc3..833e6529e2a 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -28,6 +28,7 @@ // Standard library includes that might be needed #include +#include #include #include diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index fec88d2bfd1..fa266fc81f2 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -95,7 +95,9 @@ def select_schema( @setup_entity("select") async def setup_select_core_(var, config, *, options: list[str]): - cg.add(var.traits.set_options(options)) + if options: + table = cg.shared_progmem_array("select_options", cg.const_char_ptr, options) + cg.add(var.traits.set_options_static(table, len(options))) for conf in config.get(CONF_ON_VALUE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) diff --git a/esphome/components/select/select_traits.cpp b/esphome/components/select/select_traits.cpp index 67a5118646a..23d1f47cce1 100644 --- a/esphome/components/select/select_traits.cpp +++ b/esphome/components/select/select_traits.cpp @@ -2,13 +2,18 @@ namespace esphome::select { -void SelectTraits::set_options(const std::initializer_list &options) { this->options_ = options; } +// Runtime option lists are copied, since the argument may not outlive the select; one +// out of line copy keeps a single instance of the copy code. +void SelectTraits::set_options_copy_(const char *const *options, size_t count) { + this->options_.assign_copy(options, count); +} + +void SelectTraits::set_options(const std::initializer_list &options) { + this->set_options_copy_(options.begin(), options.size()); +} void SelectTraits::set_options(const FixedVector &options) { - this->options_.init(options.size()); - for (const auto &opt : options) { - this->options_.push_back(opt); - } + this->set_options_copy_(options.begin(), options.size()); } } // namespace esphome::select diff --git a/esphome/components/select/select_traits.h b/esphome/components/select/select_traits.h index e1b261bc96f..47997ac7f28 100644 --- a/esphome/components/select/select_traits.h +++ b/esphome/components/select/select_traits.h @@ -5,14 +5,28 @@ namespace esphome::select { +/// Option strings: a shared codegen table, or a copy of a runtime list. +using SelectOptions = ConstVector; + class SelectTraits { public: + SelectTraits() = default; + SelectTraits(const SelectTraits &) = delete; + SelectTraits &operator=(const SelectTraits &) = delete; + + /// Codegen only: points at a table that outlives the select. Call before any runtime set_options; + /// it does not free a previous copy (generated setup() runs before any lambda or automation). + void set_options_static(const char *const *options, size_t count) { this->options_.assign_static(options, count); } + /// Runtime lists: the pointer list is copied, as before; the strings must still outlive the select. + void set_options(const SelectOptions &options) { this->set_options_copy_(options.data(), options.size()); } void set_options(const std::initializer_list &options); void set_options(const FixedVector &options); - const FixedVector &get_options() const { return this->options_; } + const SelectOptions &get_options() const { return this->options_; } protected: - FixedVector options_; + void set_options_copy_(const char *const *options, size_t count); + + SelectOptions options_; }; } // namespace esphome::select diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 6d00e187999..b88a9d70e93 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -129,23 +129,82 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); /// @name Container utilities ///@{ -/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory) -/// Avoids copying data from flash to RAM by keeping a pointer to the flash data. -/// Similar to std::span but with minimal overhead for embedded systems. - -template class ConstVector { +/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory). +/// Iterators are raw pointers like FixedVector. With Owning = true it can also hold a heap copy it +/// owns (see the specialization below); the default view never frees and has no extra cost. +template class ConstVector { public: + using value_type = T; + + constexpr ConstVector() = default; constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {} - const constexpr T &operator[](size_t i) const { return data_[i]; } - constexpr size_t size() const { return size_; } - constexpr bool empty() const { return size_ == 0; } + const T *begin() const { return this->data_; } + const T *end() const { return this->data_ + this->size_; } + const T *data() const { return this->data_; } + constexpr size_t size() const { return this->size_; } + constexpr bool empty() const { return this->size_ == 0; } + const constexpr T &operator[](size_t i) const { return this->data_[i]; } + const T &at(size_t i) const { return this->data_[i]; } protected: - const T *data_; - size_t size_; + const T *data_{nullptr}; + size_t size_{0}; }; +/// Owning variant: a codegen table that outlives it, or a heap copy of a runtime list it owns. +/// Ownership is the top bit of the size; it is not copyable, so a copy can never outlive the owner. +/// Elements must be whole words so ESP8266 can read a codegen table from flash. +template class ConstVector { + static_assert(std::is_trivially_copyable_v && sizeof(T) % sizeof(uint32_t) == 0, + "ConstVector elements must be whole words so ESP8266 can read them from flash"); + + public: + using value_type = T; + + constexpr ConstVector() = default; + constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {} + ConstVector(const ConstVector &) = delete; + ConstVector &operator=(const ConstVector &) = delete; + ~ConstVector() { this->release_(); } + + const T *begin() const { return this->data_; } + const T *end() const { return this->data_ + this->size(); } + const T *data() const { return this->data_; } + size_t size() const { return this->size_ & ~OWNED_BIT; } + bool empty() const { return this->size() == 0; } + const T &operator[](size_t index) const { return this->data_[index]; } + const T &at(size_t index) const { return this->data_[index]; } + + /// Codegen only: call before any runtime copy; it does not free a previous owned copy + /// (generated setup() runs before any lambda or automation can call set_options). + void assign_static(const T *data, size_t size) { + this->data_ = data; + this->size_ = size; + } + /// Copies the list into a heap array this owns, freeing a previous owned copy. + void assign_copy(const T *data, size_t size) { + auto *table = new T[size]; // NOLINT(cppcoreguidelines-owning-memory) + std::copy(data, data + size, table); + this->release_(); + this->data_ = table; + this->size_ = size | OWNED_BIT; + } + + protected: + static constexpr size_t OWNED_BIT = size_t{1} << (sizeof(size_t) * 8 - 1); + + void release_() { + if (this->size_ & OWNED_BIT) + delete[] this->data_; // NOLINT(cppcoreguidelines-owning-memory) + } + + const T *data_{nullptr}; + size_t size_{0}; // top bit set when data_ is an owned heap copy +}; +static_assert(sizeof(ConstVector) == 2 * sizeof(void *), + "ConstVector must stay a pointer and a size"); + /// Small buffer optimization - stores data inline when small, heap-allocates for large data /// This avoids heap fragmentation for common small allocations while supporting arbitrary sizes. /// Memory management is encapsulated - callers just use set() and data(). diff --git a/tests/component_tests/select/config/select_options.yaml b/tests/component_tests/select/config/select_options.yaml new file mode 100644 index 00000000000..474fc7cec49 --- /dev/null +++ b/tests/component_tests/select/config/select_options.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +select: + - platform: template + id: fan_a + name: Fan A + optimistic: true + options: [low, medium, high] + - platform: template + id: fan_b + name: Fan B + optimistic: true + options: [low, medium, high] + - platform: template + id: mode + name: Mode + optimistic: true + options: [auto, manual] + - platform: copy + id: fan_copy + name: Fan Copy + source_id: fan_a diff --git a/tests/component_tests/select/test_select_options.py b/tests/component_tests/select/test_select_options.py new file mode 100644 index 00000000000..0fe70f7f2cb --- /dev/null +++ b/tests/component_tests/select/test_select_options.py @@ -0,0 +1,27 @@ +"""Select options are shared PROGMEM tables of option pointers.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_select_options_use_shared_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("select_options.yaml")) + + calls = { + var: table + for var, table, _ in re.findall( + r"(\w+)->traits\.set_options_static\((\w+), (\d+)\);", main_cpp + ) + } + assert set(calls) == {"fan_a", "fan_b", "mode"} + assert calls["fan_a"] == calls["fan_b"] != calls["mode"] + assert ( + f'static constexpr const char * {calls["fan_a"]}[] PROGMEM = {{"low", "medium", "high"}};' + in main_cpp + ) + # The copy select takes its options from the source at setup + assert "fan_copy->traits.set_options_static(" not in main_cpp diff --git a/tests/components/core/const_vector_test.cpp b/tests/components/core/const_vector_test.cpp new file mode 100644 index 00000000000..aae067fb0f6 --- /dev/null +++ b/tests/components/core/const_vector_test.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::testing { + +static constexpr const char *const TABLE[] = {"a", "b", "c"}; + +// Exposes the owned flag, which is protected. +class ProbeVector : public ConstVector { + public: + using ConstVector::ConstVector; + bool owned() const { return (this->size_ & OWNED_BIT) != 0; } +}; + +TEST(ConstVector, StaticTableIsViewedNotOwned) { + ProbeVector list; + EXPECT_TRUE(list.empty()); + list.assign_static(TABLE, 3); + EXPECT_EQ(list.data(), TABLE); + EXPECT_EQ(list.size(), 3U); + EXPECT_FALSE(list.owned()); + EXPECT_STREQ(list[1], "b"); + EXPECT_STREQ(list.at(2), "c"); +} + +TEST(ConstVector, CopyIsOwnedAndSizeMasksTheFlag) { + ProbeVector list; + list.assign_copy(TABLE, 3); + EXPECT_NE(list.data(), TABLE); + EXPECT_TRUE(list.owned()); + EXPECT_EQ(list.size(), 3U); + EXPECT_STREQ(list[0], "a"); + + const char *const next[] = {"x", "y"}; + list.assign_copy(next, 2); // frees the previous copy + EXPECT_TRUE(list.owned()); + EXPECT_EQ(list.size(), 2U); + EXPECT_STREQ(list[1], "y"); +} + +TEST(ConstVector, StaticThenRuntimeCopiesNeverFreeTheTable) { + ProbeVector list; + list.assign_static(TABLE, 3); + EXPECT_FALSE(list.owned()); + list.assign_copy(TABLE, 2); // the static table is not owned, so nothing is freed + EXPECT_TRUE(list.owned()); + EXPECT_NE(list.data(), TABLE); + const char *const next[] = {"x"}; + list.assign_copy(next, 1); // frees the previous copy + EXPECT_EQ(list.size(), 1U); + EXPECT_STREQ(list[0], "x"); + EXPECT_STREQ(TABLE[0], "a"); +} + +TEST(ConstVector, EmptyCopyIsEmptyAndFreedOnNextSet) { + ProbeVector list; + list.assign_copy(TABLE, 3); + list.assign_copy(TABLE, 0); + EXPECT_TRUE(list.empty()); + list.assign_copy(TABLE, 2); // frees the empty copy + EXPECT_EQ(list.size(), 2U); +} + +TEST(ConstVector, OwningVariantIsNotCopyable) { + static_assert(!std::is_copy_constructible_v>); + static_assert(!std::is_copy_assignable_v>); +} + +TEST(ConstVector, CopyFromItsOwnStorage) { + ProbeVector list; + list.assign_copy(TABLE, 3); + list.assign_copy(list.data(), list.size()); + EXPECT_EQ(list.size(), 3U); + EXPECT_STREQ(list[2], "c"); +} + +TEST(ConstVector, PlainViewHasNoOwnershipCost) { + static_assert(std::is_trivially_copyable_v>); + static_assert(std::is_trivially_destructible_v>); + ConstVector list(TABLE, 3); + EXPECT_EQ(list.size(), 3U); + EXPECT_STREQ(list[2], "c"); +} + +TEST(ConstVector, IteratorsAreRawPointers) { + ConstVector list(TABLE, 3); + static_assert(std::is_same_v); + const auto *it = std::find(list.begin(), list.end(), TABLE[1]); + EXPECT_EQ(it - list.begin(), 1); +} + +} // namespace esphome::testing diff --git a/tests/components/select/select_traits_test.cpp b/tests/components/select/select_traits_test.cpp new file mode 100644 index 00000000000..2280dc203d9 --- /dev/null +++ b/tests/components/select/select_traits_test.cpp @@ -0,0 +1,83 @@ +#include + +#include +#include + +#include "esphome/components/select/select_traits.h" + +namespace esphome::select::testing { + +static constexpr const char *const OPTIONS[] = {"low", "medium", "high"}; + +TEST(SelectTraits, ViewsTheTableWithoutCopying) { + SelectTraits traits; + EXPECT_TRUE(traits.get_options().empty()); + traits.set_options_static(OPTIONS, 3); + const auto &options = traits.get_options(); + EXPECT_EQ(options.size(), 3U); + EXPECT_FALSE(options.empty()); + EXPECT_EQ(options.data(), OPTIONS); + EXPECT_STREQ(options[1], "medium"); + EXPECT_STREQ(options.at(2), "high"); + std::vector seen; + for (const char *option : options) + seen.emplace_back(option); + EXPECT_EQ(seen, (std::vector{"low", "medium", "high"})); +} + +TEST(SelectTraits, RuntimeListsAreCopied) { + SelectTraits traits; + traits.set_options({"a", "b"}); + EXPECT_EQ(traits.get_options().size(), 2U); + EXPECT_STREQ(traits.get_options()[1], "b"); + + FixedVector list; + list.init(3); + list.push_back("x"); + list.push_back("y"); + list.push_back("z"); + traits.set_options(list); + EXPECT_NE(traits.get_options().data(), list.begin()); + EXPECT_EQ(traits.get_options().size(), 3U); + EXPECT_STREQ(traits.get_options().at(2), "z"); + + // A later runtime list replaces the earlier copy + traits.set_options({"only"}); + EXPECT_EQ(traits.get_options().size(), 1U); + EXPECT_STREQ(traits.get_options()[0], "only"); +} + +TEST(SelectTraits, CopyingAnotherSelectSurvivesItsNextRuntimeList) { + SelectTraits source; + source.set_options({"a", "b"}); + SelectTraits copy; + copy.set_options(source.get_options()); + EXPECT_NE(copy.get_options().data(), source.get_options().data()); + + source.set_options({"c"}); + ASSERT_EQ(copy.get_options().size(), 2U); + EXPECT_STREQ(copy.get_options()[0], "a"); + EXPECT_STREQ(copy.get_options()[1], "b"); +} + +TEST(SelectTraits, StaticTablesAreNeverOwned) { + SelectTraits traits; + traits.set_options_static(OPTIONS, 3); + EXPECT_EQ(traits.get_options().data(), OPTIONS); + EXPECT_EQ(traits.get_options().size(), 3U); + // A runtime list after a static one copies and leaves the static table alone + traits.set_options({"x"}); + EXPECT_NE(traits.get_options().data(), OPTIONS); + EXPECT_EQ(traits.get_options().size(), 1U); + EXPECT_STREQ(OPTIONS[0], "low"); +} + +TEST(SelectTraits, CopyOfItsOwnOptionsStaysValid) { + SelectTraits traits; + traits.set_options({"a", "b"}); + traits.set_options(traits.get_options()); + ASSERT_EQ(traits.get_options().size(), 2U); + EXPECT_STREQ(traits.get_options()[1], "b"); +} + +} // namespace esphome::select::testing From 3a3707926dfa906672455bfd8d500c20415d0f82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 19:25:14 -0500 Subject: [PATCH 054/128] [wifi] Run the LN882x fallback AP on its own instead of retrying AP+STA every loop (#20166) --- esphome/components/wifi/__init__.py | 4 + esphome/components/wifi/wifi_component.cpp | 125 ++++++++++++++++-- esphome/components/wifi/wifi_component.h | 12 ++ .../wifi/wifi_component_libretiny.cpp | 7 + esphome/core/defines.h | 1 + .../captive_portal/test.ln882x-ard.yaml | 1 + .../provisioning/test.ln882x-ard.yaml | 15 +++ 7 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 tests/components/captive_portal/test.ln882x-ard.yaml create mode 100644 tests/components/provisioning/test.ln882x-ard.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 676aa0d2942..27ebdbc9763 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -669,6 +669,10 @@ async def to_code(config): if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): cg.add(var.set_ap_timeout(ap_timeout)) cg.add_define("USE_WIFI_AP") + # The LN882H radio cannot run the AP and STA together; the fallback AP + # takes turns with the networks instead. + if CORE.is_ln882x: + cg.add_define("USE_WIFI_AP_EXCLUSIVE") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which # drops SoftAP support / the LWIP DHCP server when AP mode is unused. diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 86e13ef84b0..fd982c0d312 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -380,6 +380,25 @@ static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1; /// Cooldown duration in milliseconds after adapter restart or repeated failures /// Allows WiFi hardware to stabilize before next connection attempt static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; +#ifdef USE_WIFI_AP +/// Whether the radio runs the fallback AP on its own, without STA alongside. +#ifdef USE_WIFI_AP_EXCLUSIVE +static constexpr bool WIFI_AP_EXCLUSIVE = true; +#else +static constexpr bool WIFI_AP_EXCLUSIVE = false; +#endif +#endif +#ifdef USE_WIFI_AP_EXCLUSIVE +/// On a radio that cannot run the AP and STA together: how long the fallback +/// AP stays up while nobody uses it before it pauses so the networks can be +/// tried again, and how long new credentials give the portal to answer +/// before the AP drops. +static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000; +/// The AP pauses after this long even with clients counted, so a lost +/// disconnect event cannot keep the networks from being tried for good. +static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS; +static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000; +#endif /// Cooldown duration when fallback AP is active and captive portal may be running /// Longer interval gives users time to configure WiFi without constant connection attempts @@ -642,6 +661,14 @@ void WiFiComponent::setup() { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { if (this->ap_setup_) { ESP_LOGD(TAG, "Provisioning window closed; disabling AP"); +#ifdef USE_WIFI_AP_EXCLUSIVE + // The networks wait while the AP runs on its own; hand them the radio + // now rather than at the end of the dwell. + if (this->state_ == WIFI_COMPONENT_STATE_AP && this->has_sta()) { + this->pause_exclusive_ap_(); + return; + } +#endif this->wifi_mode_({}, false); } }); @@ -739,8 +766,11 @@ void WiFiComponent::start() { } #ifdef USE_CAPTIVE_PORTAL if (captive_portal::global_captive_portal != nullptr) { - this->wifi_sta_pre_setup_(); - this->start_scanning(); + // Where the radio scans alongside the AP, the portal can list networks. + if (!WIFI_AP_EXCLUSIVE) { + this->wifi_sta_pre_setup_(); + this->start_scanning(); + } captive_portal::global_captive_portal->start(); } #endif @@ -862,8 +892,17 @@ void WiFiComponent::loop() { } break; } - case WIFI_COMPONENT_STATE_OFF: case WIFI_COMPONENT_STATE_AP: +#ifdef USE_WIFI_AP_EXCLUSIVE + // The networks are not tried while the AP is up, so it pauses for + // them once nobody has used it for a while. Without networks the AP + // is all there is and stays. + if (this->has_sta() && now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && + (this->ap_clients_ == 0 || now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) + this->pause_exclusive_ap_(); + break; +#endif + case WIFI_COMPONENT_STATE_OFF: break; case WIFI_COMPONENT_STATE_DISABLED: return; @@ -879,11 +918,23 @@ void WiFiComponent::loop() { provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); #endif if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) { - if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_)) { + if (this->ap_timeout_ != 0 && + (now - this->last_connected_ > this->ap_timeout_) +#ifdef USE_WIFI_AP_EXCLUSIVE + // After a pause, or a start that failed, the networks get a full + // ap_timeout before the AP is tried again. + && now - this->ap_exclusive_changed_ > this->ap_timeout_ +#endif + ) { ESP_LOGI(TAG, "Starting fallback AP"); +#ifdef USE_WIFI_AP_EXCLUSIVE + this->ap_exclusive_changed_ = now; +#endif this->setup_ap_config_(); #ifdef USE_CAPTIVE_PORTAL - if (captive_portal::global_captive_portal != nullptr) { + // Where the AP runs on its own, a portal with no AP behind it would + // only stretch the cooldowns. + if (captive_portal::global_captive_portal != nullptr && (!WIFI_AP_EXCLUSIVE || this->ap_setup_)) { // Reset so we force one full scan after captive portal starts // (previous scans were filtered because captive portal wasn't active yet) this->has_completed_scan_after_captive_portal_start_ = false; @@ -972,7 +1023,13 @@ network::IPAddress WiFiComponent::get_dns_address(int num) { #ifdef USE_WIFI_AP void WiFiComponent::setup_ap_config_() { +#ifdef USE_WIFI_AP_EXCLUSIVE + // The radio cannot run both, so STA goes off while the AP is up. + if (!this->wifi_mode_(false, true)) + return; +#else this->wifi_mode_({}, true); +#endif if (this->ap_setup_) return; @@ -1006,6 +1063,12 @@ void WiFiComponent::setup_ap_config_() { } } this->ap_setup_ = this->wifi_start_ap_(this->ap_); +#ifdef USE_WIFI_AP_EXCLUSIVE + // A failed start must not leave the radio in AP mode, which would refuse + // the STA attempts until the next try. + if (!this->ap_setup_) + this->wifi_mode_({}, false); +#endif char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, @@ -1030,11 +1093,40 @@ void WiFiComponent::setup_ap_config_() { } #endif - if (!this->has_sta()) { + // Where the AP runs on its own, the STA state machine also waits in this + // state until the AP pauses. + if (!this->has_sta() || (WIFI_AP_EXCLUSIVE && this->ap_setup_)) { this->state_ = WIFI_COMPONENT_STATE_AP; } } +#ifdef USE_WIFI_AP_EXCLUSIVE +void WiFiComponent::pause_exclusive_ap_() { + ESP_LOGI(TAG, "Pausing AP to try the networks"); + // Scanning and connecting turn STA back on. + this->disable_ap_(); + this->ap_setup_ = false; + // Clients drop with the AP without a disconnect event for each. + this->ap_clients_ = 0; + this->ap_exclusive_changed_ = App.get_loop_component_start_time(); + // A fresh connection cycle, as at boot, rather than resuming the one the + // AP cut short. + this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; + this->num_retried_ = 0; + this->start_initial_connection_(); +} +#endif + +void WiFiComponent::disable_ap_() { +#ifdef USE_CAPTIVE_PORTAL + if (this->is_captive_portal_active_()) { + captive_portal::global_captive_portal->end(); + } +#endif + ESP_LOGD(TAG, "Disabling AP"); + this->wifi_mode_({}, false); +} + void WiFiComponent::set_ap(const WiFiAP &ap) { this->ap_ = ap; this->has_ap_ = true; @@ -1126,6 +1218,17 @@ void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) { } void WiFiComponent::connect_soon_() { +#ifdef USE_WIFI_AP_EXCLUSIVE + // New credentials from the portal: the AP pauses for them once its answer + // is out, and the STA state machine picks them up from there. + if (this->ap_setup_) { + this->set_timeout("wifi-ap-handover", WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { + if (this->ap_setup_) + this->pause_exclusive_ap_(); + }); + return; + } +#endif // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); @@ -1636,15 +1739,11 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { // Reset to initial phase on successful connection (don't log transition, just reset state) this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; +#ifdef USE_WIFI_AP if (this->has_ap()) { -#ifdef USE_CAPTIVE_PORTAL - if (this->is_captive_portal_active_()) { - captive_portal::global_captive_portal->end(); - } -#endif - ESP_LOGD(TAG, "Disabling AP"); - this->wifi_mode_({}, false); + this->disable_ap_(); } +#endif #ifdef USE_IMPROV_BLE if (this->is_improv_ble_active_()) { improv_ble::global_improv_component->stop(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 9418165501c..9700e36e6f6 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -687,6 +687,12 @@ class WiFiComponent final : public Component { protected: #ifdef USE_WIFI_AP void setup_ap_config_(); + /// End the captive portal and turn the AP off. + void disable_ap_(); +#ifdef USE_WIFI_AP_EXCLUSIVE + /// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout. + void pause_exclusive_ap_(); +#endif #endif // USE_WIFI_AP void print_connect_params_(); @@ -919,6 +925,9 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; +#ifdef USE_WIFI_AP_EXCLUSIVE + uint32_t ap_exclusive_changed_{0}; // When the AP was last started or paused +#endif uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed @@ -1011,6 +1020,9 @@ class WiFiComponent final : public Component { bool scan_done_{false}; bool ap_setup_{false}; bool ap_started_{false}; +#ifdef USE_WIFI_AP_EXCLUSIVE + uint8_t ap_clients_{0}; // Devices joined to the AP, which keep it from pausing +#endif bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; #ifdef USE_WIFI_11KV_SUPPORT diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 1f43f37c0e2..28b30628fdc 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -624,6 +624,9 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { +#ifdef USE_WIFI_AP_EXCLUSIVE + this->ap_clients_++; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto &it = event->data.sta_connected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -633,6 +636,10 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: { +#ifdef USE_WIFI_AP_EXCLUSIVE + if (this->ap_clients_ > 0) + this->ap_clients_--; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto &it = event->data.sta_disconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f701cd29191..abd4a3b08d2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -602,6 +602,7 @@ // to parse against. #ifdef USE_LN882X #define USE_LN882H_BLE_TRACKER +#define USE_WIFI_AP_EXCLUSIVE #else #define USE_BK72XX_BLE_TRACKER #endif diff --git a/tests/components/captive_portal/test.ln882x-ard.yaml b/tests/components/captive_portal/test.ln882x-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/captive_portal/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/provisioning/test.ln882x-ard.yaml b/tests/components/provisioning/test.ln882x-ard.yaml new file mode 100644 index 00000000000..39d8800961e --- /dev/null +++ b/tests/components/provisioning/test.ln882x-ard.yaml @@ -0,0 +1,15 @@ +# Provisioning window on LN882x, where the fallback AP runs on its own: closing +# the window hands the radio back to the networks. +provisioning: + timeout: 1min + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + ap: + ssid: MyAP + +captive_portal: From 8d8bd7311279bf91a8540d5c0f47edcb38cdd088 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 19:28:36 -0500 Subject: [PATCH 055/128] [ble_device_base] Keep MAC filter lists in shared flash tables (#20088) --- .../components/ble_device_base/automation.h | 21 ++++++---- .../components/ble_device_base/automation.py | 24 ++++++++++-- .../components/esp32_ble_tracker/__init__.py | 11 ++++-- .../components/esp32_ble_tracker/automation.h | 17 +++------ .../test_automations_codegen.py | 6 ++- .../esp32_ble_tracker/config/mac_filter.yaml | 27 +++++++++++++ .../esp32_ble_tracker/test_mac_filter.py | 38 +++++++++++++++++++ .../test_automations_codegen.py | 6 ++- .../test_automations_codegen.py | 8 +++- 9 files changed, 128 insertions(+), 30 deletions(-) create mode 100644 tests/component_tests/esp32_ble_tracker/config/mac_filter.yaml create mode 100644 tests/component_tests/esp32_ble_tracker/test_mac_filter.py diff --git a/esphome/components/ble_device_base/automation.h b/esphome/components/ble_device_base/automation.h index ba3128c0eed..a50813c5856 100644 --- a/esphome/components/ble_device_base/automation.h +++ b/esphome/components/ble_device_base/automation.h @@ -11,29 +11,34 @@ #include "esphome/core/automation.h" #include "esphome/core/helpers.h" -#include -#include - namespace esphome::ble_device_base { +/// True if `address` is in `table`, a list of MACs ended by 0. +inline bool mac_in_table(const uint64_t *table, uint64_t address) { + for (; *table != 0; table++) { + if (*table == address) + return true; + } + return false; +} + // on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: template explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } - void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } + /// Table of MACs ended by 0; must outlive the trigger. + void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; } bool parse_device(const ESPBTDevice &device) override { - if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(), - device.address_uint64()) == this->addresses_.end()) { + if (this->addresses_ != nullptr && !mac_in_table(this->addresses_, device.address_uint64())) return false; - } this->trigger(device); return true; } protected: - FixedVector addresses_; + const uint64_t *addresses_{nullptr}; }; // on_ble_service_data_advertise: fires when an advertisement contains service diff --git a/esphome/components/ble_device_base/automation.py b/esphome/components/ble_device_base/automation.py index 6acc4edb924..7cc87add5a0 100644 --- a/esphome/components/ble_device_base/automation.py +++ b/esphome/components/ble_device_base/automation.py @@ -69,13 +69,24 @@ def uuid_trigger_schema( ) +def _filter_mac(value: Any) -> Any: + mac = cv.mac_address(value) + if not any(mac.parts): + # 0 ends the flash MAC table, and it is never a real device address. + raise cv.Invalid("00:00:00:00:00:00 cannot be used as a MAC address filter") + return mac + + +MAC_FILTER_LIST = cv.ensure_list(_filter_mac) + + def advertise_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]: """on_ble_advertise schema: multi-mac list filter, unlike the single-mac uuid_trigger_schema() — pairs with advertise_trigger_to_code().""" return automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class), - cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address), + cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST, } ) @@ -94,11 +105,18 @@ def scan_end_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any] _count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE) +def mac_filter_table(macs: list) -> cg.MockObj: + """Shared flash table of MACs ended by 0 (never a valid address), so triggers store a pointer.""" + return cg.shared_progmem_array( + "ble_mac_filter", cg.uint64, [*(mac.as_hex for mac in macs), 0] + ) + + async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None: """Build an on_ble_advertise trigger (optional multi-mac filter).""" trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - if (macs := conf.get(CONF_MAC_ADDRESS)) is not None: - cg.add(trigger.set_addresses([it.as_hex for it in macs])) + if macs := conf.get(CONF_MAC_ADDRESS): + cg.add(trigger.set_addresses(mac_filter_table(macs))) await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) _count_listener() diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index fc52b92ee7b..59ac445cc06 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -8,6 +8,10 @@ from esphome import automation import esphome.codegen as cg from esphome.components import ble_device_base, esp32_ble, ota from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW +from esphome.components.ble_device_base.automation import ( + MAC_FILTER_LIST, + mac_filter_table, +) from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, @@ -277,7 +281,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( ESPBTAdvertiseTrigger ), - cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address), + cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST, } ), cv.Optional( @@ -381,9 +385,8 @@ async def to_code(config: ConfigType) -> None: for conf in config.get(CONF_ON_BLE_ADVERTISE, []): _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - if CONF_MAC_ADDRESS in conf: - addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]] - cg.add(trigger.set_addresses(addr_list)) + if macs := conf.get(CONF_MAC_ADDRESS): + cg.add(trigger.set_addresses(mac_filter_table(macs))) await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []): _request_listener_slot() diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 9b4eb2c3b30..f30f63a5c87 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -1,10 +1,9 @@ #pragma once #include "esphome/core/automation.h" +#include "esphome/components/ble_device_base/automation.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" -#include - #ifdef USE_ESP32 namespace esphome::esp32_ble_tracker { @@ -12,22 +11,18 @@ namespace esphome::esp32_ble_tracker { class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } - void set_addresses(std::initializer_list addresses) { this->address_vec_ = addresses; } + /// Table of MACs ended by 0; must outlive the trigger. + void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; } bool parse_device(const ESPBTDevice &device) override { - uint64_t u64_addr = device.address_uint64(); - if (!address_vec_.empty()) { - if (std::find(address_vec_.begin(), address_vec_.end(), u64_addr) == address_vec_.end()) { - return false; - } - } - + if (this->addresses_ != nullptr && !ble_device_base::mac_in_table(this->addresses_, device.address_uint64())) + return false; this->trigger(device); return true; } protected: - std::vector address_vec_; + const uint64_t *addresses_{nullptr}; }; class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { diff --git a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py index 3f5280d9659..2fb1881d1f0 100644 --- a/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/bk72xx_ble_tracker/test_automations_codegen.py @@ -22,7 +22,11 @@ def test_trigger_codegen( main_cpp = generate_main(component_config_path("test_automations.yaml")) # on_ble_advertise: multi-mac filter (two addresses in one initializer list) - assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp + assert ( + "static constexpr uint64_t ble_mac_filter[] PROGMEM = " + "{0xAC3743775F4CULL, 0x112233445566ULL, 0};" in main_cpp + ) + assert "set_addresses(ble_mac_filter)" in main_cpp # 128-bit service uuid goes out reversed (BLE wire order); single-mac filter assert ( "set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," diff --git a/tests/component_tests/esp32_ble_tracker/config/mac_filter.yaml b/tests/component_tests/esp32_ble_tracker/config/mac_filter.yaml new file mode 100644 index 00000000000..309476a5093 --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/config/mac_filter.yaml @@ -0,0 +1,27 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +logger: + +esp32_ble_tracker: + on_ble_advertise: + - mac_address: + - AC:37:43:77:5F:4C + - 11:22:33:44:55:66 + then: + - logger.log: one + - mac_address: + - AC:37:43:77:5F:4C + - 11:22:33:44:55:66 + then: + - logger.log: two + - then: + - logger.log: any + - mac_address: [] + then: + - logger.log: empty diff --git a/tests/component_tests/esp32_ble_tracker/test_mac_filter.py b/tests/component_tests/esp32_ble_tracker/test_mac_filter.py new file mode 100644 index 00000000000..45805debc85 --- /dev/null +++ b/tests/component_tests/esp32_ble_tracker/test_mac_filter.py @@ -0,0 +1,38 @@ +"""Tests for the esp32_ble_tracker on_ble_advertise MAC filter codegen.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.ble_device_base.automation import MAC_FILTER_LIST + + +def test_mac_filters_share_one_progmem_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Identical MAC lists share one flash table ended by 0; no or an empty filter sets none.""" + main_cpp = generate_main(component_config_path("mac_filter.yaml")) + + assert ( + "static constexpr uint64_t ble_mac_filter[] PROGMEM = " + "{0xAC3743775F4CULL, 0x112233445566ULL, 0};" in main_cpp + ) + assert main_cpp.count("set_addresses(ble_mac_filter);") == 2 + assert main_cpp.count("set_addresses(") == 2 + + +def test_zero_mac_is_rejected() -> None: + """00:00:00:00:00:00 ends the flash table, so it cannot be a filter entry.""" + assert MAC_FILTER_LIST(["11:22:33:44:55:66"])[0].parts == ( + 0x11, + 0x22, + 0x33, + 0x44, + 0x55, + 0x66, + ) + with pytest.raises(cv.Invalid): + MAC_FILTER_LIST(["00:00:00:00:00:00"]) diff --git a/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py index c1ef283ea33..3d275e479e5 100644 --- a/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/ln882h_ble_tracker/test_automations_codegen.py @@ -17,7 +17,11 @@ def test_trigger_codegen( main_cpp = generate_main(component_config_path("test_automations.yaml")) # on_ble_advertise: multi-mac filter (two addresses in one initializer list) - assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp + assert ( + "static constexpr uint64_t ble_mac_filter[] PROGMEM = " + "{0xAC3743775F4CULL, 0x112233445566ULL, 0};" in main_cpp + ) + assert "set_addresses(ble_mac_filter)" in main_cpp # 128-bit service uuid goes out reversed (BLE wire order); single-mac filter assert ( "set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," diff --git a/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py b/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py index 1106fd01eb9..6014e9c285c 100644 --- a/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py +++ b/tests/component_tests/rp2_ble_tracker/test_automations_codegen.py @@ -19,8 +19,12 @@ def test_trigger_codegen( ) -> None: main_cpp = generate_main(component_config_path("test_automations.yaml")) - # on_ble_advertise: multi-mac filter (two addresses in one initializer list) - assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp + # on_ble_advertise: multi-mac filter, a shared flash table ended by 0 + assert ( + "static constexpr uint64_t ble_mac_filter[] PROGMEM = " + "{0xAC3743775F4CULL, 0x112233445566ULL, 0};" in main_cpp + ) + assert "set_addresses(ble_mac_filter)" in main_cpp # 128-bit service uuid goes out reversed (BLE wire order); single-mac filter assert ( "set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB," From 082a0b746ae6d518a7ad0da73ab24bfdd4c574f4 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:45:15 +0200 Subject: [PATCH 056/128] [lvgl] Remove redundant __aexit__ override (#20201) --- esphome/components/lvgl/lvcode.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index d6d1aaa4144..feffac28b33 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -209,9 +209,6 @@ class LvContext(LambdaContext): def __init__(self): super().__init__(parameters=LVGL_COMP_ARG) - async def __aexit__(self, exc_type, exc_val, exc_tb): - await super().__aexit__(exc_type, exc_val, exc_tb) - def add(self, expression: Expression | Statement): cg.add(expression) return expression From 6191b7553eed8a5e1de1d7a384927e3016d046c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Oct 2026 21:07:37 -0500 Subject: [PATCH 057/128] [light] Cover get_effects() as published configs use it in the component test (#20203) --- tests/components/light/common.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 3cf2c592234..64406afda86 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -28,6 +28,21 @@ esphome: ESP_LOGI("test", "Name length: %d", (int) name.size()); } + # get_effects() shapes used by published configs and external components + - lambda: |- + const auto &effects = id(test_monochromatic_light).get_effects(); + auto &same = id(test_monochromatic_light).get_effects(); + uint32_t total = effects.size(); + for (auto *effect : effects) { + ESP_LOGD("test", "Effect %s", effect->get_name().c_str()); + } + if (total > 0) { + ESP_LOGD("test", "First %s", effects.at(0)->get_name().c_str()); + // Raw pointer iterators on purpose: external code binds std::find's result to `const auto *` + auto *it = std::find(same.begin(), same.end(), effects[0]); + ESP_LOGD("test", "Index %d", (int) (it - same.begin())); + } + # Test LightState::get_effect_name() returns StringRef - lambda: |- // Test LightState::get_effect_name() returns StringRef From 718b6651fec7607e016fb106f1e671d39f0164fb Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:20:28 +0200 Subject: [PATCH 058/128] [ci] Run pylint when its version, dependencies or configuration change (#20202) --- script/determine-jobs.py | 28 ++++++++++++++++++++-------- tests/script/test_determine_jobs.py | 4 ++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 9c8f3cd3b4f..9d146c94a0b 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -31,7 +31,7 @@ The CI workflow uses this information to: - Skip or run integration tests - Skip or run clang-tidy (and whether to do a full scan) - Skip or run clang-format -- Skip or run Python linters (ruff, flake8, pylint, pyupgrade) +- Skip or run pylint - Skip or run downstream esphome/device-builder tests against the PR's Python code - Determine which components to test individually - Decide how to split component tests (if there are many) @@ -403,21 +403,33 @@ def should_run_clang_format(branch: str | None = None) -> bool: return _any_changed_file_endswith(branch, CPP_FILE_EXTENSIONS) +# Linter pins, dependencies and linter config can change findings without a .py change. +PYTHON_LINTERS_TRIGGER_FILES = frozenset( + { + "requirements.txt", + "requirements_test.txt", + "pyproject.toml", + } +) + + def should_run_python_linters(branch: str | None = None) -> bool: - """Determine if Python linters (ruff, flake8, pylint, pyupgrade) should run based on changed files. + """Determine if the pylint job should run based on changed files. - This function is used by the CI workflow to skip Python linting checks when no Python files - have changed, saving CI time and resources. - - Python linters will run when any Python source files have changed. + The CI workflow skips pylint unless a Python file or a file in + PYTHON_LINTERS_TRIGGER_FILES has changed. ruff, flake8 and pyupgrade run in + lint-format, which this flag does not gate. Args: branch: Branch to compare against. If None, uses default. Returns: - True if Python linters should run, False otherwise. + True if pylint should run, False otherwise. """ - return _any_changed_file_endswith(branch, PYTHON_FILE_EXTENSIONS) + return any( + file.endswith(PYTHON_FILE_EXTENSIONS) or file in PYTHON_LINTERS_TRIGGER_FILES + for file in changed_files(branch) + ) # Files outside esphome/**/*.py whose changes can affect `import esphome.__main__` diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index da688703fc4..7e9b09b2b93 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -907,6 +907,10 @@ def test_should_run_clang_tidy_with_branch() -> None: (["esphome/core.py"], True), (["script/test.py"], True), (["esphome/test.pyi"], True), # .pyi files should trigger + (["requirements_test.txt"], True), + (["requirements.txt"], True), + (["pyproject.toml"], True), + (["requirements_dev.txt"], False), (["README.md"], False), ([], False), ], From 6002c28a8d0c92f22bd548c357e1c3510ad0ecaa Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:38:16 +0200 Subject: [PATCH 059/128] [host] Ignore SIGPIPE so a write to a closed peer fails with EPIPE (#20228) --- esphome/components/host/core.cpp | 2 + .../fixtures/host_write_after_peer_close.yaml | 40 +++++++++++++ .../test_host_write_after_peer_close.py | 57 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 tests/integration/fixtures/host_write_after_peer_close.yaml create mode 100644 tests/integration/test_host_write_after_peer_close.py diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 9292cd77f61..f753534efd9 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -90,6 +90,8 @@ int main(int argc, char **argv) { // Install signal handlers for graceful shutdown (flushes preferences to disk) std::signal(SIGINT, signal_handler); std::signal(SIGTERM, signal_handler); + // A write to a closed peer must fail with EPIPE, as on the embedded stacks, not kill the process + std::signal(SIGPIPE, SIG_IGN); esphome::host::setup_preferences(); setup(); diff --git a/tests/integration/fixtures/host_write_after_peer_close.yaml b/tests/integration/fixtures/host_write_after_peer_close.yaml new file mode 100644 index 00000000000..4fa89907e31 --- /dev/null +++ b/tests/integration/fixtures/host_write_after_peer_close.yaml @@ -0,0 +1,40 @@ +esphome: + name: host-write-after-close + +host: + +api: + actions: + - action: write_after_close + variables: + port: int + then: + - lambda: |- + // A second connection to the test server carries the go signal. + auto sync = socket::socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_storage addr; + socklen_t len = socket::set_sockaddr(reinterpret_cast(&addr), sizeof(addr), "127.0.0.1", port); + struct timeval timeout = {10, 0}; + sync->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + uint8_t go; + // The loop stays blocked, so the link cannot read the close before the writes. + if (sync->connect(reinterpret_cast(&addr), len) != 0 || sync->read(&go, 1) != 1) { + ESP_LOGE("test", "No go signal"); + return; + } + // The first write draws a reset from the closed peer, a later one fails with EPIPE. + uint32_t start = millis(); + while (id(tcp_uart_1).is_connected() && millis() - start < 5000) { + id(tcp_uart_1).write_byte('b'); + id(tcp_uart_1).flush(); + delay(1); + } + +logger: + level: INFO + +tcp_uart: + - id: tcp_uart_1 + host: 127.0.0.1 + port: 18126 + reconnect_interval: 60s diff --git a/tests/integration/test_host_write_after_peer_close.py b/tests/integration/test_host_write_after_peer_close.py new file mode 100644 index 00000000000..3827459b486 --- /dev/null +++ b/tests/integration/test_host_write_after_peer_close.py @@ -0,0 +1,57 @@ +"""A host build must survive a write to a peer that has closed the connection. + +The device writes until the link drops; once the closed peer has answered with +a reset, a write must fail with EPIPE instead of SIGPIPE killing the process. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_write_after_peer_close( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, + unused_tcp_port_factory: Callable[[], int], +) -> None: + port = unused_tcp_port_factory() + yaml_config = yaml_config.replace("port: 18126", f"port: {port}") + + lines = LineWaiter() + peers: asyncio.Queue[asyncio.StreamWriter] = asyncio.Queue() + + async def handle(_: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + peers.put_nowait(writer) + + server = await asyncio.start_server(handle, "127.0.0.1", port) + try: + async with ( + run_compiled(yaml_config, line_callback=lines.callback), + api_client_connected() as client, + ): + peer = await asyncio.wait_for(peers.get(), 15.0) + _, services = await client.list_entities_services() + action = next(s for s in services if s.name == "write_after_close") + + await client.execute_service(action, {"port": port}) + # The action opens a second connection and blocks the loop until it gets a byte. + sync = await asyncio.wait_for(peers.get(), 15.0) + peer.close() + await peer.wait_closed() + sync.write(b"g") + sync.close() + + # EPIPE (32 on Linux and macOS) must come back as an errno. + await lines.wait_for("Connection lost: 32") + assert await client.device_info() is not None + finally: + server.close() + await server.wait_closed() From b7ddd1daa2f88c6bed7a4085788e09014852a47d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Oct 2026 08:03:20 -0500 Subject: [PATCH 060/128] [climate] Cover custom mode reads and runtime sets in the component test (#20208) --- tests/components/template/common-base.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index ccef9ded052..f87a4e0320e 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -275,6 +275,28 @@ button: name: "Template Button" on_press: - logger.log: Button Pressed + - platform: template + name: "Climate Custom Modes Lambdas" + on_press: + - lambda: |- + // How external components and configs read and set climate custom modes + auto traits = id(template_climate_custom_modes).get_traits(); + const auto &fan_modes = traits.get_supported_custom_fan_modes(); + for (const auto *mode : fan_modes) + ESP_LOGD("test", "%s", mode); + for (const auto &preset : traits.get_supported_custom_presets()) + ESP_LOGD("test", "%s", preset); + for (const char *mode : traits.get_supported_custom_fan_modes()) + ESP_LOGD("test", "%s", mode); + std::vector presets{"eco_plus", "max"}; + id(template_climate_custom_modes).set_supported_custom_presets(presets); + id(template_climate_custom_modes).set_supported_custom_presets({"eco_plus"}); + id(template_climate_custom_modes).set_supported_custom_fan_modes({}); + std::vector new_fan_modes{"turbo", "silent"}; + id(template_climate_custom_modes).set_supported_custom_fan_modes(new_fan_modes); + static const char *const MODES[] = {"turbo", "silent"}; + id(template_climate_custom_modes).set_supported_custom_fan_modes(MODES); + id(template_climate_custom_modes).set_supported_custom_presets(MODES); cover: - platform: template From 9b1a75b0ed1a75c97c84f605c842a362ac417238 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Oct 2026 08:03:58 -0500 Subject: [PATCH 061/128] [fan] Cover preset mode reads and runtime sets in the component test (#20209) --- tests/components/fan/common.yaml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 12c7b6c8726..4c6a6b8a633 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -66,9 +66,26 @@ binary_sensor: } return false; -# Exercise fan.turn_on with various field combinations so the -# register_apply_action codegen paths get build coverage. button: + # Read and set preset modes the way external fan components do + - platform: template + name: "Fan Preset Modes Lambdas" + on_press: + - lambda: |- + // How external components and configs read and set fan preset modes + auto traits = id(test_fan).get_traits(); + const auto &modes = traits.supported_preset_modes(); + for (const auto &mode : modes) + ESP_LOGD("test", "%s", mode); + for (const char *mode : traits.supported_preset_modes()) + ESP_LOGD("test", "%s", mode); + std::vector runtime_modes{"Eco", "Turbo"}; + id(test_fan).set_supported_preset_modes(runtime_modes); + id(test_fan).set_supported_preset_modes({"Eco"}); + id(test_fan).set_supported_preset_modes({}); + + # Exercise fan.turn_on with various field combinations so the + # register_apply_action codegen paths get build coverage. - platform: template name: "Fan Speed Only" on_press: From 29090d50a7cb988a712ad7fda9c032cf4c0be849 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Oct 2026 08:04:37 -0500 Subject: [PATCH 062/128] [event] Cover how configs and components read and set event types in the component test (#20216) --- tests/components/event/common.yaml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/components/event/common.yaml b/tests/components/event/common.yaml index 827f1ebb529..14ec46c4dfc 100644 --- a/tests/components/event/common.yaml +++ b/tests/components/event/common.yaml @@ -19,6 +19,12 @@ event: ESP_LOGD("test", "Event type: %.*s", (int) event_type.size(), event_type.c_str()); } + - platform: template + name: Other Event + id: other_event + event_types: + - template_event_type1 + button: - platform: template name: Trigger Event @@ -30,3 +36,21 @@ button: id: some_event event_type: !lambda |- return id(some_event).has_event() ? "template_event_type2" : "template_event_type1"; + + - platform: template + name: Event Types Lambdas + on_press: + - lambda: |- + // How external components and configs read and set event types + const auto &types = id(some_event).get_event_types(); + for (const char *type : types) + ESP_LOGD("test", "%s", type); + if (types.empty()) { + ESP_LOGD("test", "no types"); + } else if (types.size() > 1) { + ESP_LOGD("test", "%s %s", types[0], types.at(1)); + const auto *it = std::find(types.begin(), types.end(), types[1]); + ESP_LOGD("test", "Index %d", (int) (it - types.begin())); + } + id(other_event).set_event_types(id(some_event).get_event_types()); + id(other_event).set_event_types({"other_a", "other_b"}); From a69c648c6fa8ed071e9d6e902e7023799d8ee329 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Oct 2026 08:04:52 -0500 Subject: [PATCH 063/128] [neopixelbus] Cover addressable_color_wipe with a random color on ESP8266 (#20221) --- tests/components/neopixelbus/test.esp8266-ard.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/components/neopixelbus/test.esp8266-ard.yaml b/tests/components/neopixelbus/test.esp8266-ard.yaml index 2c1f16a38c3..2ae04770961 100644 --- a/tests/components/neopixelbus/test.esp8266-ard.yaml +++ b/tests/components/neopixelbus/test.esp8266-ard.yaml @@ -10,6 +10,16 @@ light: name: Flicker Effect With Custom Values update_interval: 16ms intensity: 5% + - addressable_color_wipe: + name: Color Wipe With Random + colors: + - red: 100% + green: 0% + blue: 0% + num_leds: 2 + gradient: true + - random: true + num_leds: 1 type: GRBW variant: SK6812 method: esp8266_uart From b63794b53c8310b42e0d2a77a7cbfd482be81727 Mon Sep 17 00:00:00 2001 From: Jeroen Date: Tue, 6 Oct 2026 17:00:50 +0200 Subject: [PATCH 064/128] [web_server_idf] Respect explicit HTTP header limit (#18657) Co-authored-by: jeroen85 <26403565+jeroen85@users.noreply.github.com> Co-authored-by: Jeroen Jansen --- esphome/components/esp32/__init__.py | 7 ++-- esphome/components/web_server_idf/__init__.py | 4 +-- .../web_server/test_httpd_sdkconfig.py | 32 +++++++++++++++++++ .../web_server_header_limit_override.yaml | 16 ++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/web_server/test_httpd_sdkconfig.py create mode 100644 tests/component_tests/web_server/web_server_header_limit_override.yaml diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index be6b2c32663..ce1a66a4d22 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -742,9 +742,10 @@ def is_idf_sdkconfig_option_enabled(name: str) -> bool: def set_idf_sdkconfig_default(name: str, value: SdkconfigValueType) -> None: """Set an sdkconfig option unless it is already set. - For the FINAL priority reconcile jobs: they run after every to_code, - including the user's sdkconfig_options, and must not override an - existing value. + User sdkconfig_options take precedence regardless of to_code order: + esp32.to_code applies them unconditionally, and this helper preserves + values that are already set. FINAL priority reconcile jobs use the same + guard because they run after every to_code, including the user's options. """ if name not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]: add_idf_sdkconfig_option(name, value) diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 5a400dfbf3e..5eac63212f2 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,6 +1,6 @@ from esphome.components.esp32 import ( - add_idf_sdkconfig_option, include_builtin_idf_component, + set_idf_sdkconfig_default, ) from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv @@ -16,7 +16,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server - add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) + set_idf_sdkconfig_default("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) # Re-enable esp-tls (excluded by default to save compile time); # web_server_idf.cpp includes for digest auth include_builtin_idf_component("esp-tls") diff --git a/tests/component_tests/web_server/test_httpd_sdkconfig.py b/tests/component_tests/web_server/test_httpd_sdkconfig.py new file mode 100644 index 00000000000..d3b0acb384a --- /dev/null +++ b/tests/component_tests/web_server/test_httpd_sdkconfig.py @@ -0,0 +1,32 @@ +"""Tests for ESP-IDF web server sdkconfig defaults.""" + +from collections.abc import Callable + +from esphome.components.esp32 import RawSdkconfigValue +from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS +from esphome.core import CORE + + +def test_web_server_idf_uses_default_header_limit( + generate_main: Callable[[str], str], +) -> None: + """The web server retains its existing 1024-byte default.""" + generate_main("tests/component_tests/web_server/web_server_no_auth.yaml") + + assert ( + CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]["CONFIG_HTTPD_MAX_REQ_HDR_LEN"] + == 1024 + ) + + +def test_web_server_idf_respects_explicit_header_limit( + generate_main: Callable[[str], str], +) -> None: + """An explicit sdkconfig option takes precedence over the component default.""" + generate_main( + "tests/component_tests/web_server/web_server_header_limit_override.yaml" + ) + + value = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]["CONFIG_HTTPD_MAX_REQ_HDR_LEN"] + assert isinstance(value, RawSdkconfigValue) + assert value.value == "4096" diff --git a/tests/component_tests/web_server/web_server_header_limit_override.yaml b/tests/component_tests/web_server/web_server_header_limit_override.yaml new file mode 100644 index 00000000000..eb1c8a87ddc --- /dev/null +++ b/tests/component_tests/web_server/web_server_header_limit_override.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + sdkconfig_options: + CONFIG_HTTPD_MAX_REQ_HDR_LEN: "4096" + +wifi: + ssid: MySSID + password: password1 + +web_server: From 1f53ccb5725601a4088f02ae1ee0f5410fc163b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Oct 2026 10:04:45 -0500 Subject: [PATCH 065/128] [api] Cover Home Assistant action fields in the api test config (#20236) --- tests/components/api/common-base.yaml | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index f55e7b176dd..1513d537c21 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -149,6 +149,38 @@ api: args: - error.c_str() - name.c_str() + # Constant and lambda fields mixed in one action, with lambdas of several + # return types reading the trigger args, and a templated action name + - action: test_homeassistant_fields + variables: + room: string + level: int + then: + - homeassistant.action: + action: !lambda 'return std::string("light.") + (level > 0 ? "turn_on" : "turn_off");' + data: + entity_id: light.living_room + brightness: !lambda 'return level;' + data_template: + message: "{{ room }} is at {{ level }}" + variables: + room: !lambda 'return room.c_str();' + level: !lambda 'static char buf[12]; snprintf(buf, sizeof(buf), "%d", level); return buf;' + - homeassistant.action: + action: notify.html5 + data: + message: Button was pressed + # Same constant fields as above, so codegen can share them + - homeassistant.action: + action: notify.html5 + data: + message: Button was pressed + - homeassistant.event: + event: esphome.room_level + data: + room: !lambda 'return room;' + ratio: !lambda 'return level / 100.0f;' + - homeassistant.tag_scanned: !lambda 'return room;' # Test ContinuationAction (IfAction with then/else branches) - action: test_if_action variables: From e66ef45df7fd390cd7663f85d426edec96084b2b Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:08:01 +0200 Subject: [PATCH 066/128] [zigbee] add missing tests (#20234) --- tests/components/zigbee/common.yaml | 4 ++++ tests/components/zigbee/test.nrf52-mcumgr.yaml | 2 +- tests/components/zigbee/test.nrf52-xiao-ble.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index 4518e390602..ce59f4a6ae1 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -14,9 +14,12 @@ sensor: name: "Analog 1" lambda: return 10.0; accuracy_decimals: 0 + device_class: temperature + unit_of_measurement: "°C" - platform: template name: "Analog 2" lambda: return 11.0; + unit_of_measurement: "°C" - platform: template name: "Analog 3" lambda: return 12.0; @@ -41,6 +44,7 @@ number: min_value: 2 max_value: 100 step: 1 + unit_of_measurement: "°C" time: - platform: zigbee diff --git a/tests/components/zigbee/test.nrf52-mcumgr.yaml b/tests/components/zigbee/test.nrf52-mcumgr.yaml index a81feea0691..cdead0db905 100644 --- a/tests/components/zigbee/test.nrf52-mcumgr.yaml +++ b/tests/components/zigbee/test.nrf52-mcumgr.yaml @@ -1,4 +1,4 @@ -<<: !include common_nrf52.yaml +<<: !include common.yaml zigbee: router: true diff --git a/tests/components/zigbee/test.nrf52-xiao-ble.yaml b/tests/components/zigbee/test.nrf52-xiao-ble.yaml index acfbc9e9961..64134e536d1 100644 --- a/tests/components/zigbee/test.nrf52-xiao-ble.yaml +++ b/tests/components/zigbee/test.nrf52-xiao-ble.yaml @@ -1,4 +1,4 @@ -<<: !include common_nrf52.yaml +<<: !include common.yaml zigbee: wipe_on_boot: once From fef3804aa5400a7f4ac003002141df680e0d664c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Oct 2026 10:09:47 -0500 Subject: [PATCH 067/128] [espidf] Fix V1 ECDSA signed builds when skipping the bootloader (#20244) --- esphome/build_gen/espidf.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 42a1de5a4b9..e7a2dc1536b 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -160,7 +160,7 @@ def _cmake_quote(value: str) -> str: # CONFIG_APP_BUILD_BOOTLOADER is hidden and force-selected, so it can only be # cleared at the CMake level (the same state IDF's RAM-app build type uses). -# The macro is IDF's __build_process_project_includes plus two added lines; +# The macro is IDF's __build_process_project_includes plus a few added lines; # the flag is ignored and the bootloader builds as usual if IDF changes it. IDF_BOOTLOADER_OVERRIDE = """\ # ESPHome bootloader skip switch; see esphome/espidf/toolchain.py. @@ -173,6 +173,9 @@ if(ESPHOME_SKIP_BOOTLOADER) # skipped) bootloader project_include leaks; keep it defined, or # its empty TARGET_SRC_NAME sends file(GLOB_RECURSE) across /. idf_build_get_property(idf_target IDF_TARGET) + # partition_table's V1 ECDSA signing reads this key, which the + # skipped bootloader project_include also sets. + get_filename_component(SECURE_BOOT_SIGNING_KEY "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}") idf_build_get_property(build_properties __BUILD_PROPERTIES) foreach(build_property ${build_properties}) idf_build_get_property(val ${build_property}) @@ -198,6 +201,10 @@ endif() BOOTLOADER_OVERRIDE_ADDED_LINES = ( 'set(CONFIG_APP_BUILD_BOOTLOADER "")', "idf_build_get_property(idf_target IDF_TARGET)", + ( + "get_filename_component(SECURE_BOOT_SIGNING_KEY" + ' "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")' + ), ) _MACRO = re.compile( From 74d0a90308357f9f58a199888f2a0926ffd24341 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:11:25 +1100 Subject: [PATCH 068/128] [esphome] Revise warning text for password use (#20230) --- esphome/components/esphome/ota/__init__.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index ffd7c509497..0cb2657ea1f 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -133,20 +133,12 @@ def ota_esphome_final_validate(config: ConfigType) -> None: _resolve_encryption_key(encryption_conf, api_conf) elif CONF_PASSWORD in ota_conf and static_encryption_key(api_conf) is not None: _LOGGER.warning( - "'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 " - "bytes plus the password on the heap): the device already offers " - "encryption with the '%s' %s %s, which authenticates any uploader " - "that takes it, and a password only matters for uploaders without " - "encryption support; remove '%s' and add '%s' under '%s' so " - "uploads use the key and encryption is required", + "'%s' %s wastes significant flash and RAM; " + "using '%s' instead is recommended - " + "see https://esphome.io/components/ota/esphome/#configuration-variables", CONF_OTA, CONF_PASSWORD, - CONF_API, CONF_ENCRYPTION, - CONF_KEY, - CONF_PASSWORD, - CONF_ENCRYPTION, - CONF_OTA, ) elif ( CONF_PASSWORD in ota_conf From fb20966353e1b004a11cd837fc622220509990d0 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:16:57 +0100 Subject: [PATCH 069/128] [wireguard] Restart the connection while the peer is offline so the endpoint is re-resolved (#20231) --- esphome/components/wireguard/wireguard.cpp | 5 +++++ esphome/components/wireguard/wireguard.h | 2 ++ 2 files changed, 7 insertions(+) diff --git a/esphome/components/wireguard/wireguard.cpp b/esphome/components/wireguard/wireguard.cpp index fc06569fbab..1fd610f4e88 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -101,8 +101,13 @@ void Wireguard::update() { if (this->wg_peer_offline_time_ == 0) { ESP_LOGW(TAG, LOGMSG_PEER_STATUS, LOGMSG_OFFLINE, latest_handshake.c_str()); this->wg_peer_offline_time_ = millis(); + this->wg_reconnect_time_ = this->wg_peer_offline_time_; } else if (this->enabled_) { ESP_LOGD(TAG, LOGMSG_PEER_STATUS, LOGMSG_OFFLINE, latest_handshake.c_str()); + if (millis() - this->wg_reconnect_time_ >= RECONNECT_INTERVAL_MS) { + this->wg_reconnect_time_ = millis(); + this->stop_connection_(); + } this->start_connection_(); } diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index 68885f3a351..faf63cd444c 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -140,6 +140,7 @@ class Wireguard final : public PollingComponent { /// The last time the remote peer become offline. uint32_t wg_peer_offline_time_ = 0; + uint32_t wg_reconnect_time_ = 0; /** \brief The latest saved handshake. * @@ -159,6 +160,7 @@ void resume_wdt(); /// Size of buffer required for mask_key_to: 5 chars + "[...]=" + null = 12 static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; +static constexpr uint32_t RECONNECT_INTERVAL_MS = 30000; /// Strip most part of the key only for secure printing void mask_key_to(char *buffer, size_t len, const char *key); From f670993fe92313b523afddabfadf0ba702259100 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:40:53 +0100 Subject: [PATCH 070/128] [ethernet] Pin the IDF version in the KSZ8851SNL test fixtures (#20245) --- tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml | 2 ++ tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml b/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml index 7c8f3336844..eb83960f67d 100644 --- a/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml +++ b/tests/component_tests/ethernet/config/ksz8851snl_idf5.yaml @@ -3,6 +3,8 @@ esphome: esp32: board: esp32dev + framework: + version: 5.5.5 ethernet: id: eth_component diff --git a/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml b/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml index bc11c6db5b8..3ca9c801b77 100644 --- a/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml +++ b/tests/component_tests/ethernet/config/ksz8851snl_idf6.yaml @@ -4,7 +4,7 @@ esphome: esp32: board: esp32dev framework: - version: 6.0.0 + version: 6.1.0 ethernet: id: eth_component From 980fed9e964676f563a61256f92ad8ca8c897712 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 6 Oct 2026 13:23:57 -0400 Subject: [PATCH 071/128] [sendspin] Require a type on the switch platform (#20189) --- .../components/sendspin/switch/__init__.py | 24 ++++++++++++------- tests/components/sendspin/common-switch.yaml | 1 + 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py index 63f5f7ad28b..1f896640bfc 100644 --- a/esphome/components/sendspin/switch/__init__.py +++ b/esphome/components/sendspin/switch/__init__.py @@ -1,7 +1,8 @@ import esphome.codegen as cg from esphome.components import switch +from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns @@ -12,14 +13,19 @@ DEPENDENCIES = ["sendspin"] SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) CONFIG_SCHEMA = cv.All( - switch.switch_schema( - SendspinSwitch, - block_inverted=True, - default_restore_mode="RESTORE_DEFAULT_ON", - entity_category=ENTITY_CATEGORY_CONFIG, - ) - .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) - .extend(cv.COMPONENT_SCHEMA), + cv.typed_schema( + { + CONF_ENABLED: switch.switch_schema( + SendspinSwitch, + block_inverted=True, + default_restore_mode="RESTORE_DEFAULT_ON", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + .extend(cv.COMPONENT_SCHEMA), + }, + key=CONF_TYPE, + ), cv.only_on_esp32, ) diff --git a/tests/components/sendspin/common-switch.yaml b/tests/components/sendspin/common-switch.yaml index d332cb0dde0..01e2f0b02b1 100644 --- a/tests/components/sendspin/common-switch.yaml +++ b/tests/components/sendspin/common-switch.yaml @@ -4,3 +4,4 @@ packages: switch: - platform: sendspin name: "Sendspin Enabled" + type: enabled From cad670785b6db78db1ce7a9aea87c9b2381fd5b4 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:28:06 +0200 Subject: [PATCH 072/128] [socket] Add host tests for TcpClientLink and TcpListener (#20167) Co-authored-by: J. Nick Koston --- esphome/components/socket/tcp_client_link.cpp | 20 +-- tests/components/socket/__init__.py | 3 +- .../socket/test_ipv4_allow_host.cpp | 35 +++++ .../socket/test_tcp_client_link_host.cpp | 134 ++++++++++++++++++ .../socket/test_tcp_listener_host.cpp | 132 +++++++++++++++++ 5 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 tests/components/socket/test_tcp_listener_host.cpp diff --git a/esphome/components/socket/tcp_client_link.cpp b/esphome/components/socket/tcp_client_link.cpp index 141695a4bb6..9812f1835e1 100644 --- a/esphome/components/socket/tcp_client_link.cpp +++ b/esphome/components/socket/tcp_client_link.cpp @@ -17,19 +17,23 @@ static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; // Non-blocking options and TCP keepalive for a bridged stream socket. // Keepalive is best-effort: the raw lwIP implementation (ESP8266, RP2040) // rejects it, so a half-open link there is only detected by a failed write. -static void set_stream_options(Socket *sock) { +static void set_stream_options(Socket *sock, const char *tag) { int yes = 1; + // Fails only on an invalid descriptor, or on raw lwIP after a peer reset that the next read() reports. sock->setblocking(false); - sock->setsockopt(IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes)); - sock->setsockopt(SOL_SOCKET, SO_KEEPALIVE, &yes, sizeof(yes)); + int err = sock->setsockopt(IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes)); + err |= sock->setsockopt(SOL_SOCKET, SO_KEEPALIVE, &yes, sizeof(yes)); #ifdef TCP_KEEPIDLE int idle = 30; int interval = 10; int count = 3; - sock->setsockopt(IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); - sock->setsockopt(IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval)); - sock->setsockopt(IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval)); + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); #endif + if (err != 0) { + ESP_LOGV(tag, "Nodelay/keepalive not fully applied"); + } } void TcpClientLink::begin(const char *tag) { @@ -84,7 +88,7 @@ void TcpClientLink::try_connect_() { this->drop_(LOG_STR("Connect failed"), errno); return; } - set_stream_options(this->sock_.get()); + set_stream_options(this->sock_.get(), this->tag_); // Starts the pending-connect clock that poll() times out against. this->note_attempt(); // An immediate success is reported by the next poll(); poll_connect() sees it writable. @@ -95,7 +99,7 @@ void TcpClientLink::try_connect_() { void TcpClientLink::adopt(std::unique_ptr sock) { this->close(); - set_stream_options(sock.get()); + set_stream_options(sock.get(), this->tag_); this->sock_ = std::move(sock); this->connected_ = true; } diff --git a/tests/components/socket/__init__.py b/tests/components/socket/__init__.py index 6c1d2b1c080..3cd8cd839d7 100644 --- a/tests/components/socket/__init__.py +++ b/tests/components/socket/__init__.py @@ -14,6 +14,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: await real_to_code(config) cg.add_define("USE_NETWORK_IPV6", True) # The gtests link against the filtered helper files. - socket_component.require_tcp_client_link() + socket_component.require_tcp_listener() + cg.add_define("USE_SOCKET_IPV4_ALLOW") manifest.to_code = to_code_testing diff --git a/tests/components/socket/test_ipv4_allow_host.cpp b/tests/components/socket/test_ipv4_allow_host.cpp index e4257b41c4d..a8924de3713 100644 --- a/tests/components/socket/test_ipv4_allow_host.cpp +++ b/tests/components/socket/test_ipv4_allow_host.cpp @@ -70,6 +70,41 @@ TEST(Ipv4Allow, InstancesKeepIndependentLists) { EXPECT_FALSE(first.allows(htonl(0x0A00002A))); } +TEST(Ipv4Allow, HostEntryMatchesOnlyThatAddress) { + static const Ipv4AllowEntry HOST[] = {{htonl(0xC0A8AF14), htonl(0xFFFFFFFF)}}; + Ipv4Allow list; + list.set(HOST, std::size(HOST)); + EXPECT_TRUE(allows_peer(list, "192.168.175.20")); + EXPECT_FALSE(allows_peer(list, "192.168.175.21")); + EXPECT_FALSE(allows_peer(list, "192.168.175.19")); +} + +TEST(Ipv4Allow, CatchAllAllowsEveryV4PeerOnly) { + static const Ipv4AllowEntry ANY[] = {{0, 0}}; + Ipv4Allow list; + list.set(ANY, std::size(ANY)); + EXPECT_TRUE(allows_peer(list, "0.0.0.0")); + EXPECT_TRUE(allows_peer(list, "255.255.255.255")); + EXPECT_TRUE(allows_peer(list, "::ffff:10.1.2.3")); + // Unlike an empty list, 0.0.0.0/0 still turns a native IPv6 peer away. + EXPECT_FALSE(allows_peer(list, "fe80::1")); +} + +TEST(Ipv4Allow, LastEntryOfAFullListMatches) { + // 255 is the schema's cap: 10.0.0.1/32 to 10.0.0.255/32. + static Ipv4AllowEntry full[255]; + for (uint32_t i = 0; i < std::size(full); i++) { + full[i] = {htonl(0x0A000001 + i), htonl(0xFFFFFFFF)}; + } + Ipv4Allow list; + list.set(full, std::size(full)); + EXPECT_EQ(list.size(), 255u); + EXPECT_TRUE(allows_peer(list, "10.0.0.1")); + EXPECT_TRUE(allows_peer(list, "10.0.0.255")); + EXPECT_FALSE(allows_peer(list, "10.0.1.0")); + EXPECT_FALSE(allows_peer(list, "10.0.0.0")); +} + } // namespace esphome::socket::testing #endif diff --git a/tests/components/socket/test_tcp_client_link_host.cpp b/tests/components/socket/test_tcp_client_link_host.cpp index 9a6d388b725..98be8190b00 100644 --- a/tests/components/socket/test_tcp_client_link_host.cpp +++ b/tests/components/socket/test_tcp_client_link_host.cpp @@ -1,15 +1,22 @@ #include +#include #include #include +#include #include +#include #include #include #include "esphome/components/socket/tcp_client_link.h" +#include "esphome/core/application.h" #ifdef USE_HOST +// Host only: ESP-IDF has no poll.h. +#include + namespace esphome::socket::testing { class LinkPeer { @@ -40,6 +47,133 @@ class LinkPeer { int peer_fd_{-1}; }; +// Sets the cached loop time the link's clock reads, as the main loop does. +static void set_loop_time(uint32_t now) { LoopBlockingGuard guard(nullptr, LOG_STR("test"), now); } + +class LinkUnderTest : public TcpClientLink { + public: + void set_socket(std::unique_ptr sock) { this->sock_ = std::move(sock); } + bool has_socket() const { return this->sock_ != nullptr; } + int fd() const { return this->sock_->get_fd(); } +}; + +class TcpClientLinkClock : public ::testing::Test { + protected: + void SetUp() override { + signal(SIGPIPE, SIG_IGN); + set_loop_time(1000); + } + void TearDown() override { + this->link_.close(); + if (this->peer_fd_ >= 0) { + ::close(this->peer_fd_); + } + set_loop_time(0); + } + // A connected socket whose send buffer is full selects as not writable, + // which poll_connect() reports as a connect still in progress. + void set_pending_socket() { + int fds[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + this->peer_fd_ = fds[1]; + ASSERT_EQ(fcntl(fds[0], F_SETFL, fcntl(fds[0], F_GETFL, 0) | O_NONBLOCK), 0); + char fill[1024]{}; + while (::write(fds[0], fill, sizeof(fill)) > 0) { + } + ASSERT_EQ(errno, EAGAIN); + this->link_.set_socket(std::make_unique(fds[0])); + } + + LinkUnderTest link_; + int peer_fd_{-1}; +}; + +TEST_F(TcpClientLinkClock, PendingConnectTimesOutIntoBackoff) { + this->link_.begin("link_test"); + this->set_pending_socket(); + this->link_.note_attempt(); + set_loop_time(1000 + 9999); + this->link_.poll(); + ASSERT_TRUE(this->link_.has_socket()); + EXPECT_FALSE(this->link_.connected()); + set_loop_time(1000 + 10000); + this->link_.poll(); + EXPECT_FALSE(this->link_.has_socket()); + EXPECT_TRUE(this->link_.in_backoff()); +} + +TEST_F(TcpClientLinkClock, ConnectTimeoutFollowsALongerInterval) { + this->link_.set_reconnect_interval(20000); + this->link_.begin("link_test"); + this->set_pending_socket(); + this->link_.note_attempt(); + set_loop_time(1000 + 19999); + this->link_.poll(); + ASSERT_TRUE(this->link_.has_socket()); + set_loop_time(1000 + 20000); + this->link_.poll(); + EXPECT_FALSE(this->link_.has_socket()); +} + +TEST_F(TcpClientLinkClock, ResolveFailureBacksOff) { + // An IPv6 literal fails the IPv4 lookup without DNS. + this->link_.set_host("::1"); + this->link_.set_port(1); + this->link_.begin("link_test"); + this->link_.poll(); + ASSERT_FALSE(this->link_.in_backoff()); + // The failure is consumed by the next attempt and restarts the clock. + this->link_.poll(); + EXPECT_FALSE(this->link_.has_socket()); + EXPECT_TRUE(this->link_.in_backoff()); + set_loop_time(1000 + 4999); + EXPECT_TRUE(this->link_.in_backoff()); + set_loop_time(1000 + 5000); + EXPECT_FALSE(this->link_.in_backoff()); +} + +TEST_F(TcpClientLinkClock, RefusedConnectBacksOff) { + // A loopback port that was just free refuses the connect. + int probe = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(probe, 0); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(addr); + ASSERT_EQ(::bind(probe, reinterpret_cast(&addr), len), 0); + ASSERT_EQ(::getsockname(probe, reinterpret_cast(&addr), &len), 0); + ::close(probe); + + this->link_.set_host("127.0.0.1"); + this->link_.set_port(ntohs(addr.sin_port)); + this->link_.begin("link_test"); + // The refusal comes back from connect() itself or from a later poll. + this->link_.poll(); + if (this->link_.has_socket()) { + // Wait for the stack to finish the connect, then let poll() read the result. + struct pollfd pfd { + this->link_.fd(), POLLOUT, 0 + }; + ASSERT_EQ(::poll(&pfd, 1, 1000), 1); + this->link_.poll(); + } + EXPECT_FALSE(this->link_.connected()); + EXPECT_FALSE(this->link_.has_socket()); + EXPECT_TRUE(this->link_.in_backoff()); +} + +TEST_F(TcpClientLinkClock, BackoffSpansAMillisWrap) { + set_loop_time(UINT32_MAX - 999); + this->link_.begin("link_test"); + // begin() back-dates the clock so the first attempt is immediate. + EXPECT_FALSE(this->link_.in_backoff()); + this->link_.note_attempt(); + set_loop_time(3999); + EXPECT_TRUE(this->link_.in_backoff()); + set_loop_time(4000); + EXPECT_FALSE(this->link_.in_backoff()); +} + TEST(TcpClientLink, AdoptedSocketFlushesQueuedBytes) { LinkPeer p; ASSERT_TRUE(p.link_.connected()); diff --git a/tests/components/socket/test_tcp_listener_host.cpp b/tests/components/socket/test_tcp_listener_host.cpp new file mode 100644 index 00000000000..7c6e8392da9 --- /dev/null +++ b/tests/components/socket/test_tcp_listener_host.cpp @@ -0,0 +1,132 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/components/socket/tcp_listener.h" + +#ifdef USE_HOST + +// Host only: ESP-IDF has no poll.h. +#include + +namespace esphome::socket::testing { + +class ListenerUnderTest : public TcpListener { + public: + void set_listen(std::unique_ptr sock) { this->listen_ = std::move(sock); } + bool listening() const { return this->listen_ != nullptr; } + void accept(TcpClientLink &link) { this->accept_(link); } +}; + +class TcpListenerAccept : public ::testing::Test { + protected: + void SetUp() override { + this->link_.set_port(1); + this->link_.begin("listener_test"); + this->listener_.begin("listener_test"); + } + void TearDown() override { + this->listener_.close(); + this->link_.close(); + if (this->client_fd_ >= 0) { + ::close(this->client_fd_); + } + } + // A non-blocking IPv4 listener on a free loopback port; returns the port. + uint16_t listen_on_loopback() { + int fd = ::socket(AF_INET, SOCK_STREAM, 0); + EXPECT_GE(fd, 0); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(addr); + EXPECT_EQ(::bind(fd, reinterpret_cast(&addr), len), 0); + EXPECT_EQ(::listen(fd, 1), 0); + EXPECT_EQ(::getsockname(fd, reinterpret_cast(&addr), &len), 0); + EXPECT_EQ(fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK), 0); + this->listen_fd_ = fd; + this->listener_.set_listen(std::make_unique(fd)); + return ntohs(addr.sin_port); + } + // A blocking connect to the loopback listener; it completes into the backlog. + void connect_client(uint16_t port) { + this->client_fd_ = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(this->client_fd_, 0); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons(port); + ASSERT_EQ(::connect(this->client_fd_, reinterpret_cast(&addr), sizeof(addr)), 0); + // The stack may queue the connection for accept() a moment after connect() returns. + struct pollfd pfd { + this->listen_fd_, POLLIN, 0 + }; + ASSERT_EQ(::poll(&pfd, 1, 1000), 1); + } + + TcpClientLink link_; + ListenerUnderTest listener_; + int listen_fd_{-1}; + int client_fd_{-1}; +}; + +TEST_F(TcpListenerAccept, AcceptErrorRebuildsAfterTheBackoff) { + // accept() on a bad descriptor fails with EBADF, which no retry fixes. + this->listener_.set_listen(std::make_unique(-1)); + this->listener_.accept(this->link_); + EXPECT_FALSE(this->listener_.listening()); + EXPECT_TRUE(this->link_.in_backoff()); + // No new listen socket until the backoff has passed. + this->listener_.poll(this->link_, true); + EXPECT_FALSE(this->listener_.listening()); +} + +TEST_F(TcpListenerAccept, NothingPendingKeepsTheListener) { + this->listen_on_loopback(); + this->listener_.accept(this->link_); + EXPECT_TRUE(this->listener_.listening()); + EXPECT_FALSE(this->link_.connected()); + EXPECT_FALSE(this->link_.in_backoff()); +} + +TEST_F(TcpListenerAccept, AcceptedClientIsAdopted) { + this->connect_client(this->listen_on_loopback()); + this->listener_.accept(this->link_); + EXPECT_TRUE(this->link_.connected()); + EXPECT_TRUE(this->listener_.listening()); +} + +#ifdef USE_SOCKET_IPV4_ALLOW +TEST_F(TcpListenerAccept, PeerOutsideTheAllowListIsClosed) { + static const Ipv4AllowEntry ONLY_TEN[] = {{htonl(0x0A000000), htonl(0xFF000000)}}; + this->listener_.set_allow(ONLY_TEN, 1); + this->connect_client(this->listen_on_loopback()); + this->listener_.accept(this->link_); + EXPECT_FALSE(this->link_.connected()); + EXPECT_TRUE(this->listener_.listening()); + EXPECT_FALSE(this->link_.in_backoff()); + // The peer sees the close. + struct pollfd pfd { + this->client_fd_, POLLIN, 0 + }; + ASSERT_EQ(::poll(&pfd, 1, 1000), 1); + char b; + EXPECT_EQ(::read(this->client_fd_, &b, 1), 0); +} + +TEST_F(TcpListenerAccept, PeerInsideTheAllowListIsAdopted) { + static const Ipv4AllowEntry LOOPBACK_ONLY[] = {{htonl(INADDR_LOOPBACK), htonl(0xFFFFFFFF)}}; + this->listener_.set_allow(LOOPBACK_ONLY, 1); + this->connect_client(this->listen_on_loopback()); + this->listener_.accept(this->link_); + EXPECT_TRUE(this->link_.connected()); +} +#endif + +} // namespace esphome::socket::testing + +#endif From 2b02602a49515fcf06bbac86309c4afcd4382c3a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:37:14 -0600 Subject: [PATCH 073/128] Bump smpclient from 7.2.0 to 7.3.0 (#17443) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 744c5c1e669..1b719701e48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ resvg-py==0.5.0 freetype-py==2.5.1 jinja2==3.1.6 bleak==3.0.2 -smpclient==7.2.0 +smpclient==7.3.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.2 # native esp-idf toolchain global cache dir From 61ac0cb753e464dcee7bfa079a914033a5d7fc83 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:39:02 +0200 Subject: [PATCH 074/128] [tcp_uart] Keep received bytes readable after the connection drops (#20164) --- esphome/components/tcp_uart/tcp_uart.cpp | 7 +- esphome/components/tcp_uart/tcp_uart.h | 2 +- tests/components/tcp_uart/__init__.py | 8 + .../components/tcp_uart/test_buffers_host.cpp | 234 ++++++++++++++++++ 4 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 tests/components/tcp_uart/test_buffers_host.cpp diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp index 5913610ee84..3d3f8dfeba1 100644 --- a/esphome/components/tcp_uart/tcp_uart.cpp +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -46,7 +46,8 @@ void TcpUart::on_shutdown() { void TcpUart::sync_link_() { bool up = this->link_.connected(); this->link_was_up_ = up; - if (!up) { + if (up) { + // Unread bytes of the last session stay readable while down, never into the next one. this->rx_start_ = this->rx_end_ = 0; } if (this->connected_sensor_ != nullptr) { @@ -67,7 +68,7 @@ void TcpUart::read_socket_() { } ssize_t count = this->link_.read(this->rx_ + this->rx_end_, room); if (count <= 0) { - // A dropped link (-1) is cleaned up by sync_link_() on the next loop. + // A dropped link (-1) is seen by sync_link_() on the next loop. if (count == 0) { this->rx_pending_ = false; } @@ -81,7 +82,7 @@ void TcpUart::loop() { #ifdef USE_SOCKET_TCP_LISTENER if (this->server_) { // link_was_up_ holds the accept until the previous drop's edge has run, - // so the sensor and the cleared RX buffer always see the disconnect. + // so the sensor sees the disconnect and the new session's edge clears RX. this->listener_.poll(this->link_, !this->link_was_up_); } else { this->link_.poll(); diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h index e973c7a1891..4b2ff0aa228 100644 --- a/esphome/components/tcp_uart/tcp_uart.h +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -64,7 +64,7 @@ class TcpUart : public uart::UARTComponent, public Component { uint16_t rx_start_{0}; uint16_t rx_end_{0}; bool server_{false}; - // The link state loop() saw last; edges clear rx_ and publish the sensor. + // The link state loop() saw last; edges publish the sensor, the up edge clears rx_. bool link_was_up_{false}; // A read stopped before EAGAIN. ready() stays false until new data arrives. bool rx_pending_{false}; diff --git a/tests/components/tcp_uart/__init__.py b/tests/components/tcp_uart/__init__.py index 1e4de448b74..7090910cb23 100644 --- a/tests/components/tcp_uart/__init__.py +++ b/tests/components/tcp_uart/__init__.py @@ -1,7 +1,15 @@ # This file's presence makes pytest treat this directory as a package named # "tcp_uart"; required for cpp unit testing. +from esphome.components import socket as socket_component +from esphome.types import ConfigType from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: manifest.multi_conf = False + + async def to_code_testing(config: ConfigType) -> None: + # The server-role gtest needs the listener. + socket_component.require_tcp_listener() + + manifest.to_code = to_code_testing diff --git a/tests/components/tcp_uart/test_buffers_host.cpp b/tests/components/tcp_uart/test_buffers_host.cpp new file mode 100644 index 00000000000..3ec233eba2e --- /dev/null +++ b/tests/components/tcp_uart/test_buffers_host.cpp @@ -0,0 +1,234 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/tcp_uart/tcp_uart.h" +#include "esphome/core/application.h" +#include "esphome/core/wake.h" + +#ifdef USE_HOST + +namespace esphome::tcp_uart::testing { + +class TcpUartLoopDriver : public TcpUart { + public: + TcpUartLoopDriver() { + this->set_host("peer"); + this->set_port(1); + this->link_.begin("buffers_test"); + } + socket::TcpClientLink &link() { return this->link_; } +}; + +class TcpUartBuffers : public ::testing::Test { + protected: + void SetUp() override { + // EPIPE must come back as an errno, not a signal. + signal(SIGPIPE, SIG_IGN); + this->connect_peer(); + } + void TearDown() override { + this->close_peer(); + this->uart_.link().close(); + } + // Hands the UART a fresh session, as an accept or a reconnect would. + void connect_peer() { + int fds[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + this->peer_fd_ = fds[1]; + this->uart_.link().adopt(std::make_unique(fds[0])); + } + void close_peer() { + if (this->peer_fd_ >= 0) { + ::close(this->peer_fd_); + this->peer_fd_ = -1; + } + } + void send(const void *data, size_t len) { ASSERT_EQ(::write(this->peer_fd_, data, len), static_cast(len)); } + void loops(int count) { + for (int i = 0; i < count; i++) { + this->uart_.loop(); + } + } + + TcpUartLoopDriver uart_; + int peer_fd_{-1}; +}; + +TEST_F(TcpUartBuffers, BytesReceivedBeforeACloseStayReadable) { + this->loops(1); + this->send("HELLO-0123456789", 16); + this->close_peer(); + // Read the bytes, see the close, run the down edge, then idle. + this->loops(4); + ASSERT_FALSE(this->uart_.is_connected()); + ASSERT_EQ(this->uart_.available(), 16u); + uint8_t got[16]; + ASSERT_TRUE(this->uart_.read_array(got, sizeof(got))); + EXPECT_EQ(std::memcmp(got, "HELLO-0123456789", sizeof(got)), 0); + EXPECT_EQ(this->uart_.available(), 0u); +} + +TEST_F(TcpUartBuffers, UnreadBytesAreGoneWhenTheNextSessionStarts) { + this->loops(1); + this->send("OLD", 3); + this->close_peer(); + this->loops(4); + ASSERT_FALSE(this->uart_.is_connected()); + this->connect_peer(); + this->send("NEW", 3); + this->loops(1); + ASSERT_EQ(this->uart_.available(), 3u); + uint8_t got[3]; + ASSERT_TRUE(this->uart_.read_array(got, sizeof(got))); + EXPECT_EQ(std::memcmp(got, "NEW", sizeof(got)), 0); +} + +TEST_F(TcpUartBuffers, FullBufferWaitsAndCompactsAfterARead) { + static constexpr size_t TOTAL = 1500; + uint8_t data[TOTAL]; + for (size_t i = 0; i < TOTAL; i++) { + data[i] = static_cast(i % 251); + } + this->loops(1); + this->send(data, TOTAL); + this->loops(1); + ASSERT_EQ(this->uart_.available(), 1024u); + // Nothing read, no room: the rest stays in the socket. + this->loops(2); + ASSERT_EQ(this->uart_.available(), 1024u); + + uint8_t got[TOTAL]; + ASSERT_TRUE(this->uart_.read_array(got, 100)); + // The read freed the front; the next pass moves the rest down and refills. + this->loops(1); + ASSERT_EQ(this->uart_.available(), 1024u); + ASSERT_TRUE(this->uart_.read_array(got + 100, 1024)); + this->loops(1); + ASSERT_EQ(this->uart_.available(), TOTAL - 1124); + ASSERT_TRUE(this->uart_.read_array(got + 1124, TOTAL - 1124)); + EXPECT_EQ(std::memcmp(got, data, TOTAL), 0); + EXPECT_TRUE(this->uart_.is_connected()); +} + +TEST_F(TcpUartBuffers, WritesBeyondTheSendBufferAreDropped) { + static constexpr size_t TOTAL = 1500; + uint8_t data[TOTAL]; + for (size_t i = 0; i < TOTAL; i++) { + data[i] = static_cast(i % 251); + } + this->loops(1); + this->uart_.write_array(data, TOTAL); + EXPECT_EQ(this->uart_.available_for_write(), 0u); + EXPECT_EQ(this->uart_.flush(), uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS); + + uint8_t got[TOTAL]; + size_t total = 0; + while (total < 1024) { + ssize_t n = ::read(this->peer_fd_, got + total, sizeof(got) - total); + ASSERT_GT(n, 0); + total += static_cast(n); + } + EXPECT_EQ(total, 1024u); + EXPECT_EQ(std::memcmp(got, data, 1024), 0); + // The 476 bytes past the buffer never reach the peer. + EXPECT_EQ(::recv(this->peer_fd_, got, sizeof(got), MSG_DONTWAIT), -1); +} + +#ifdef USE_SOCKET_TCP_LISTENER +// Server role through the real listener on a loopback port. +class TcpUartServer : public ::testing::Test { + protected: + void SetUp() override { + signal(SIGPIPE, SIG_IGN); + // Find a free port for the listener. + int probe = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(probe, 0); + struct sockaddr_in addr = loopback(0); + ASSERT_EQ(::bind(probe, reinterpret_cast(&addr), sizeof(addr)), 0); + socklen_t len = sizeof(addr); + ASSERT_EQ(::getsockname(probe, reinterpret_cast(&addr), &len), 0); + ::close(probe); + this->port_ = ntohs(addr.sin_port); + this->uart_.set_server(true); + this->uart_.set_port(this->port_); + this->uart_.set_reconnect_interval(0); + this->uart_.setup(); + this->pass(); + } + void TearDown() override { + this->close_peer(); + this->uart_.on_shutdown(); + } + static struct sockaddr_in loopback(uint16_t port) { + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons(port); + return addr; + } + // One main loop pass: select() marks readable sockets, then the component runs. + void pass() { + internal::wakeable_delay(5); + this->now_ += 16; + LoopBlockingGuard dispatch{nullptr, nullptr, this->now_}; + this->uart_.loop(); + } + void connect_peer() { + this->peer_fd_ = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(this->peer_fd_, 0); + struct sockaddr_in addr = loopback(this->port_); + ASSERT_EQ(::connect(this->peer_fd_, reinterpret_cast(&addr), sizeof(addr)), 0); + for (int i = 0; i < 50 && !this->uart_.is_connected(); i++) + this->pass(); + ASSERT_TRUE(this->uart_.is_connected()); + } + void close_peer() { + if (this->peer_fd_ >= 0) { + ::close(this->peer_fd_); + this->peer_fd_ = -1; + } + } + void send(const void *data, size_t len) { ASSERT_EQ(::write(this->peer_fd_, data, len), static_cast(len)); } + void pass_until_available(size_t count) { + for (int i = 0; i < 50 && this->uart_.available() < count; i++) + this->pass(); + } + + TcpUart uart_; + uint16_t port_{0}; + int peer_fd_{-1}; + uint32_t now_{0}; +}; + +TEST_F(TcpUartServer, NextAcceptedClientStartsWithAnEmptyBuffer) { + this->connect_peer(); + this->send("OLD", 3); + this->pass_until_available(3); + ASSERT_EQ(this->uart_.available(), 3u); + this->close_peer(); + for (int i = 0; i < 50 && this->uart_.is_connected(); i++) + this->pass(); + ASSERT_FALSE(this->uart_.is_connected()); + // Unread bytes stay readable while no client is connected. + EXPECT_EQ(this->uart_.available(), 3u); + + this->connect_peer(); + this->send("NEW", 3); + this->pass_until_available(3); + ASSERT_EQ(this->uart_.available(), 3u); + uint8_t got[3]; + ASSERT_TRUE(this->uart_.read_array(got, sizeof(got))); + EXPECT_EQ(std::memcmp(got, "NEW", sizeof(got)), 0); +} +#endif + +} // namespace esphome::tcp_uart::testing + +#endif From 1520d228bc965d4ca447852f1091b3de715884e8 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 6 Oct 2026 12:43:49 -0500 Subject: [PATCH 075/128] [i2s_audio] Park the speaker data-out pin low at setup (#20226) Co-authored-by: Claude Fable 5.1 --- .../i2s_audio/speaker/i2s_audio_speaker.cpp | 14 +++++++++++--- .../i2s_audio/speaker/i2s_audio_speaker.h | 3 +++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 7ae58b3e440..4b74b2e29d4 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -40,6 +40,16 @@ void I2SAudioSpeakerBase::setup() { // When no audio_dac is configured, this initializes software volume control. this->set_volume(this->volume_); this->set_mute_state(this->mute_state_); + + // Until the I2S driver first starts, dout sits in its reset state (often pulled high, or a JTAG + // function on the ESP32-S3), which keeps a SPDIF optical transmitter lit. Park it low now. + this->park_dout_pin_(); +} + +void I2SAudioSpeakerBase::park_dout_pin_() { + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); } void I2SAudioSpeakerBase::dump_config() { @@ -303,9 +313,7 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() { // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. - gpio_reset_pin(this->dout_pin_); - gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); - gpio_set_level(this->dout_pin_, 0); + this->park_dout_pin_(); } this->parent_->unlock(); } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index b443166ea1b..965eede0201 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -129,6 +129,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Stops the I2S driver and unlocks the I2S port void stop_i2s_driver_(); + /// @brief Detaches dout from the I2S signal and drives it low + void park_dout_pin_(); + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} From 490d478e546b3a7328ceab102ea478d3eebdfd3c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:02:34 +1100 Subject: [PATCH 076/128] [mipi_spi] Light platform for AMOLED display brightness (#20212) Co-authored-by: Claude Opus 5.5 --- esphome/components/mipi/__init__.py | 5 +- esphome/components/mipi_spi/display.py | 4 +- esphome/components/mipi_spi/light/__init__.py | 68 ++++++++++ .../mipi_spi/light/mipi_spi_light.h | 36 +++++ esphome/components/mipi_spi/mipi_spi.h | 13 +- .../mipi_spi/test_get_sequence.py | 36 ++++- tests/component_tests/mipi_spi/test_light.py | 126 ++++++++++++++++++ tests/components/mipi_spi/common.yaml | 45 +++++++ 8 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 esphome/components/mipi_spi/light/__init__.py create mode 100644 esphome/components/mipi_spi/light/mipi_spi_light.h create mode 100644 tests/component_tests/mipi_spi/test_light.py diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 50b84b630d1..526afa914b5 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -648,7 +648,10 @@ class DriverChip: sequence.append((INVON,)) else: sequence.append((INVOFF,)) - if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): + brightness = config.get( + CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS, None) + ) + if brightness is not None: sequence.append((BRIGHTNESS, brightness)) # Add a SLPOUT command if required. if not self.skip_command("SLPOUT"): diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index b49675ca4c1..ff49979a244 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -225,10 +225,10 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema: ) .extend({model.option(x): cv.boolean for x in other_options}) ) - if brightness := model.get_default(CONF_BRIGHTNESS): + if model.get_default(CONF_BRIGHTNESS, None) is not None or model.name == "CUSTOM": schema = schema.extend( { - cv.Optional(CONF_BRIGHTNESS, default=brightness): cv.int_range( + model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.int_range( 0, 0xFF, min_included=True, max_included=True ), } diff --git a/esphome/components/mipi_spi/light/__init__.py b/esphome/components/mipi_spi/light/__init__.py new file mode 100644 index 00000000000..aec5d9b4fdf --- /dev/null +++ b/esphome/components/mipi_spi/light/__init__.py @@ -0,0 +1,68 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_DISPLAY_ID, + CONF_GAMMA_CORRECT, + CONF_MAX_BRIGHTNESS, + CONF_MIN_BRIGHTNESS, + CONF_MODEL, + CONF_OUTPUT_ID, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from ..display import MipiSpi, mipi_spi_ns + +MipiSpiLight = mipi_spi_ns.class_("MipiSpiLight", light.LightOutput) + + +def _validate_brightness_range(config: ConfigType) -> ConfigType: + if config[CONF_MIN_BRIGHTNESS] >= config[CONF_MAX_BRIGHTNESS]: + raise cv.Invalid( + f"'{CONF_MIN_BRIGHTNESS}' must be less than '{CONF_MAX_BRIGHTNESS}'" + ) + return config + + +CONFIG_SCHEMA = cv.All( + light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MipiSpiLight), + cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(MipiSpi), + cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float, + cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.int_range(0, 255), + cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.int_range(0, 255), + } + ), + _validate_brightness_range, +) + + +def _final_validate(config: ConfigType) -> None: + full_config = fv.full_config.get() + display_path = full_config.get_path_for_id(config[CONF_DISPLAY_ID])[:-1] + display_config = full_config.get_config_for_path(display_path) + if CONF_BRIGHTNESS not in display_config: + model = display_config[CONF_MODEL] + if model == "CUSTOM": + raise cv.Invalid( + f"The '{CONF_BRIGHTNESS}' option must be set in the display config" + ) + raise cv.Invalid( + f"Display model {model} does not support setting brightness with an SPI command" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_OUTPUT_ID], + config[CONF_MIN_BRIGHTNESS], + config[CONF_MAX_BRIGHTNESS], + ) + await light.register_light(var, config) + await cg.register_parented(var, config[CONF_DISPLAY_ID]) diff --git a/esphome/components/mipi_spi/light/mipi_spi_light.h b/esphome/components/mipi_spi/light/mipi_spi_light.h new file mode 100644 index 00000000000..30bbdcd8ee0 --- /dev/null +++ b/esphome/components/mipi_spi/light/mipi_spi_light.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" +#include "esphome/components/mipi_spi/mipi_spi.h" +#include "esphome/core/helpers.h" + +namespace esphome::mipi_spi { + +// Brightness-only light that sets the display brightness with an SPI command. +// Brightness is scaled into [min_brightness, max_brightness]; off writes min_brightness. +class MipiSpiLight final : public light::LightOutput, public Parented { + public: + MipiSpiLight(uint8_t min_brightness, uint8_t max_brightness) + : min_brightness_(min_brightness), max_brightness_(max_brightness) {} + + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + return traits; + } + + void write_state(light::LightState *state) override { + float brightness; + state->current_values_as_brightness(&brightness); + this->parent_->set_brightness( + this->min_brightness_ + + static_cast(roundf(brightness * (this->max_brightness_ - this->min_brightness_)))); + } + + protected: + uint8_t min_brightness_; + uint8_t max_brightness_; +}; + +} // namespace esphome::mipi_spi diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index e428a1f96e5..48a2cafbdd3 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -72,6 +72,12 @@ void internal_dump_config(const char *model, int width, int height, int offset_w GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width, bool has_hardware_rotation); +// Lets a light set the display brightness without knowing the display's template parameters. +class MipiSpiBrightness { + public: + virtual void set_brightness(uint8_t brightness) = 0; +}; + /** * Base class for MIPI SPI displays. * All the methods are defined here in the header file, as it is not possible to define templated methods in a cpp file. @@ -94,6 +100,7 @@ template class MipiSpi : public display::Display, + public MipiSpiBrightness, public spi::SPIDevice { public: @@ -108,9 +115,11 @@ class MipiSpi : public display::Display, this->invert_colors_ = invert_colors; this->reset_params_(); } - void set_brightness(uint8_t brightness) { + void set_brightness(uint8_t brightness) override { this->brightness_ = brightness; - this->reset_params_(); + // Before setup the stored value is applied by reset_params_() + if (this->is_ready()) + this->write_command_(BRIGHTNESS, brightness); } void set_rotation(display::DisplayRotation rotation) override { this->rotation_ = rotation; diff --git a/tests/component_tests/mipi_spi/test_get_sequence.py b/tests/component_tests/mipi_spi/test_get_sequence.py index 263b567b9c9..f8614b17a3a 100644 --- a/tests/component_tests/mipi_spi/test_get_sequence.py +++ b/tests/component_tests/mipi_spi/test_get_sequence.py @@ -4,7 +4,13 @@ from collections.abc import Generator import pytest -from esphome.components.mipi import CONF_INVERT_COLORS, CONF_PIXEL_MODE, DriverChip +from esphome.components.mipi import ( + BRIGHTNESS, + CONF_INVERT_COLORS, + CONF_PIXEL_MODE, + DriverChip, +) +from esphome.const import CONF_BRIGHTNESS # A minimal config with no reset pin: enough for get_sequence(add_madctl=False) to run # without needing a full display configuration. @@ -62,3 +68,31 @@ def test_get_sequence_skips_reset_delay_validation_without_add_reset() -> None: chip = DriverChip("TEST-GET-SEQUENCE-NO-RESET", reset_delay=999) chip.get_sequence(_BASE_CONFIG, add_madctl=False, add_reset=False) + + +def _has_brightness_command(sequence: tuple[int, ...], value: int) -> bool: + """True if the flattened sequence holds a one-byte brightness (0x51) command.""" + return any( + sequence[i : i + 3] == (BRIGHTNESS, 1, value) for i in range(len(sequence) - 2) + ) + + +@pytest.mark.parametrize("brightness", [0, 0x80]) +def test_get_sequence_adds_configured_brightness(brightness: int) -> None: + """A configured brightness is sent at startup, including zero.""" + chip = DriverChip("TEST-GET-SEQUENCE-BRIGHTNESS") + + sequence = chip.get_sequence( + {**_BASE_CONFIG, CONF_BRIGHTNESS: brightness}, add_madctl=False + ) + + assert _has_brightness_command(sequence, brightness) + + +def test_get_sequence_omits_brightness_when_not_set() -> None: + """No brightness command is sent when neither config nor model sets one.""" + chip = DriverChip("TEST-GET-SEQUENCE-NO-BRIGHTNESS") + + sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False) + + assert not any(_has_brightness_command(sequence, v) for v in range(256)) diff --git a/tests/component_tests/mipi_spi/test_light.py b/tests/component_tests/mipi_spi/test_light.py new file mode 100644 index 00000000000..c42b56cf5ae --- /dev/null +++ b/tests/component_tests/mipi_spi/test_light.py @@ -0,0 +1,126 @@ +"""Tests for the mipi_spi light platform validation.""" + +from __future__ import annotations + +import pytest + +from esphome import final_validate +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi_spi.display import CONFIG_SCHEMA as DISPLAY_SCHEMA +from esphome.components.mipi_spi.light import ( + CONFIG_SCHEMA as LIGHT_SCHEMA, + FINAL_VALIDATE_SCHEMA as LIGHT_FINAL_VALIDATE_SCHEMA, +) +from esphome.config import Config +import esphome.config_validation as cv +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_DISPLAY_ID, + CONF_GAMMA_CORRECT, + CONF_ID, + CONF_MAX_BRIGHTNESS, + CONF_MIN_BRIGHTNESS, + PlatformFramework, +) +from esphome.core import ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +DISPLAY_ID = "test_display" + +_CUSTOM: ConfigType = { + "model": "custom", + "dimensions": {"width": 240, "height": 240}, + "init_sequence": [[0xA0, 0x01]], +} + + +@pytest.fixture(autouse=True) +def _esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def _light(**extra: object) -> ConfigType: + return { + "name": "Display Brightness", + CONF_DISPLAY_ID: DISPLAY_ID, + **extra, + } + + +def _final_validate_with_display(display: ConfigType) -> None: + """Validate a display, place it in the full config, then final-validate a light on it.""" + display = DISPLAY_SCHEMA( + {"id": DISPLAY_ID, "dc_pin": 18, **display}, + ) + full = Config() + full["display"] = [display] + full.declare_ids.append((display[CONF_ID], ["display", 0, CONF_ID])) + final_validate.full_config.set(full) + LIGHT_FINAL_VALIDATE_SCHEMA({CONF_DISPLAY_ID: ID(DISPLAY_ID, is_declaration=False)}) + + +def test_light_defaults() -> None: + """Gamma defaults to 1.0 and the range to the full 0-255.""" + config = LIGHT_SCHEMA(_light()) + + assert config[CONF_GAMMA_CORRECT] == 1.0 + assert config[CONF_MIN_BRIGHTNESS] == 0 + assert config[CONF_MAX_BRIGHTNESS] == 255 + + +@pytest.mark.parametrize( + ("min_brightness", "max_brightness"), + [(100, 100), (200, 100)], + ids=["equal", "inverted"], +) +def test_light_rejects_bad_range(min_brightness: int, max_brightness: int) -> None: + with pytest.raises( + cv.Invalid, match="'min_brightness' must be less than 'max_brightness'" + ): + LIGHT_SCHEMA( + _light(min_brightness=min_brightness, max_brightness=max_brightness) + ) + + +@pytest.mark.parametrize("key", [CONF_MIN_BRIGHTNESS, CONF_MAX_BRIGHTNESS]) +def test_light_rejects_out_of_range_value(key: str) -> None: + with pytest.raises(cv.Invalid): + LIGHT_SCHEMA(_light(**{key: 256})) + + +@pytest.mark.parametrize( + "display", + [ + pytest.param({"model": "rm690b0"}, id="model_with_default_brightness"), + pytest.param({**_CUSTOM, CONF_BRIGHTNESS: 0}, id="custom_with_brightness"), + ], +) +def test_light_accepts_display_with_brightness(display: ConfigType) -> None: + _final_validate_with_display(display) + + +def test_light_rejects_model_without_brightness() -> None: + with pytest.raises( + cv.Invalid, + match="Display model ILI9488 does not support setting brightness", + ): + _final_validate_with_display({"model": "ili9488"}) + + +def test_light_rejects_custom_without_brightness() -> None: + with pytest.raises( + cv.Invalid, + match="The 'brightness' option must be set in the display config", + ): + _final_validate_with_display(_CUSTOM) + + +def test_brightness_not_allowed_on_model_without_default() -> None: + with pytest.raises(cv.Invalid, match=r"extra keys not allowed.*brightness"): + DISPLAY_SCHEMA( + {"id": DISPLAY_ID, "dc_pin": 18, "model": "ili9488", CONF_BRIGHTNESS: 10} + ) diff --git a/tests/components/mipi_spi/common.yaml b/tests/components/mipi_spi/common.yaml index 6bdee9e54a6..45815b1ba5e 100644 --- a/tests/components/mipi_spi/common.yaml +++ b/tests/components/mipi_spi/common.yaml @@ -53,3 +53,48 @@ display: reset_pin: allow_other_uses: true number: ${reset_pin} + + - platform: mipi_spi + id: amoled_display + model: RM690B0 + dc_pin: + allow_other_uses: true + number: ${dc_pin} + cs_pin: + allow_other_uses: true + number: ${cs_pin} + reset_pin: + allow_other_uses: true + number: ${reset_pin} + + - platform: mipi_spi + id: custom_display + model: custom + brightness: 0x80 + dimensions: + width: 240 + height: 240 + init_sequence: + - [0xd0, 1] + dc_pin: + allow_other_uses: true + number: ${dc_pin} + cs_pin: + allow_other_uses: true + number: ${cs_pin} + reset_pin: + allow_other_uses: true + number: ${reset_pin} + +light: + - platform: mipi_spi + id: display_brightness + name: Display Brightness + display_id: amoled_display + + - platform: mipi_spi + id: custom_display_brightness + name: Custom Display Brightness + display_id: custom_display + min_brightness: 16 + max_brightness: 200 From 194273955c62fc2b269d9c2889654a8d9885133c Mon Sep 17 00:00:00 2001 From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:22:00 +1000 Subject: [PATCH 077/128] [tas58xx] Add support for tas5825 dac (#19971) --- esphome/components/tas58xx/__init__.py | 9 ++ esphome/components/tas58xx/audio_dac.py | 15 ++- esphome/components/tas58xx/binary_sensor.py | 46 ++++++- esphome/components/tas58xx/model_tas5805m.cpp | 28 +++-- esphome/components/tas58xx/model_tas5825m.cpp | 119 ++++++++++++++++++ esphome/components/tas58xx/tas58xx.cpp | 7 +- esphome/components/tas58xx/tas58xx.h | 32 ++++- tests/components/tas58xx/common.yaml | 17 ++- 8 files changed, 247 insertions(+), 26 deletions(-) create mode 100644 esphome/components/tas58xx/model_tas5825m.cpp diff --git a/esphome/components/tas58xx/__init__.py b/esphome/components/tas58xx/__init__.py index b87036984dc..0931d7ba1f3 100644 --- a/esphome/components/tas58xx/__init__.py +++ b/esphome/components/tas58xx/__init__.py @@ -1 +1,10 @@ +from esphome.config_helpers import filter_source_files_from_defines + CODEOWNERS = ["@mrtoy-me", "@remcom"] + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "model_tas5805m.cpp": "USE_TAS58XX_TAS5805M", + "model_tas5825m.cpp": "USE_TAS58XX_TAS5825M", + } +) diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 18a60a66455..7b1e3e239e8 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -11,6 +11,8 @@ from esphome.const import CONF_ENABLE_PIN, CONF_ID, CONF_MODEL from esphome.cpp_generator import MockObj from esphome.types import ConfigType +from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import + _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["i2c"] @@ -23,6 +25,9 @@ CONF_VOLUME_MIN_DB = "volume_min_db" CONF_VOLUME_MAX_DB = "volume_max_db" CONF_TAS58XX_ID = "tas58xx_id" +DAC_TAS5805M = "tas5805m" +DAC_TAS5825M = "tas5825m" + tas58xx_ns = cg.esphome_ns.namespace("tas58xx") TAS58xx = tas58xx_ns.class_("TAS58xx", AudioDac, cg.PollingComponent, i2c.I2CDevice) @@ -54,13 +59,20 @@ class Model: MODELS: dict[str, Model] = { - "tas5805m": Model( + DAC_TAS5805M: Model( model_info=tas58xx_ns.TAS5805M_MODEL, analog_gain_min_db=-15.5, volume_min_db=-103.0, volume_max_db=24.0, default_address=0x2D, ), + DAC_TAS5825M: Model( + model_info=tas58xx_ns.TAS5825M_MODEL, + analog_gain_min_db=-15.5, + volume_min_db=-103.0, + volume_max_db=24.0, + default_address=0x4C, + ), } @@ -162,6 +174,7 @@ for _name, _call in ( async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL]] + cg.add_define(f"USE_TAS58XX_{config[CONF_MODEL].upper()}") var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&{model.model_info}")) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/tas58xx/binary_sensor.py b/esphome/components/tas58xx/binary_sensor.py index 398cfa2440a..d0577398741 100644 --- a/esphome/components/tas58xx/binary_sensor.py +++ b/esphome/components/tas58xx/binary_sensor.py @@ -1,15 +1,17 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.const import CONF_MODEL, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +import esphome.final_validate as fv from esphome.types import ConfigType -from .audio_dac import CONF_TAS58XX_ID, TAS58xx, tas58xx_ns +from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import +from .audio_dac import CONF_TAS58XX_ID, DAC_TAS5825M, TAS58xx, tas58xx_ns CONF_HAVE_FAULT = "have_fault" -# Each name matches a FaultSensor value in tas58xx.h -FAULT_SENSORS = ( +# Faults common to TAS5805M and TAS5825M +COMMON_FAULT_SENSORS = ( "left_channel_dc_fault", "right_channel_dc_fault", "left_channel_over_current", @@ -20,8 +22,22 @@ FAULT_SENSORS = ( "pvdd_over_voltage", "pvdd_under_voltage", "over_temp_shutdown", - "over_temp_warning", + "over_temp_warning", # tas582x OTW Level 3 - use tas5805 naming ) +# Additional faults available on TAS5825M +TAS5825M_ONLY_FAULT_SENSORS = ( + "load_eeprom_error", + "right_channel_cbc_over_current", + "left_channel_cbc_over_current", + "left_channel_cbc_over_current_warning", + "right_channel_cbc_over_current_warning", + "over_temp_146c_warning", # tas582x OTW Level 4 + # "over_temp_122c_warning", # tas582x OTW Level 2 - not currently included + # "over_temp_112c_warning", # tas582x OTW Level 1 - not currently included +) + +# Each name matches a FaultSensor value in tas58xx.h +FAULT_SENSORS = COMMON_FAULT_SENSORS + TAS5825M_ONLY_FAULT_SENSORS FaultSensor = tas58xx_ns.enum("FaultSensor") @@ -39,6 +55,26 @@ CONFIG_SCHEMA = cv.Schema( ) +def _final_validate(config: ConfigType) -> ConfigType: + fconf = fv.full_config.get() + hub_path = fconf.get_path_for_id(config[CONF_TAS58XX_ID]) + hub_conf = fconf.get_config_for_path(hub_path[:-1]) + + if hub_conf[CONF_MODEL] == DAC_TAS5825M: + return config + + unsupported = sorted(frozenset(TAS5825M_ONLY_FAULT_SENSORS).intersection(config)) + if unsupported: + raise cv.Invalid( + f"binary_sensor platform tas58xx: '{', '.join(unsupported)}' is only available for 'model: {DAC_TAS5825M}' " + f"- Remove from YAML for 'model: {hub_conf[CONF_MODEL]}'" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_TAS58XX_ID]) binary_sensors = binary_sensor.sub_binary_sensors(config) diff --git a/esphome/components/tas58xx/model_tas5805m.cpp b/esphome/components/tas58xx/model_tas5805m.cpp index e5eb5a837e1..de0a0c5d750 100644 --- a/esphome/components/tas58xx/model_tas5805m.cpp +++ b/esphome/components/tas58xx/model_tas5805m.cpp @@ -67,17 +67,23 @@ const ModelInfo TAS5805M_MODEL = { .fault_name = fault_name, .fault_sensor_bits = { - 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT - 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT - 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT - 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT - 15, // FAULT_SENSOR_OTP_CRC_CHECK - 14, // FAULT_SENSOR_BQ_WRITE_FAILED - 10, // FAULT_SENSOR_CLOCK_FAULT - 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE - 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE - 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN - 26, // FAULT_SENSOR_OVER_TEMP_WARNING + 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT + 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT + 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT + 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT + 15, // FAULT_SENSOR_OTP_CRC_CHECK + 14, // FAULT_SENSOR_BQ_WRITE_FAILED + 10, // FAULT_SENSOR_CLOCK_FAULT + 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE + 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE + 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN + 26, // FAULT_SENSOR_OVER_TEMP_WARNING + NO_BIT, // FAULT_SENSOR_LOAD_EEPROM_ERROR + NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT + NO_BIT, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT + NO_BIT, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING + NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING + NO_BIT, // FAULT_SENSOR_OVER_TEMP_146C_WARNING }, }; diff --git a/esphome/components/tas58xx/model_tas5825m.cpp b/esphome/components/tas58xx/model_tas5825m.cpp new file mode 100644 index 00000000000..3aa74c675e5 --- /dev/null +++ b/esphome/components/tas58xx/model_tas5825m.cpp @@ -0,0 +1,119 @@ +#include "tas58xx.h" + +#include "esphome/core/hal.h" + +namespace esphome::tas58xx { + +// Remainder of the startup sequence, run after the reset. Register 0x00 selects the page. +// All writes except starting and last three register writes are exactly the startup sequence from TI PurePath Console. +// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and Page 2 registers 0x1D, 0x19 are not documented in the datasheet. + +// clang-format off +static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { + {0x03, 0x00}, // Deep Sleep + {0x7D, 0x11}, + {0x7E, 0xFF}, + {0x00, 0x01}, // Page 1 + {0x51, 0x05}, + {0x00, 0x02}, // Page 2 + {0x1D, 0x00}, + {0x19, 0x80}, + {0x00, 0x00}, // Page 0 + {0x46, 0x11}, + {0x02, 0x00}, // DEVICE_CTRL_1 - BD MODE, Damp to PBL MODE + {0x53, 0x01}, // ANA_CTRL PWM Phase Control - in phase + {0x54, 0x00}, // AGAIN 0dB + {0x03, 0x02}, // Hi-Z + {0x61, 0x0B}, // GPIO0_SEL - GPIO0 as FAULTZ output + {0x60, 0x01}, // GPIO0_OE - Enable GPIO0 as output + {0x77, 0x07}, // CBC_CONTROL enabling CBC function for warnings and faults +}; +// clang-format on + +static const LogString *model_name() { return LOG_STR("TAS5825M"); } + +// An if chain rather than a switch: a switch table would land in rodata, which is RAM on ESP8266. +static const LogString *fault_name(uint8_t index) { + if (index == 0) + return LOG_STR("Right channel over current"); + if (index == 1) + return LOG_STR("Left channel over current"); + if (index == 2) + return LOG_STR("Right channel DC fault"); + if (index == 3) + return LOG_STR("Left channel DC fault"); + if (index == 8) + return LOG_STR("PVDD under voltage"); + if (index == 9) + return LOG_STR("PVDD over voltage"); + if (index == 10) + return LOG_STR("Clock fault"); + if (index == 13) + return LOG_STR("Load EEPROM error"); + if (index == 14) + return LOG_STR("BQ write failed"); + if (index == 15) + return LOG_STR("OTP CRC check error"); + if (index == 16) + return LOG_STR("Over temperature shutdown"); + if (index == 17) + return LOG_STR("Left channel CBC over current"); + if (index == 18) + return LOG_STR("Right channel CBC over current"); + // if (index == 24) + // return LOG_STR("Over temperature warning 112C"); // not currently included + // if (index == 25) + // return LOG_STR("Over temperature warning 122C"); // not currently included + if (index == 26) + return LOG_STR("Over temperature warning"); + if (index == 27) + return LOG_STR("Over temperature warning 146C"); + if (index == 28) + return LOG_STR("Right channel CBC over current warning"); + if (index == 29) + return LOG_STR("Left channel CBC over current warning"); + return LOG_STR("Unknown fault"); +} + +const ModelInfo TAS5825M_MODEL = { + .name = model_name, + .startup_sequence = STARTUP_SEQUENCE, + .startup_sequence_length = sizeof(STARTUP_SEQUENCE) / sizeof(STARTUP_SEQUENCE[0]), + .mixer_book = 0x8C, + .mixer_page = 0x0B, + .mixer_register = 0x14, + // The clock fault is left out of the log and have_fault: it is set whenever the I2S clock stops, which is normal + .fault_error_mask = 0x0007E30F, + .fault_warning_mask = + 0x3C000000, // 0x3F000000 if OVER_TEMP_122C_WARNING and OVER_TEMP_112C_WARNING included in future + // DC and over current faults keep the output off until cleared (datasheet 7.5.3.3.1, 7.5.3.3.2). They are not + // cleared automatically: a DC fault re-trips only after 570 ms, so a clear on every poll would pass DC to the + // speaker. + .fault_output_off_mask = 0x0000000F, + .fault_latched_mask = 0x3F07E70F, + .fault_name = fault_name, + .fault_sensor_bits = + { + 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT + 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT + 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT + 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT + 15, // FAULT_SENSOR_OTP_CRC_CHECK + 14, // FAULT_SENSOR_BQ_WRITE_FAILED + 10, // FAULT_SENSOR_CLOCK_FAULT + 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE + 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE + 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN + 26, // FAULT_SENSOR_OVER_TEMP_WARNING + 13, // FAULT_SENSOR_LOAD_EEPROM_ERROR + 18, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT + 17, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT + 29, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING + 28, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING + 27, // FAULT_SENSOR_OVER_TEMP_146C_WARNING + // 25, // FAULT_SENSOR_OVER_TEMP_122C_WARNING // not currently included + // 24, // FAULT_SENSOR_OVER_TEMP_112C_WARNING // not currently included + }, +}; + +} // namespace esphome::tas58xx diff --git a/esphome/components/tas58xx/tas58xx.cpp b/esphome/components/tas58xx/tas58xx.cpp index 458150a1254..d042d42e24e 100644 --- a/esphome/components/tas58xx/tas58xx.cpp +++ b/esphome/components/tas58xx/tas58xx.cpp @@ -196,8 +196,9 @@ bool TAS58xx::read_faults_() { if (this->have_fault_binary_sensor_ != nullptr) this->have_fault_binary_sensor_->publish_state((active & model.fault_error_mask) != 0); for (uint8_t fault = 0; fault < FAULT_SENSOR_COUNT; fault++) { - if (this->fault_binary_sensors_[fault] != nullptr) - this->fault_binary_sensors_[fault]->publish_state(faults & (uint32_t{1} << model.fault_sensor_bits[fault])); + const uint8_t bit = model.fault_sensor_bits[fault]; + if (this->fault_binary_sensors_[fault] != nullptr && bit != NO_BIT) + this->fault_binary_sensors_[fault]->publish_state(faults & (uint32_t{1} << bit)); } #endif @@ -264,7 +265,7 @@ void TAS58xx::dump_config() { " Analog Gain: %.1f dB\n" " DAC Mode: %s\n" " Mixer Mode: %s\n" - " Volume Range: %.1f dB - %.1f dB", + " Volume Range: %.1f dB to %.1f dB", this->analog_gain_db_, this->dac_mode_ == DAC_MODE_PBTL ? LOG_STR_LITERAL("PBTL") : LOG_STR_LITERAL("BTL"), LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_); diff --git a/esphome/components/tas58xx/tas58xx.h b/esphome/components/tas58xx/tas58xx.h index b1e9a531ddc..d0f97a5cd9f 100644 --- a/esphome/components/tas58xx/tas58xx.h +++ b/esphome/components/tas58xx/tas58xx.h @@ -27,9 +27,11 @@ enum MixerMode : uint8_t { MIXER_MODE_RIGHT, }; -/// Fault binary sensors that map to a single fault bit. The Python FAULT_SENSORS list uses the same names. +/// Fault binary sensors that map to a single fault bit. Note that ordering is important. +/// The Python FAULT_SENSORS list in binary_sensor.py uses the same names and ordering. enum FaultSensor : uint8_t { - FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT = 0, + // Faults common to TAS5805M and TAS5825M + FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT, FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT, FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT, FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT, @@ -40,9 +42,26 @@ enum FaultSensor : uint8_t { FAULT_SENSOR_PVDD_UNDER_VOLTAGE, FAULT_SENSOR_OVER_TEMP_SHUTDOWN, FAULT_SENSOR_OVER_TEMP_WARNING, - FAULT_SENSOR_COUNT, + // Faults available for TAS5825M Only + FAULT_SENSOR_LOAD_EEPROM_ERROR, + FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT, + FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT, + FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING, + FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING, + FAULT_SENSOR_OVER_TEMP_146C_WARNING, + // FAULT_SENSOR_OVER_TEMP_122C_WARNING, + // FAULT_SENSOR_OVER_TEMP_112C_WARNING, + FAULT_SENSOR_COUNT, // keep last }; +// compile time check +static_assert( + FAULT_SENSOR_COUNT == 17, + "enum FaultSensor altered: Update FAULT_SENSORS in binary_sensor.py and fault_sensor_bits in each model's cpp"); + +/// ModelInfo::fault_sensor_bits value for a FaultSensor that the model does not have. +static constexpr uint8_t NO_BIT = 0xFF; + /// Everything that differs between models of the family. One constant instance exists per model, see /// model_*.cpp, and each TAS58xx instance points to the one for its model. /// @@ -67,11 +86,16 @@ struct ModelInfo { /// Faults that stay set after the condition is gone and are cleared after each read uint32_t fault_latched_mask; const LogString *(*fault_name)(uint8_t index); - /// Bit index for each FaultSensor + /// Bit index for each FaultSensor, NO_BIT used where the model does not have that fault bit uint8_t fault_sensor_bits[FAULT_SENSOR_COUNT]; }; +#ifdef USE_TAS58XX_TAS5805M extern const ModelInfo TAS5805M_MODEL; +#endif +#ifdef USE_TAS58XX_TAS5825M +extern const ModelInfo TAS5825M_MODEL; +#endif class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c::I2CDevice { public: diff --git a/tests/components/tas58xx/common.yaml b/tests/components/tas58xx/common.yaml index e12ddf75671..afe38fef21f 100644 --- a/tests/components/tas58xx/common.yaml +++ b/tests/components/tas58xx/common.yaml @@ -12,10 +12,9 @@ audio_dac: volume_max_db: 0dB update_interval: 1s - platform: tas58xx - model: TAS5805M + model: TAS5825M id: tas58xx_amp_2 i2c_id: i2c_bus - address: 0x2C ignore_enable_pin_warning: true esphome: @@ -59,3 +58,17 @@ binary_sensor: name: Second Amp Any Fault left_channel_dc_fault: name: Second Amp Left Channel DC Fault + left_channel_cbc_over_current: + name: Left Channel CBC Current Fault + right_channel_cbc_over_current: + name: Right Channel CBC Current Fault + over_temp_shutdown: + name: Over Temperature Shutdown Fault + left_channel_cbc_over_current_warning: + name: Left Channel CBC Current Warning + right_channel_cbc_over_current_warning: + name: Right Channel CBC Current Warning + over_temp_146c_warning: + name: Over Temperature 146C Warning + over_temp_warning: + name: Over Temperature 134C Warning From 4eeb110247c089e77658a899f2e972f11f42085d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Oct 2026 15:26:46 -0500 Subject: [PATCH 078/128] [esp32] Extract prefetched ESP-IDF tool archives in parallel (#18841) --- esphome/espidf/_tool_resolution.py | 67 +++ esphome/espidf/framework.py | 78 +++- esphome/espidf/get_tool_downloads.py | 79 +--- esphome/espidf/install_tool_archives.py | 141 ++++++ .../fixtures/idf_tools_stub/idf_tools.py | 50 +- tests/unit_tests/test_espidf_framework.py | 434 +++++++++++++++++- 6 files changed, 757 insertions(+), 92 deletions(-) create mode 100644 esphome/espidf/_tool_resolution.py create mode 100644 esphome/espidf/install_tool_archives.py diff --git a/esphome/espidf/_tool_resolution.py b/esphome/espidf/_tool_resolution.py new file mode 100644 index 00000000000..624a07fa25b --- /dev/null +++ b/esphome/espidf/_tool_resolution.py @@ -0,0 +1,67 @@ +"""Shared tool resolution for the sibling idf_tools-backed scripts. + +Importable because ``python